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 />;
}