dgb-wallet/packages/web-ui/src/PinConfirmModal.tsx
Digibyte.X ad3923c18c web-ui: top-align single-column views; reveal toggle on password and PIN fields
Login / Import / Create / Settings were vertically centred in the content
column (align-self: center), which on tall screens left a dead band above
the page heading. Now top-aligned; horizontal centring unchanged.

New PasswordInput component wraps every password / PIN / passphrase field
with an eye button that toggles the text. Also: wallet-order drag&drop,
path-search sweep, settings panel sections, change-address policy.
2026-10-02 17:33:02 +02:00

96 lines
3.5 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { PasswordInput } from './PasswordInput.js';
import { useSessionAuth } from './session-auth.js';
// Small modal that asks for the session PIN before letting a
// sensitive action proceed. Currently used by Send when the
// "Require PIN on send" security setting is on.
//
// Verifies the PIN via session-auth.unlockWithPin — same code path
// as unlocking, so the same lockout budget applies: three wrong
// PINs will lock the session (the master password is then needed
// to unlock and restore the PIN). That's a deliberate security
// posture, not a bug — a stranger with the unlocked wallet gets
// three tries before the master password is re-required.
export function PinConfirmModal({
title,
message,
onConfirm,
onCancel,
}: {
title: string;
message?: string;
onConfirm: () => void;
onCancel: () => void;
}): JSX.Element {
const { unlockWithPin, pinAttemptsLeft } = useSessionAuth();
const [pin, setPin] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
async function submit(): Promise<void> {
if (pin.length < 4 || busy) return;
setBusy(true); setErr(null);
try {
const ok = await unlockWithPin(pin);
if (ok) { onConfirm(); return; }
// unlockWithPin returned false → wrong PIN. The session-auth
// hook already decremented the attempt budget; if the last
// attempt was used it also flipped state to 'locked', which
// AppShell will render as the master-password screen — this
// modal will unmount naturally.
setPin('');
setErr(`Wrong PIN. ${Math.max(0, pinAttemptsLeft - 1)} attempt${pinAttemptsLeft - 1 === 1 ? '' : 's'} left before the session locks.`);
} finally { setBusy(false); }
}
// Reuses the shared .modal-backdrop + .modal chrome from styles.css
// (defined for the Receive modal). PIN-specific rows below are the
// only new visual surface.
return (
<div
className="modal-backdrop"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div className="modal">
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600 }}>{title}</h3>
{message && <p className="field-help" style={{ margin: 0 }}>{message}</p>}
<PasswordInput
ref={inputRef}
inputMode="numeric"
placeholder="Session PIN"
value={pin}
onChange={(e) => { setPin(e.target.value); setErr(null); }}
onKeyDown={(e) => {
if (e.key === 'Enter') void submit();
if (e.key === 'Escape') onCancel();
}}
maxLength={12}
autoComplete="off"
style={{ padding: '10px 12px', fontSize: 15, letterSpacing: '0.2em' }} />
{err && <div className="field-help" role="alert" style={{ color: 'var(--danger, #e05252)' }}>{err}</div>}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 4 }}>
<button
type="button"
onClick={onCancel}
disabled={busy}
>Cancel</button>
<button
type="button"
className="primary"
onClick={() => void submit()}
disabled={pin.length < 4 || busy}
>{busy ? 'Checking…' : 'Confirm'}</button>
</div>
</div>
</div>
);
}