From 6caa674c19a82cb87ac7ea45cfbc53f9f421a46e Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 00:15:15 +0200 Subject: [PATCH] fix(shared): legacy conv-key migration query used .eq(null) instead of .is(null) PostgREST translates .eq('col', null) to `col = NULL` which is always false in SQL. The migration silently returned zero rows -> setupNewUserIdentity fired but re-wrapped nothing -> users could set a PIN but every send threw 'Awaiting key'. Switching to .is('col', null) emits `col IS NULL` and the migration finally finds its work. Also makes the migration trigger idempotent and re-fires it on: - every successful loadOrUnlockUserKey - AuthContext startup when the user-key is already cached so users stuck on 0.18.0 auto-recover the moment they install 0.18.1. PinInput: focused + active-slot now show a brand-coloured ring, glow, and a blinking caret so users see where the next keystroke lands. --- apps/desktop/src/components/PinInput.tsx | 33 ++++++++++++++----- apps/desktop/src/context/AuthContext.tsx | 8 ++++- apps/desktop/src/lib/userIdentity.ts | 17 +++++++++- .../shared/src/chat/userKeyMigration.test.ts | 2 +- packages/shared/src/chat/userKeyMigration.ts | 7 +++- 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/components/PinInput.tsx b/apps/desktop/src/components/PinInput.tsx index 71675b9..dd8d91e 100644 --- a/apps/desktop/src/components/PinInput.tsx +++ b/apps/desktop/src/components/PinInput.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; interface Props { value: string; @@ -12,7 +12,12 @@ interface Props { export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) { const ref = useRef(null); + const [focused, setFocused] = useState(false); useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]); + // Index of the next empty slot the next keystroke will fill. When the user + // has typed all `length` digits, no slot is "active" — the form should + // submit instead of pretending one is still focused. + const activeIndex = value.length < length ? value.length : -1; return (
ref.current?.focus()}> onChange(e.target.value.replace(/\D/g, '').slice(0, length))} onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} className="absolute h-px w-px overflow-hidden p-0 opacity-0" />
{Array.from({ length }).map((_, i) => { const filled = i < value.length; + const active = focused && i === activeIndex; + let classes = + 'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition '; + if (filled) { + classes += 'border-brand-400 bg-brand-500/10 text-white'; + } else if (active) { + // Brand-coloured ring + glow so the user immediately sees where + // the next keystroke lands. The animated cursor inside reinforces + // the "input is alive" feeling. + classes += 'border-brand-400 bg-brand-500/10 text-brand-300 ring-2 ring-brand-400/40 shadow-[0_0_12px_-2px] shadow-brand-500/40'; + } else { + classes += 'border-white/10 bg-ink-800 text-neutral-500'; + } return ( - {filled ? '•' : ''} + + {filled ? '•' : active ? | : ''} + ); })}
diff --git a/apps/desktop/src/context/AuthContext.tsx b/apps/desktop/src/context/AuthContext.tsx index 0d00a4a..d290ece 100644 --- a/apps/desktop/src/context/AuthContext.tsx +++ b/apps/desktop/src/context/AuthContext.tsx @@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next'; import { ensureInstallId } from '../lib/installId'; import { setSecretStoreUser } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; -import { cachedUserKey } from '../lib/userIdentity'; +import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity'; import { registerWebPush } from '../lib/webPush'; // Discriminated union describing the per-user encrypted key blob lifecycle: @@ -137,6 +137,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const cached = await cachedUserKey(session.user.id); if (cached) { setUserKeyState({ status: 'unlocked' }); + // Best-effort: re-wrap any unmigrated legacy bundles. Idempotent (RPC + // uses ON CONFLICT DO NOTHING). Recovers users who set up under 0.18.0 + // where the migration query had a `.eq(null)` bug that made it a no-op. + void ensureLegacyMigrated(session.user.id).catch((err) => { + console.warn('legacy conv-key migration on auth-resume failed', err); + }); return; } const blob = await fetchUserKeyBlob(supabase, session.user.id); diff --git a/apps/desktop/src/lib/userIdentity.ts b/apps/desktop/src/lib/userIdentity.ts index b99b240..a4af25d 100644 --- a/apps/desktop/src/lib/userIdentity.ts +++ b/apps/desktop/src/lib/userIdentity.ts @@ -36,12 +36,24 @@ export async function setupNewUserIdentity(p: SetupParams): Promise recoverySalt: recoverySealed?.salt ?? null, }); await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey); - void runLegacyMigration(p.userId, kp.privateKey, kp.publicKey).catch((err) => { + void ensureLegacyMigrated(p.userId).catch((err) => { console.warn('legacy conv-key migration failed', err); }); return { publicKey: kp.publicKey, recoveryCode }; } +// Background, idempotent re-wrap of own legacy conv-key bundles for the new +// per-user identity. Safe to call repeatedly: the underlying RPC uses +// ON CONFLICT DO NOTHING. Triggered on every successful unlock so users who +// upgraded to 0.18.0 (where the .eq(null) bug made setup-time migration a +// no-op) auto-recover on the next launch. +export async function ensureLegacyMigrated(userId: string): Promise { + const priv = await devLocalSecretStore.getSecret(cacheKey(userId)); + if (!priv) return; + const pub = await derivePublicKey(priv); + await runLegacyMigration(userId, priv, pub); +} + export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean } export type UnlockOutcome = @@ -66,6 +78,9 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise {}); await devLocalSecretStore.setSecret(cacheKey(p.userId), priv); + void ensureLegacyMigrated(p.userId).catch((err) => { + console.warn('legacy conv-key migration failed', err); + }); return { kind: 'unlocked' }; } diff --git a/packages/shared/src/chat/userKeyMigration.test.ts b/packages/shared/src/chat/userKeyMigration.test.ts index 3a4a3fa..1149399 100644 --- a/packages/shared/src/chat/userKeyMigration.test.ts +++ b/packages/shared/src/chat/userKeyMigration.test.ts @@ -26,7 +26,7 @@ describe('migrateOwnLegacyBundles', () => { return { select: () => ({ in: () => ({ - eq: () => Promise.resolve({ + is: () => Promise.resolve({ data: [ { conversation_id: 'conv-1', diff --git a/packages/shared/src/chat/userKeyMigration.ts b/packages/shared/src/chat/userKeyMigration.ts index f153b41..b4c3380 100644 --- a/packages/shared/src/chat/userKeyMigration.ts +++ b/packages/shared/src/chat/userKeyMigration.ts @@ -48,12 +48,17 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise