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
+12 -6
View File
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync'; import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval'; import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureInstallId } from '../lib/installId';
import { ensureNotificationPermission } from '../lib/osNotify'; import { ensureNotificationPermission } from '../lib/osNotify';
import { cachedUserKey } from '../lib/userIdentity'; import { cachedUserKey } from '../lib/userIdentity';
import { BackupPromptBanner } from './BackupPromptBanner'; import { BackupPromptBanner } from './BackupPromptBanner';
@@ -12,7 +13,7 @@ import { DeviceApprovalBanner } from './DeviceApprovalBanner';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
export function AppShell() { export function AppShell() {
const { session, device } = useAuth(); const { session } = useAuth();
useEffect(() => { useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents // Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session. // re-asking if the user already responded this session.
@@ -20,20 +21,25 @@ export function AppShell() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!session?.user.id || !device?.id) return; if (!session?.user.id) return;
const userId = session.user.id; const userId = session.user.id;
const deviceId = device.id; // Per-install id replaces the per-device record now that crypto is
const stopKeySync = startConversationKeySync(userId, deviceId); // user-keyed. The legacy conv-key-sync and device-approval subsystems
// still take a "deviceId" argument for telemetry / row identity; passing
// the install id keeps those wires intact until they are themselves
// reworked in later tasks (Task 16/17/19 territory).
const installId = ensureInstallId();
const stopKeySync = startConversationKeySync(userId, installId);
const stopApproval = startDeviceApprovalListener({ const stopApproval = startDeviceApprovalListener({
ownUserId: userId, ownUserId: userId,
ownDeviceId: deviceId, ownDeviceId: installId,
getPriv: () => cachedUserKey(userId), getPriv: () => cachedUserKey(userId),
}); });
return () => { return () => {
stopKeySync(); stopKeySync();
stopApproval(); stopApproval();
}; };
}, [session?.user.id, device?.id]); }, [session?.user.id]);
return ( return (
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg"> <div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity'; import { cachedUserKey } from '../lib/userIdentity';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
@@ -32,7 +33,7 @@ interface Props {
// preview hints at the dropped attachment. // preview hints at the dropped attachment.
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) { export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']); const { t } = useTranslation(['app', 'errors']);
const { session, device } = useAuth(); const { session } = useAuth();
const { conversations } = useConversationsContext(); const { conversations } = useConversationsContext();
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -83,7 +84,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
} }
async function handleSend() { async function handleSend() {
if (!session?.user.id || !device?.id || !message) return; if (!session?.user.id || !message) return;
if (selected.size === 0) return; if (selected.size === 0) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
@@ -137,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
conversationId: convId, conversationId: convId,
plaintext: text, plaintext: text,
senderUserId: session.user.id, senderUserId: session.user.id,
senderDeviceId: device.id, senderDeviceId: ensureInstallId(),
senderPrivateKey: priv, senderPrivateKey: priv,
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}), ...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
}); });
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity'; import { cachedUserKey } from '../lib/userIdentity';
import type { AggregatedReaction } from '../lib/useMessageReactions'; import type { AggregatedReaction } from '../lib/useMessageReactions';
@@ -97,7 +98,7 @@ export function MessageBubble({
highlighted = false, highlighted = false,
}: Props) { }: Props) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { session, device } = useAuth(); const { session } = useAuth();
const parsed = parseMessagePayload(message.plaintext); const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : ''; const initialText = parsed.kind === 'text' ? parsed.text : '';
@@ -190,7 +191,7 @@ export function MessageBubble({
}, [pickerOpen]); }, [pickerOpen]);
const handleEditSave = useCallback(async () => { const handleEditSave = useCallback(async () => {
if (!session || !device) return; if (!session) return;
const trimmed = editText.trim(); const trimmed = editText.trim();
if (!trimmed || trimmed === bodyText) { if (!trimmed || trimmed === bodyText) {
setEditing(false); setEditing(false);
@@ -208,7 +209,7 @@ export function MessageBubble({
conversationId, conversationId,
newPlaintext: trimmed, newPlaintext: trimmed,
senderUserId: session.user.id, senderUserId: session.user.id,
senderDeviceId: device.id, senderDeviceId: ensureInstallId(),
senderPrivateKey: priv, senderPrivateKey: priv,
}); });
setEditing(false); setEditing(false);
@@ -224,7 +225,7 @@ export function MessageBubble({
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [editText, message.id, message.plaintext, conversationId, session, device, t]); }, [editText, message.id, message.plaintext, conversationId, session, t]);
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
if (busy) return; if (busy) return;
+7 -4
View File
@@ -23,11 +23,14 @@ export function RequireAuth() {
return <Outlet />; return <Outlet />;
} }
// Forces a registered device on this install. Sends to /device otherwise. // Forces a usable per-user encrypted key blob on this install. Sends to
// /device (the setup/unlock page) when the blob is missing or locked.
export function RequireDevice() { export function RequireDevice() {
const { device, deviceLookupDone } = useAuth(); const { userKeyState } = useAuth();
if (!deviceLookupDone) return <FullScreenSpinner />; if (userKeyState.status === 'loading') return <FullScreenSpinner />;
if (!device) return <Navigate to="/device" replace />; if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
return <Navigate to="/device" replace />;
}
return <Outlet />; return <Outlet />;
} }
+73 -61
View File
@@ -1,9 +1,8 @@
import { import {
type DeviceRecord, fetchUserKeyBlob,
getOwnProfile, getOwnProfile,
signOut as supabaseSignOut, signOut as supabaseSignOut,
type Profile, type Profile,
touchDeviceLastSeen,
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';
@@ -20,23 +19,34 @@ import {
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device'; import { ensureInstallId } from '../lib/installId';
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
import { setSecretStoreUser } from '../lib/secretStore'; import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { registerWebPush } from '../lib/webPush'; 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 { interface AuthContextValue {
session: Session | null; session: Session | null;
profile: Profile | null; profile: Profile | null;
device: DeviceRecord | null; userKeyState: UserKeyState;
// null while we're still resolving the very first auth state. // null while we're still resolving the very first auth state.
ready: boolean; ready: boolean;
// null until a device lookup has finished for the current session.
deviceLookupDone: boolean;
refreshProfile: () => Promise<void>; refreshProfile: () => Promise<void>;
refreshDevice: () => Promise<void>; refreshUserKeyState: () => Promise<void>;
setDevice: (device: DeviceRecord | null) => void;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
@@ -47,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
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 [device, setDevice] = useState<DeviceRecord | null>(null); const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [deviceLookupDone, setDeviceLookupDone] = 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
@@ -112,41 +121,72 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
}, [session, i18n.resolvedLanguage]); }, [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) { if (!session) {
setDevice(null); setUserKeyState({ status: 'loading' });
setDeviceLookupDone(false);
return; return;
} }
setDeviceLookupDone(false); setUserKeyState({ status: 'loading' });
const found = await findExistingDevice(session.user.id); const cached = await cachedUserKey(session.user.id);
setDevice(found); if (cached) {
setDeviceLookupDone(true); 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]); }, [session]);
// Re-pull profile + device whenever session flips. // Re-pull profile + user-key state whenever session flips.
useEffect(() => { useEffect(() => {
if (!session) { if (!session) {
setProfile(null); setProfile(null);
setDevice(null); setUserKeyState({ status: 'loading' });
setDeviceLookupDone(false);
return; return;
} }
void refreshProfile().catch((err: unknown) => { void refreshProfile().catch((err: unknown) => {
console.error('refreshProfile failed', err); console.error('refreshProfile failed', err);
}); });
void refreshDevice().catch((err: unknown) => { void refreshUserKeyState().catch((err: unknown) => {
console.error('refreshDevice failed', err); console.error('refreshUserKeyState failed', err);
setDeviceLookupDone(true); // 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 // Best-effort web-push registration once we have a session. Keyed by an
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset. // 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(() => { useEffect(() => {
if (!device?.id) return; if (!session) return;
void registerWebPush(device.id); const installId = ensureInstallId();
}, [device?.id]); void registerWebPush(installId);
}, [session]);
// Auto online/offline transition. // Auto online/offline transition.
// //
@@ -188,32 +228,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
}, [session, profile, refreshProfile]); }, [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 () => { const signOut = useCallback(async () => {
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => { await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
console.warn('offline update before sign-out failed', err); console.warn('offline update before sign-out failed', err);
@@ -225,15 +239,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({ () => ({
session, session,
profile, profile,
device, userKeyState,
ready, ready,
deviceLookupDone,
refreshProfile, refreshProfile,
refreshDevice, refreshUserKeyState,
setDevice,
signOut, signOut,
}), }),
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut], [session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+5 -4
View File
@@ -113,6 +113,7 @@ import {
type ScreenSharePreset, type ScreenSharePreset,
updateScreenShareSettings, updateScreenShareSettings,
} from '../lib/screenShareSettings'; } from '../lib/screenShareSettings';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity'; import { cachedUserKey } from '../lib/userIdentity';
import { setWindowFullscreen } from '../lib/windowFullscreen'; import { setWindowFullscreen } from '../lib/windowFullscreen';
@@ -324,7 +325,7 @@ function newCallId(): string {
} }
export function CallProvider({ children }: { children: ReactNode }) { export function CallProvider({ children }: { children: ReactNode }) {
const { session, device, profile } = useAuth(); const { session, profile } = useAuth();
const { conversations } = useConversationsContext(); const { conversations } = useConversationsContext();
const myId = session?.user.id; const myId = session?.user.id;
@@ -445,7 +446,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
mediaKind: CallKind, mediaKind: CallKind,
durationSec: number, durationSec: number,
) => { ) => {
if (!session?.user.id || !device?.id) return; if (!session?.user.id) return;
try { try {
const priv = await cachedUserKey(session.user.id); const priv = await cachedUserKey(session.user.id);
if (!priv) return; if (!priv) return;
@@ -461,14 +462,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
conversationId, conversationId,
plaintext: payload, plaintext: payload,
senderUserId: session.user.id, senderUserId: session.user.id,
senderDeviceId: device.id, senderDeviceId: ensureInstallId(),
senderPrivateKey: priv, senderPrivateKey: priv,
}); });
} catch (err: unknown) { } catch (err: unknown) {
console.error('emitCallEvent failed', err); console.error('emitCallEvent failed', err);
} }
}, },
[session?.user.id, device?.id], [session?.user.id],
); );
const clearRingTimer = useCallback(() => { const clearRingTimer = useCallback(() => {
+19
View File
@@ -0,0 +1,19 @@
// Per-install browser identifier. Replaces the legacy per-device crypto
// identity for non-crypto bookkeeping (push tokens, message sender_device_id
// metadata) where we just need a stable, opaque id for this browser/install.
//
// The user-key model has no device-bound crypto; the `devices` table and
// `messages.sender_device_id` column still exist but they're now plain
// telemetry. Using a localStorage UUID keeps the column populated without
// any of the old key-management overhead.
const KEY = 'chatapp.installId';
export function ensureInstallId(): string {
let id = window.localStorage.getItem(KEY);
if (!id) {
id = crypto.randomUUID();
window.localStorage.setItem(KEY, id);
}
return id;
}
+3 -2
View File
@@ -37,6 +37,7 @@ import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures'; import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
import { compressImages } from '../lib/imageCompress'; import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache'; import { searchCachedMessages } from '../lib/messageCache';
import type { OutboxItem } from '../lib/messageOutbox'; import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages'; import { useConversationMessages } from '../lib/useConversationMessages';
@@ -61,7 +62,7 @@ const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: bool
export function ConversationPage() { export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']); const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { session, device } = useAuth(); const { session } = useAuth();
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext(); const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
const conversation = useMemo( const conversation = useMemo(
@@ -75,7 +76,7 @@ export function ConversationPage() {
useConversationMessages({ useConversationMessages({
conversationId: id, conversationId: id,
userId: session?.user.id, userId: session?.user.id,
deviceId: device?.id, deviceId: ensureInstallId(),
}); });
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]); const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const { const {