import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { Clipboard, Globe } from 'lucide-preact'; import { calcTotpNow } from '@/lib/crypto'; import { t } from '@/lib/i18n'; import type { Cipher } from '@/lib/types'; 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; function formatTotp(code: string): string { if (!code || code.length < 6) return code; return `${code.slice(0, 3)} ${code.slice(3, 6)}`; } function firstCipherUri(cipher: Cipher): string { const uris = cipher.login?.uris || []; for (const uri of uris) { const raw = uri.decUri || uri.uri || ''; if (raw.trim()) return raw.trim(); } return ''; } function hostFromUri(uri: string): string { if (!uri.trim()) return ''; try { const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`; return new URL(normalized).hostname || ''; } catch { return ''; } } function TotpListIcon({ cipher }: { cipher: Cipher }) { const uri = firstCipherUri(cipher); const host = hostFromUri(uri); const [errored, setErrored] = useState(false); if (host && !errored) { return ( setErrored(true)} /> ); } return ( ); } export default function TotpCodesPage(props: TotpCodesPageProps) { const [totpMap, setTotpMap] = useState>({}); const [columnCount, setColumnCount] = useState(1); const listRef = useRef(null); async function copyToClipboard(value: string): Promise { if (!value.trim()) return; await navigator.clipboard.writeText(value); props.onNotify('success', t('txt_code_copied')); } const totpItems = useMemo( () => props.ciphers .filter((cipher) => { const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt); return !isDeleted && !!cipher.login?.decTotp; }) .sort((a, b) => { const nameA = (a.decName || a.name || '').trim().toLowerCase(); const nameB = (b.decName || b.name || '').trim().toLowerCase(); return nameA.localeCompare(nameB); }), [props.ciphers] ); useEffect(() => { if (!totpItems.length) { setTotpMap({}); return; } let stopped = false; let timer = 0; const tick = async () => { const entries = await Promise.all( totpItems.map(async (cipher) => { try { const next = await calcTotpNow(cipher.login?.decTotp || ''); return [cipher.id, next] as const; } catch { return [cipher.id, null] as const; } }) ); if (!stopped) setTotpMap(Object.fromEntries(entries)); }; void tick(); timer = window.setInterval(() => void 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 &&
{t('txt_no_verification_codes')}
} {totpItems.map((cipher) => { const live = totpMap[cipher.id] || null; const name = cipher.decName || cipher.name || t('txt_no_name'); const username = cipher.login?.decUsername || ''; return (
{name}
{username || t('txt_no_username')}
{live ? formatTotp(live.code) : t('txt_text_3')}
{live ? live.remain : 0}
); })}
); }