import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { Clipboard, Globe } from 'lucide-preact'; import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard'; import { calcTotpNow } from '@/lib/crypto'; import { t } from '@/lib/i18n'; import type { Cipher } from '@/lib/types'; import LoadingState from '@/components/LoadingState'; import WebsiteIcon from '@/components/vault/WebsiteIcon'; import { formatTotp, isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers'; interface TotpCodesPageProps { ciphers: Cipher[]; loading: boolean; onNotify: (type: 'success' | 'error', text: string) => void; } const TOTP_PERIOD_SECONDS = 30; const TOTP_RING_RADIUS = 14; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const TOTP_REFRESH_BATCH_SIZE = 16; 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 TotpListIcon({ cipher }: { cipher: Cipher }) { return } />; } interface TotpRowProps { cipher: Cipher; live: { code: string; remain: number } | null; onCopy: (value: string) => void; } function TotpRow(props: TotpRowProps) { const name = props.cipher.decName || props.cipher.name || t('txt_no_name'); const username = props.cipher.login?.decUsername || ''; return (
{name}
{username || t('txt_no_username')}
{props.live ? formatTotp(props.live.code) : t('txt_text_3')}
{props.live ? props.live.remain : 0}
); } export default function TotpCodesPage(props: TotpCodesPageProps) { const [totpCodes, setTotpCodes] = useState>({}); const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain); const [columnCount, setColumnCount] = useState(1); const listRef = useRef(null); async function copyToClipboard(value: string): Promise { await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') }); } const nameCollator = useMemo( () => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }), [] ); const totpItems = useMemo( () => props.ciphers .filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp) .sort((a, b) => { const nameA = (a.decName || a.name || '').trim(); const nameB = (b.decName || b.name || '').trim(); return nameCollator.compare(nameA, nameB); }), [props.ciphers, nameCollator] ); useEffect(() => { if (!totpItems.length) { setTotpCodes({}); return; } let stopped = false; let activeRun = 0; let timer = 0; let currentWindowId = -1; const refreshCodes = async () => { const runId = ++activeRun; const nextCodes: Record = {}; for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) { if (stopped || runId !== activeRun) return; const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE); const entries = await Promise.all( batch.map(async (cipher) => { try { const next = await calcTotpNow(cipher.login?.decTotp || ''); return [cipher.id, next?.code || null] as const; } catch { return [cipher.id, null] as const; } }) ); for (const [id, code] of entries) nextCodes[id] = code; if (start + TOTP_REFRESH_BATCH_SIZE < totpItems.length) { await new Promise((resolve) => window.setTimeout(resolve, 0)); } } if (stopped || runId !== activeRun) return; setTotpCodes((prev) => { let changed = false; const next: Record = { ...prev }; for (const id of Object.keys(next)) { if (id in nextCodes) continue; delete next[id]; changed = true; } for (const [id, code] of Object.entries(nextCodes)) { if (next[id] === code) continue; next[id] = code; changed = true; } return changed ? next : prev; }); }; 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); }; }, [totpItems]); useEffect(() => { const element = listRef.current; if (!element) return; const gap = 10; const minCardWidth = 320; const maxColumns = 4; const updateColumns = () => { const width = element.clientWidth; if (!width) return; const next = Math.max(1, Math.min(maxColumns, Math.floor((width + gap) / (minCardWidth + gap)))); setColumnCount(next); }; updateColumns(); const observer = new ResizeObserver(() => updateColumns()); observer.observe(element); return () => observer.disconnect(); }, []); return (

{t('txt_verification_code')}

} > {!totpItems.length && props.loading && } {!totpItems.length && !props.loading &&
{t('txt_no_verification_codes')}
} {totpItems.map((cipher) => ( void copyToClipboard(value)} /> ))}
); }