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.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -12,7 +12,12 @@ interface Props {
|
|||||||
|
|
||||||
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
|
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
|
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 (
|
return (
|
||||||
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
|
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
|
||||||
<input
|
<input
|
||||||
@@ -26,20 +31,30 @@ export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ari
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
|
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
|
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"
|
className="absolute h-px w-px overflow-hidden p-0 opacity-0"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{Array.from({ length }).map((_, i) => {
|
{Array.from({ length }).map((_, i) => {
|
||||||
const filled = i < value.length;
|
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 (
|
return (
|
||||||
<span key={i}
|
<span key={i} className={classes}>
|
||||||
className={
|
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
|
||||||
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold ' +
|
</span>
|
||||||
(filled
|
|
||||||
? 'border-brand-400 bg-brand-500/10 text-white'
|
|
||||||
: 'border-white/10 bg-ink-800 text-neutral-500')
|
|
||||||
}
|
|
||||||
>{filled ? '•' : ''}</span>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ensureInstallId } from '../lib/installId';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
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 { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
|
||||||
import { registerWebPush } from '../lib/webPush';
|
import { registerWebPush } from '../lib/webPush';
|
||||||
|
|
||||||
// Discriminated union describing the per-user encrypted key blob lifecycle:
|
// 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);
|
const cached = await cachedUserKey(session.user.id);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setUserKeyState({ status: 'unlocked' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const blob = await fetchUserKeyBlob(supabase, session.user.id);
|
const blob = await fetchUserKeyBlob(supabase, session.user.id);
|
||||||
|
|||||||
@@ -36,12 +36,24 @@ export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult>
|
|||||||
recoverySalt: recoverySealed?.salt ?? null,
|
recoverySalt: recoverySealed?.salt ?? null,
|
||||||
});
|
});
|
||||||
await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey);
|
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);
|
console.warn('legacy conv-key migration failed', err);
|
||||||
});
|
});
|
||||||
return { publicKey: kp.publicKey, recoveryCode };
|
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<void> {
|
||||||
|
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 interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean }
|
||||||
|
|
||||||
export type UnlockOutcome =
|
export type UnlockOutcome =
|
||||||
@@ -66,6 +78,9 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcom
|
|||||||
}
|
}
|
||||||
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
|
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
|
||||||
await devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
|
await devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
|
||||||
|
void ensureLegacyMigrated(p.userId).catch((err) => {
|
||||||
|
console.warn('legacy conv-key migration failed', err);
|
||||||
|
});
|
||||||
return { kind: 'unlocked' };
|
return { kind: 'unlocked' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('migrateOwnLegacyBundles', () => {
|
|||||||
return {
|
return {
|
||||||
select: () => ({
|
select: () => ({
|
||||||
in: () => ({
|
in: () => ({
|
||||||
eq: () => Promise.resolve({
|
is: () => Promise.resolve({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
conversation_id: 'conv-1',
|
conversation_id: 'conv-1',
|
||||||
|
|||||||
@@ -48,12 +48,17 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
const result: MigrateResult = { migratedConversations: 0, errors: [] };
|
const result: MigrateResult = { migratedConversations: 0, errors: [] };
|
||||||
if (params.ownLegacyDeviceIds.length === 0) return result;
|
if (params.ownLegacyDeviceIds.length === 0) return result;
|
||||||
|
|
||||||
|
// PostgREST translates `.eq('col', null)` to `col=eq.null` which evaluates as
|
||||||
|
// `col = NULL` — always false in SQL. `.is('col', null)` produces `col IS NULL`,
|
||||||
|
// which is what we want here. Wrong filter silently returned zero rows so the
|
||||||
|
// entire migration was a no-op (0.18.0 bug; users could set a PIN but no
|
||||||
|
// conv-key bundles got re-wrapped → "Awaiting key" on every send).
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const { data: rowsRaw, error } = await (params.client as any)
|
const { data: rowsRaw, error } = await (params.client as any)
|
||||||
.from('conversation_keys')
|
.from('conversation_keys')
|
||||||
.select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce')
|
.select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce')
|
||||||
.in('recipient_device_id', params.ownLegacyDeviceIds)
|
.in('recipient_device_id', params.ownLegacyDeviceIds)
|
||||||
.eq('recipient_user_id', null as unknown as string);
|
.is('recipient_user_id', null);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const rows = (rowsRaw ?? []) as LegacyRow[];
|
const rows = (rowsRaw ?? []) as LegacyRow[];
|
||||||
if (rows.length === 0) return result;
|
if (rows.length === 0) return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user