mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: refactor TOTP code handling to improve state management and refresh logic
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user