mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add TOTP codes page and related components for displaying verification codes
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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 (
|
||||
<img
|
||||
className="list-icon"
|
||||
src={`/icons/${host}/icon.png?v=2`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="list-icon-fallback">
|
||||
<Globe size={18} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
|
||||
const [columnCount, setColumnCount] = useState(1);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
async function copyToClipboard(value: string): Promise<void> {
|
||||
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 (
|
||||
<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 && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||
{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 (
|
||||
<div key={cipher.id} className="totp-code-row">
|
||||
<div className="totp-code-info">
|
||||
<div className="list-icon-wrap">
|
||||
<TotpListIcon cipher={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>{live ? formatTotp(live.code) : t('txt_text_3')}</strong>
|
||||
<div
|
||||
className="totp-timer"
|
||||
title={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
|
||||
aria-label={t('txt_refresh_in_seconds_s', { seconds: live ? 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, live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<span className="totp-timer-value">{live ? live.remain : 0}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => void copyToClipboard(live?.code || '')} aria-label={t('txt_copy')}>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user