refactor(desktop): AuthContext exposes userKeyState instead of device record

Replaces the per-device DeviceRecord lookup with a per-user discriminated
union (loading | needs-setup | needs-unlock | unlocked). Heartbeat block
deleted (telemetry no longer device-bound); webPush keyed by install-id.
This commit is contained in:
byGalax
2026-05-15 22:52:41 +02:00
parent 2c586351fc
commit 20216b37c6
8 changed files with 128 additions and 84 deletions
+73 -61
View File
@@ -1,9 +1,8 @@
import {
type DeviceRecord,
fetchUserKeyBlob,
getOwnProfile,
signOut as supabaseSignOut,
type Profile,
touchDeviceLastSeen,
updateOwnProfile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
@@ -20,23 +19,34 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
import { ensureInstallId } from '../lib/installId';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { registerWebPush } from '../lib/webPush';
// Discriminated union describing the per-user encrypted key blob lifecycle:
//
// loading — initial state, or refresh in flight
// needs-setup — no row exists on Supabase; user must pick a PIN
// needs-unlock — row exists but local cache empty; PIN (or recovery code)
// required. `lockedUntil` non-null means the server-side
// rate limiter is currently rejecting attempts.
// unlocked — private key is in the local secret store and ready to use
export type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
profile: Profile | null;
device: DeviceRecord | null;
userKeyState: UserKeyState;
// null while we're still resolving the very first auth state.
ready: boolean;
// null until a device lookup has finished for the current session.
deviceLookupDone: boolean;
refreshProfile: () => Promise<void>;
refreshDevice: () => Promise<void>;
setDevice: (device: DeviceRecord | null) => void;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
}
@@ -47,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState<Profile | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const autoOnlineUserRef = useRef<string | null>(null);
// Initial session + auth subscription. We verify the cached JWT against the
@@ -112,41 +121,72 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [session, i18n.resolvedLanguage]);
const refreshDevice = useCallback(async () => {
// Resolves the current state of the per-user encrypted key blob:
// 1. local cache hit → 'unlocked'
// 2. no remote row → 'needs-setup'
// 3. server rate-limit → 'needs-unlock' with lockedUntil set
// 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a
// recovery-code blob is present so the UI can
// conditionally offer the recovery affordance
const refreshUserKeyState = useCallback(async () => {
if (!session) {
setDevice(null);
setDeviceLookupDone(false);
setUserKeyState({ status: 'loading' });
return;
}
setDeviceLookupDone(false);
const found = await findExistingDevice(session.user.id);
setDevice(found);
setDeviceLookupDone(true);
setUserKeyState({ status: 'loading' });
const cached = await cachedUserKey(session.user.id);
if (cached) {
setUserKeyState({ status: 'unlocked' });
return;
}
const blob = await fetchUserKeyBlob(supabase, session.user.id);
if (!blob || !blob.exists) {
setUserKeyState({ status: 'needs-setup' });
return;
}
if (blob.locked) {
setUserKeyState({
status: 'needs-unlock',
lockedUntil: blob.lockedUntil,
hasRecovery: false,
});
return;
}
setUserKeyState({
status: 'needs-unlock',
lockedUntil: null,
hasRecovery: blob.recoverySealedPrivateKey !== null,
});
}, [session]);
// Re-pull profile + device whenever session flips.
// Re-pull profile + user-key state whenever session flips.
useEffect(() => {
if (!session) {
setProfile(null);
setDevice(null);
setDeviceLookupDone(false);
setUserKeyState({ status: 'loading' });
return;
}
void refreshProfile().catch((err: unknown) => {
console.error('refreshProfile failed', err);
});
void refreshDevice().catch((err: unknown) => {
console.error('refreshDevice failed', err);
setDeviceLookupDone(true);
void refreshUserKeyState().catch((err: unknown) => {
console.error('refreshUserKeyState failed', err);
// Treat an unrecoverable lookup error as "needs-setup" so the UI at
// least drives the user toward the setup/unlock page rather than
// hanging forever on the spinner.
setUserKeyState({ status: 'needs-setup' });
});
}, [session, refreshProfile, refreshDevice]);
}, [session, refreshProfile, refreshUserKeyState]);
// Best-effort web-push registration once we know the device id. No-op on
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
// Best-effort web-push registration once we have a session. Keyed by an
// install-id (localStorage UUID) since there's no longer a per-device
// crypto record to key by. No-op on Tauri (uses native notifications) or
// when VITE_VAPID_PUBLIC_KEY is unset.
useEffect(() => {
if (!device?.id) return;
void registerWebPush(device.id);
}, [device?.id]);
if (!session) return;
const installId = ensureInstallId();
void registerWebPush(installId);
}, [session]);
// Auto online/offline transition.
//
@@ -188,32 +228,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, [session, profile, refreshProfile]);
useEffect(() => {
if (!session || !device?.id) return;
const touch = () => {
void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
console.warn('presence heartbeat failed', err);
});
};
const touchWhenVisible = () => {
if (document.visibilityState === 'visible') touch();
};
touch();
const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
window.addEventListener('focus', touch);
window.addEventListener('online', touch);
document.addEventListener('visibilitychange', touchWhenVisible);
return () => {
window.clearInterval(heartbeat);
window.removeEventListener('focus', touch);
window.removeEventListener('online', touch);
document.removeEventListener('visibilitychange', touchWhenVisible);
};
}, [session, device?.id]);
const signOut = useCallback(async () => {
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
console.warn('offline update before sign-out failed', err);
@@ -225,15 +239,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({
session,
profile,
device,
userKeyState,
ready,
deviceLookupDone,
refreshProfile,
refreshDevice,
setDevice,
refreshUserKeyState,
signOut,
}),
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;