diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index e1c82e4..6a16d66 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -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`; } +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) { - const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`; const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [newPassword2, setNewPassword2] = useState(''); 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 [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [recoveryMasterPassword, setRecoveryMasterPassword] = useState(''); @@ -65,6 +77,10 @@ export default function SettingsPage(props: SettingsPageProps) { const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); + useEffect(() => { + clearLegacyTotpSetupSecrets(); + }, []); + useEffect(() => { if (!props.totpEnabled) { setTotpLocked(false); @@ -89,8 +105,6 @@ export default function SettingsPage(props: SettingsPageProps) { async function enableTotp(): Promise { try { await props.onEnableTotp(secret, token); - // Secret is now stored on the server; remove plaintext copy from localStorage. - localStorage.removeItem(totpSecretStorageKey); setTotpLocked(true); } catch { // Keep inputs editable after a failed attempt. diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index 85ea016..64844c3 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -35,6 +35,14 @@ const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; const failedIconHosts = new Set(); +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 { if (!code) return code; 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) { - const [totpMap, setTotpMap] = useState>({}); + const [totpCodes, setTotpCodes] = useState>({}); + const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain); const [columnCount, setColumnCount] = useState(1); const [orderedIds, setOrderedIds] = useState(() => { if (typeof window === 'undefined') return []; @@ -251,26 +260,39 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { useEffect(() => { if (!totpItems.length) { - setTotpMap({}); + setTotpCodes({}); return; } let stopped = false; + let activeRun = 0; let timer = 0; - const tick = async () => { + let currentWindowId = -1; + + const refreshCodes = async () => { + const runId = ++activeRun; const entries = await Promise.all( totpItems.map(async (cipher) => { try { const next = await calcTotpNow(cipher.login?.decTotp || ''); - return [cipher.id, next] as const; + return [cipher.id, next?.code || null] as const; } catch { 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 () => { stopped = true; window.clearInterval(timer); @@ -326,7 +348,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { void copyToClipboard(value)} /> ))} diff --git a/webapp/src/hooks/useAccountSecurityActions.ts b/webapp/src/hooks/useAccountSecurityActions.ts index 797f6fb..90347b9 100644 --- a/webapp/src/hooks/useAccountSecurityActions.ts +++ b/webapp/src/hooks/useAccountSecurityActions.ts @@ -131,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct try { const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); - if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`); clearDisableTotpDialog(); await refetchTotpStatus(); onNotify('success', t('txt_totp_disabled'));