feat: refactor TOTP code handling to improve state management and refresh logic

This commit is contained in:
shuaiplus
2026-04-25 01:48:20 +08:00
parent fccc85c4bb
commit 514889adfc
3 changed files with 48 additions and 13 deletions
+18 -4
View File
@@ -49,13 +49,25 @@ function buildOtpUri(email: string, secret: string): string {
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
} }
function clearLegacyTotpSetupSecrets(): void {
if (typeof window === 'undefined') return;
const prefix = 'nodewarden.totp.secret.';
const keys: string[] = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (key?.startsWith(prefix)) keys.push(key);
}
for (const key of keys) {
window.localStorage.removeItem(key);
}
}
export default function SettingsPage(props: SettingsPageProps) { export default function SettingsPage(props: SettingsPageProps) {
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState(''); const [newPassword2, setNewPassword2] = useState('');
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || ''); const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32)); const [secret, setSecret] = useState(() => randomBase32Secret(32));
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState(''); const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
@@ -65,6 +77,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
useEffect(() => {
clearLegacyTotpSetupSecrets();
}, []);
useEffect(() => { useEffect(() => {
if (!props.totpEnabled) { if (!props.totpEnabled) {
setTotpLocked(false); setTotpLocked(false);
@@ -89,8 +105,6 @@ export default function SettingsPage(props: SettingsPageProps) {
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try { try {
await props.onEnableTotp(secret, token); await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true); setTotpLocked(true);
} catch { } catch {
// Keep inputs editable after a failed attempt. // Keep inputs editable after a failed attempt.
+30 -8
View File
@@ -35,6 +35,14 @@ const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const failedIconHosts = new Set<string>(); const failedIconHosts = new Set<string>();
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
return {
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
};
}
function formatTotp(code: string): string { function formatTotp(code: string): string {
if (!code) return code; if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`; if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
@@ -168,7 +176,8 @@ function SortableTotpRow(props: SortableTotpRowProps) {
} }
export default function TotpCodesPage(props: TotpCodesPageProps) { export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({}); const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
const [columnCount, setColumnCount] = useState(1); const [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => { const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
@@ -251,26 +260,39 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
useEffect(() => { useEffect(() => {
if (!totpItems.length) { if (!totpItems.length) {
setTotpMap({}); setTotpCodes({});
return; return;
} }
let stopped = false; let stopped = false;
let activeRun = 0;
let timer = 0; let timer = 0;
const tick = async () => { let currentWindowId = -1;
const refreshCodes = async () => {
const runId = ++activeRun;
const entries = await Promise.all( const entries = await Promise.all(
totpItems.map(async (cipher) => { totpItems.map(async (cipher) => {
try { try {
const next = await calcTotpNow(cipher.login?.decTotp || ''); const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next] as const; return [cipher.id, next?.code || null] as const;
} catch { } catch {
return [cipher.id, null] as const; return [cipher.id, null] as const;
} }
}) })
); );
if (!stopped) setTotpMap(Object.fromEntries(entries)); if (!stopped && runId === activeRun) setTotpCodes(Object.fromEntries(entries));
}; };
void tick();
timer = window.setInterval(() => void tick(), 1000); const tick = () => {
const next = getTotpTimeState();
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
if (next.windowId === currentWindowId) return;
currentWindowId = next.windowId;
void refreshCodes();
};
tick();
timer = window.setInterval(tick, 1000);
return () => { return () => {
stopped = true; stopped = true;
window.clearInterval(timer); window.clearInterval(timer);
@@ -326,7 +348,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
<SortableTotpRow <SortableTotpRow
key={cipher.id} key={cipher.id}
cipher={cipher} cipher={cipher}
live={totpMap[cipher.id] || null} live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
onCopy={(value) => void copyToClipboard(value)} onCopy={(value) => void copyToClipboard(value)}
/> />
))} ))}
@@ -131,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
try { try {
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
clearDisableTotpDialog(); clearDisableTotpDialog();
await refetchTotpStatus(); await refetchTotpStatus();
onNotify('success', t('txt_totp_disabled')); onNotify('success', t('txt_totp_disabled'));