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 { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureInstallId } from '../lib/installId';
import { ensureNotificationPermission } from '../lib/osNotify';
import { cachedUserKey } from '../lib/userIdentity';
import { BackupPromptBanner } from './BackupPromptBanner';
@@ -12,7 +13,7 @@ import { DeviceApprovalBanner } from './DeviceApprovalBanner';
import { Sidebar } from './Sidebar';
export function AppShell() {
const { session, device } = useAuth();
const { session } = useAuth();
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
@@ -20,20 +21,25 @@ export function AppShell() {
}, []);
useEffect(() => {
if (!session?.user.id || !device?.id) return;
if (!session?.user.id) return;
const userId = session.user.id;
const deviceId = device.id;
const stopKeySync = startConversationKeySync(userId, deviceId);
// Per-install id replaces the per-device record now that crypto is
// 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({
ownUserId: userId,
ownDeviceId: deviceId,
ownDeviceId: installId,
getPriv: () => cachedUserKey(userId),
});
return () => {
stopKeySync();
stopApproval();
};
}, [session?.user.id, device?.id]);
}, [session?.user.id]);
return (
<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 { useConversationsContext } from '../context/ConversationsContext';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { Avatar } from './Avatar';
@@ -32,7 +33,7 @@ interface Props {
// preview hints at the dropped attachment.
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']);
const { session, device } = useAuth();
const { session } = useAuth();
const { conversations } = useConversationsContext();
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -83,7 +84,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
}
async function handleSend() {
if (!session?.user.id || !device?.id || !message) return;
if (!session?.user.id || !message) return;
if (selected.size === 0) return;
setBusy(true);
setError(null);
@@ -137,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
conversationId: convId,
plaintext: text,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
});
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import type { AggregatedReaction } from '../lib/useMessageReactions';
@@ -97,7 +98,7 @@ export function MessageBubble({
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
const { session } = useAuth();
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
@@ -190,7 +191,7 @@ export function MessageBubble({
}, [pickerOpen]);
const handleEditSave = useCallback(async () => {
if (!session || !device) return;
if (!session) return;
const trimmed = editText.trim();
if (!trimmed || trimmed === bodyText) {
setEditing(false);
@@ -208,7 +209,7 @@ export function MessageBubble({
conversationId,
newPlaintext: trimmed,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
});
setEditing(false);
@@ -224,7 +225,7 @@ export function MessageBubble({
} finally {
setBusy(false);
}
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
}, [editText, message.id, message.plaintext, conversationId, session, t]);
const handleDelete = useCallback(async () => {
if (busy) return;
+7 -4
View File
@@ -23,11 +23,14 @@ export function RequireAuth() {
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() {
const { device, deviceLookupDone } = useAuth();
if (!deviceLookupDone) return <FullScreenSpinner />;
if (!device) return <Navigate to="/device" replace />;
const { userKeyState } = useAuth();
if (userKeyState.status === 'loading') return <FullScreenSpinner />;
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
return <Navigate to="/device" replace />;
}
return <Outlet />;
}
+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>;
+5 -4
View File
@@ -113,6 +113,7 @@ import {
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { setWindowFullscreen } from '../lib/windowFullscreen';
@@ -324,7 +325,7 @@ function newCallId(): string {
}
export function CallProvider({ children }: { children: ReactNode }) {
const { session, device, profile } = useAuth();
const { session, profile } = useAuth();
const { conversations } = useConversationsContext();
const myId = session?.user.id;
@@ -445,7 +446,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
mediaKind: CallKind,
durationSec: number,
) => {
if (!session?.user.id || !device?.id) return;
if (!session?.user.id) return;
try {
const priv = await cachedUserKey(session.user.id);
if (!priv) return;
@@ -461,14 +462,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
conversationId,
plaintext: payload,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
});
} catch (err: unknown) {
console.error('emitCallEvent failed', err);
}
},
[session?.user.id, device?.id],
[session?.user.id],
);
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 { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
@@ -61,7 +62,7 @@ const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: bool
export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
const { session, device } = useAuth();
const { session } = useAuth();
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
const conversation = useMemo(
@@ -75,7 +76,7 @@ export function ConversationPage() {
useConversationMessages({
conversationId: id,
userId: session?.user.id,
deviceId: device?.id,
deviceId: ensureInstallId(),
});
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const {