mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
227 lines
7.9 KiB
TypeScript
227 lines
7.9 KiB
TypeScript
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 <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
|
}
|
|
|
|
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 (
|
|
<div className="totp-code-row">
|
|
<div className="totp-code-info">
|
|
<div className="list-icon-wrap">
|
|
<TotpListIcon cipher={props.cipher} />
|
|
</div>
|
|
<div className="totp-code-meta">
|
|
<div className="totp-code-name" title={name}>{name}</div>
|
|
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
|
|
</div>
|
|
</div>
|
|
<div className="totp-code-main">
|
|
<strong>{props.live ? formatTotp(props.live.code) : t('txt_text_3')}</strong>
|
|
<div
|
|
className="totp-timer"
|
|
title={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
|
|
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
|
|
>
|
|
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
|
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
|
|
<circle
|
|
className="totp-ring-progress"
|
|
cx="18"
|
|
cy="18"
|
|
r={TOTP_RING_RADIUS}
|
|
style={{
|
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
|
strokeDashoffset: String(
|
|
TOTP_RING_CIRCUMFERENCE -
|
|
TOTP_RING_CIRCUMFERENCE *
|
|
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
),
|
|
}}
|
|
/>
|
|
</svg>
|
|
<span className="totp-timer-value">{props.live ? props.live.remain : 0}</span>
|
|
</div>
|
|
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => props.onCopy(props.live?.code || '')} aria-label={t('txt_copy')}>
|
|
<Clipboard size={14} className="btn-icon" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
|
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
|
const [columnCount, setColumnCount] = useState(1);
|
|
const listRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
async function copyToClipboard(value: string): Promise<void> {
|
|
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<string, string | null> = {};
|
|
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<void>((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
if (stopped || runId !== activeRun) return;
|
|
setTotpCodes((prev) => {
|
|
let changed = false;
|
|
const next: Record<string, string | null> = { ...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 (
|
|
<div className="totp-codes-page">
|
|
<div className="card">
|
|
<div className="section-head">
|
|
<h3 className="detail-title">{t('txt_verification_code')}</h3>
|
|
</div>
|
|
<div
|
|
ref={listRef}
|
|
className="totp-codes-list"
|
|
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
|
>
|
|
{!totpItems.length && props.loading && <LoadingState lines={6} />}
|
|
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
|
{totpItems.map((cipher) => (
|
|
<TotpRow
|
|
key={cipher.id}
|
|
cipher={cipher}
|
|
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
|
onCopy={(value) => void copyToClipboard(value)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|