feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag

This commit is contained in:
byGalax
2026-05-16 19:08:17 +02:00
parent f6e0e4dd09
commit a4f7a16c90
2 changed files with 143 additions and 1 deletions
+113 -1
View File
@@ -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>;
+30
View File
@@ -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 */
}
}