From a4f7a16c906e8c0c9330f5c6a73d8406a43ba7c4 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 19:08:17 +0200 Subject: [PATCH] feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag --- apps/desktop/src/context/AuthContext.tsx | 114 ++++++++++++++++++++++- apps/desktop/src/lib/deviceRowId.ts | 30 ++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/deviceRowId.ts diff --git a/apps/desktop/src/context/AuthContext.tsx b/apps/desktop/src/context/AuthContext.tsx index d554126..6adc9a0 100644 --- a/apps/desktop/src/context/AuthContext.tsx +++ b/apps/desktop/src/context/AuthContext.tsx @@ -1,11 +1,15 @@ import { fetchUserKeyBlob, getOwnProfile, + listOwnDevices, + registerDevice, signOut as supabaseSignOut, + touchDeviceLastSeen, type Profile, updateOwnProfile, } from '@chat-app/shared/auth'; import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n'; +import type { DevicePlatform } from '@chat-app/shared/supabase'; import type { Session } from '@supabase/supabase-js'; import { createContext, @@ -19,6 +23,7 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; +import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId'; import { ensureInstallId } from '../lib/installId'; import { wipeLocalState } from '../lib/memoryWipe'; import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings'; @@ -50,6 +55,8 @@ interface AuthContextValue { refreshProfile: () => Promise; refreshUserKeyState: () => Promise; signOut: () => Promise; + revokedRemotely: boolean; + acknowledgeRevocation: () => void; } const AuthContext = createContext(null); @@ -60,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [profile, setProfile] = useState(null); const [userKeyState, setUserKeyState] = useState({ status: 'loading' }); + const [revokedRemotely, setRevokedRemotely] = useState(false); const autoOnlineUserRef = useRef(null); // Initial session + auth subscription. We verify the cached JWT against the @@ -196,6 +204,58 @@ export function AuthProvider({ children }: { children: ReactNode }) { void registerWebPush(installId); }, [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` // 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. @@ -257,6 +317,46 @@ export function AuthProvider({ children }: { children: ReactNode }) { await wipeLocalState(uid); }, [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( () => ({ session, @@ -266,8 +366,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { refreshProfile, refreshUserKeyState, signOut, + revokedRemotely, + acknowledgeRevocation, }), - [session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut], + [ + session, + profile, + userKeyState, + ready, + refreshProfile, + refreshUserKeyState, + signOut, + revokedRemotely, + acknowledgeRevocation, + ], ); return {children}; diff --git a/apps/desktop/src/lib/deviceRowId.ts b/apps/desktop/src/lib/deviceRowId.ts new file mode 100644 index 0000000..c8a78f1 --- /dev/null +++ b/apps/desktop/src/lib/deviceRowId.ts @@ -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 */ + } +}