feat(desktop): UserKeyUnlock screen (PIN entry + recovery fallback)
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { loadOrUnlockUserKey } from '../lib/userIdentity';
|
||||||
|
import { PinInput } from './PinInput';
|
||||||
|
import { LockIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
hasRecovery: boolean;
|
||||||
|
lockedUntil?: string | null;
|
||||||
|
onUnlocked: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mode = 'pin' | 'recovery';
|
||||||
|
|
||||||
|
export function UserKeyUnlock({ userId, hasRecovery, lockedUntil, onUnlocked }: Props) {
|
||||||
|
const [mode, setMode] = useState<Mode>('pin');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
const res = await loadOrUnlockUserKey({
|
||||||
|
userId, pin: value, isRecoveryCode: mode === 'recovery',
|
||||||
|
});
|
||||||
|
if (res.kind === 'unlocked') onUnlocked();
|
||||||
|
else if (res.kind === 'locked') setError('Konto gesperrt bis ' + res.lockedUntil);
|
||||||
|
else setError('Schlüssel auf dem Server nicht gefunden.');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}, [userId, value, mode, onUnlocked]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||||||
|
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LockIcon className="h-5 w-5 text-brand-300" />
|
||||||
|
<h2 className="text-lg font-semibold">Account entsperren</h2>
|
||||||
|
</div>
|
||||||
|
{lockedUntil && (
|
||||||
|
<p role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-100">
|
||||||
|
Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mode === 'pin' ? (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||||||
|
<PinInput value={value} onChange={setValue} ariaLabel="PIN eingeben" autoFocus onSubmit={() => void submit()} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400" htmlFor="recovery-input">Recovery-Code</label>
|
||||||
|
<input
|
||||||
|
id="recovery-input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2 font-mono tracking-widest text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasRecovery && (
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => { setMode((m) => (m === 'pin' ? 'recovery' : 'pin')); setValue(''); setError(null); }}
|
||||||
|
className="text-xs text-brand-300 hover:underline"
|
||||||
|
>
|
||||||
|
{mode === 'pin' ? 'PIN vergessen? Recovery-Code nutzen' : 'Stattdessen PIN verwenden'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || (mode === 'pin' ? value.length !== 6 : value.trim().length === 0)}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
|
Entsperren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user