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.
96 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|