feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
fetchUserKeyBlob,
|
fetchUserKeyBlob,
|
||||||
getOwnProfile,
|
getOwnProfile,
|
||||||
|
listOwnDevices,
|
||||||
|
registerDevice,
|
||||||
signOut as supabaseSignOut,
|
signOut as supabaseSignOut,
|
||||||
|
touchDeviceLastSeen,
|
||||||
type Profile,
|
type Profile,
|
||||||
updateOwnProfile,
|
updateOwnProfile,
|
||||||
} from '@chat-app/shared/auth';
|
} from '@chat-app/shared/auth';
|
||||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||||
|
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||||
import type { Session } from '@supabase/supabase-js';
|
import type { Session } from '@supabase/supabase-js';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -19,6 +23,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
|
||||||
import { ensureInstallId } from '../lib/installId';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { wipeLocalState } from '../lib/memoryWipe';
|
import { wipeLocalState } from '../lib/memoryWipe';
|
||||||
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
|
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
|
||||||
@@ -50,6 +55,8 @@ interface AuthContextValue {
|
|||||||
refreshProfile: () => Promise<void>;
|
refreshProfile: () => Promise<void>;
|
||||||
refreshUserKeyState: () => Promise<void>;
|
refreshUserKeyState: () => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
revokedRemotely: boolean;
|
||||||
|
acknowledgeRevocation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@@ -60,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||||
|
const [revokedRemotely, setRevokedRemotely] = useState(false);
|
||||||
const autoOnlineUserRef = useRef<string | null>(null);
|
const autoOnlineUserRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Initial session + auth subscription. We verify the cached JWT against the
|
// Initial session + auth subscription. We verify the cached JWT against the
|
||||||
@@ -196,6 +204,58 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
void registerWebPush(installId);
|
void registerWebPush(installId);
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||||
|
// pure session-list telemetry — it does not carry any cryptographic
|
||||||
|
// material since the per-user-key refactor. We re-use the row across
|
||||||
|
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
|
||||||
|
// intentionally treated as "new install".
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const existing = getDeviceRowId();
|
||||||
|
if (existing) {
|
||||||
|
const rows = await listOwnDevices(supabase);
|
||||||
|
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
|
||||||
|
if (match) {
|
||||||
|
await touchDeviceLastSeen(supabase, existing).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Row gone / revoked: drop the stale id and fall through to
|
||||||
|
// registering a fresh one.
|
||||||
|
clearDeviceRowId();
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
const hostname =
|
||||||
|
(typeof window.electronAPI?.getHostname === 'function'
|
||||||
|
? await window.electronAPI.getHostname().catch(() => null)
|
||||||
|
: null) ?? 'Desktop';
|
||||||
|
// Map Node's process.platform values to the schema's device_platform
|
||||||
|
// enum ('windows' | 'macos' | 'linux' | 'ios' | 'android'). Default
|
||||||
|
// to 'linux' for unknown/web-build cases — the field is only used
|
||||||
|
// for the session-list UI icon.
|
||||||
|
const osPlatform = window.electronAPI?.osPlatform;
|
||||||
|
const platform: DevicePlatform =
|
||||||
|
osPlatform === 'win32'
|
||||||
|
? 'windows'
|
||||||
|
: osPlatform === 'darwin'
|
||||||
|
? 'macos'
|
||||||
|
: 'linux';
|
||||||
|
const created = await registerDevice(supabase, {
|
||||||
|
name: hostname.slice(0, 64),
|
||||||
|
platform,
|
||||||
|
});
|
||||||
|
if (!cancelled) setDeviceRowId(created.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('ensure device row failed', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
// Wipe-on-close: register a handler the main process pings on `before-quit`
|
// Wipe-on-close: register a handler the main process pings on `before-quit`
|
||||||
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
|
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
|
||||||
// Electron (web build has no preload bridge) or when the toggle is off.
|
// Electron (web build has no preload bridge) or when the toggle is off.
|
||||||
@@ -257,6 +317,46 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
await wipeLocalState(uid);
|
await wipeLocalState(uid);
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
const acknowledgeRevocation = useCallback(() => {
|
||||||
|
setRevokedRemotely(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Phase 3: listen for own-device revocations. The same channel also fires
|
||||||
|
// when *another* of the user's installs is revoked — we ignore those (we
|
||||||
|
// only force-sign-out when OUR row's revoked_at flips). The UI's device
|
||||||
|
// list refetches independently via its own subscription in useOwnDevices.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
const userId = session.user.id;
|
||||||
|
const channel = supabase
|
||||||
|
.channel('devices:self:' + userId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'UPDATE',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'devices',
|
||||||
|
filter: 'user_id=eq.' + userId,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
const ownId = getDeviceRowId();
|
||||||
|
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
|
||||||
|
if (!row || !ownId) return;
|
||||||
|
if (row.id !== ownId) return;
|
||||||
|
if (row.revoked_at) {
|
||||||
|
setRevokedRemotely(true);
|
||||||
|
void signOut().catch((err) => {
|
||||||
|
console.warn('forced signOut after revoke failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
return () => {
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [session, signOut]);
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
session,
|
session,
|
||||||
@@ -266,8 +366,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
refreshProfile,
|
refreshProfile,
|
||||||
refreshUserKeyState,
|
refreshUserKeyState,
|
||||||
signOut,
|
signOut,
|
||||||
|
revokedRemotely,
|
||||||
|
acknowledgeRevocation,
|
||||||
}),
|
}),
|
||||||
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
|
[
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
userKeyState,
|
||||||
|
ready,
|
||||||
|
refreshProfile,
|
||||||
|
refreshUserKeyState,
|
||||||
|
signOut,
|
||||||
|
revokedRemotely,
|
||||||
|
acknowledgeRevocation,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// localStorage key for "the devices.id row that belongs to THIS install".
|
||||||
|
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
|
||||||
|
// fresh install, so registering a new row is correct.
|
||||||
|
|
||||||
|
const KEY = 'chatapp.deviceRowId.v1';
|
||||||
|
|
||||||
|
export function getDeviceRowId(): string | null {
|
||||||
|
try {
|
||||||
|
const v = window.localStorage.getItem(KEY);
|
||||||
|
return v && v.length > 0 ? v : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDeviceRowId(id: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(KEY, id);
|
||||||
|
} catch {
|
||||||
|
/* quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDeviceRowId(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(KEY);
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user