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:
@@ -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;
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user