feat: add recovery code functionality and device management

This commit is contained in:
shuaiplus
2026-03-01 08:49:35 +08:00
committed by Shuai
parent 8852127743
commit 8641df3cff
15 changed files with 995 additions and 63 deletions
+90 -31
View File
@@ -10,6 +10,8 @@ interface SettingsPageProps {
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onNotify?: (type: 'success' | 'error', text: string) => void;
}
function randomBase32Secret(length: number): string {
@@ -35,6 +37,8 @@ export default function SettingsPage(props: SettingsPageProps) {
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
useEffect(() => {
if (!props.totpEnabled) {
@@ -57,6 +61,12 @@ export default function SettingsPage(props: SettingsPageProps) {
setTotpLocked(true);
}
async function loadRecoveryCode(): Promise<void> {
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
setRecoveryCode(code);
props.onNotify?.('success', 'Recovery code loaded');
}
return (
<div className="stack">
<section className="card">
@@ -112,41 +122,90 @@ export default function SettingsPage(props: SettingsPageProps) {
</section>
<section className="card">
<h3>TOTP</h3>
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
<div className="totp-grid">
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
<div>
<div>
<label className="field">
<span>Authenticator Key</span>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
</label>
<label className="field">
<span>Verification Code</span>
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? 'Enabled' : 'Enable TOTP'}
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
<RefreshCw size={14} className="btn-icon" />
Regenerate
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => navigator.clipboard.writeText(secret)}>
<Clipboard size={14} className="btn-icon" />
Copy Secret
</button>
<div className="settings-twofactor-grid">
<div className="settings-subcard">
<h3>TOTP</h3>
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
<div className="totp-grid">
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
<div>
<div>
<label className="field">
<span>Authenticator Key</span>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
</label>
<label className="field">
<span>Verification Code</span>
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? 'Enabled' : 'Enable TOTP'}
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
<RefreshCw size={14} className="btn-icon" />
Regenerate
</button>
<button
type="button"
className="btn btn-secondary"
disabled={totpLocked}
onClick={() => {
void navigator.clipboard.writeText(secret);
props.onNotify?.('success', 'Secret copied');
}}
>
<Clipboard size={14} className="btn-icon" />
Copy Secret
</button>
</div>
</div>
</div>
</div>
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
Disable TOTP
</button>
</div>
<div className="settings-subcard">
<h3>Recovery Code</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}>
This is a one-time code. After it is used, a new code is generated automatically.
</p>
<label className="field">
<span>Master Password</span>
<input
className="input"
type="password"
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
View Recovery Code
</button>
<button
type="button"
className="btn btn-secondary"
disabled={!recoveryCode}
onClick={() => {
void navigator.clipboard.writeText(recoveryCode);
props.onNotify?.('success', 'Recovery code copied');
}}
>
Copy Code
</button>
</div>
{recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
</div>
)}
</div>
</div>
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
Disable TOTP
</button>
</section>
</div>
);