mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-06 07:00:12 +00:00
Improve Bitwarden-compatible TOTP handling
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
|
||||
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} />} />;
|
||||
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||
|
||||
interface TotpRowProps {
|
||||
cipher: Cipher;
|
||||
live: { code: string; remain: number } | null;
|
||||
live: TotpCodeResult | 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 || '';
|
||||
const period = Math.max(1, props.live?.period || 30);
|
||||
const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0;
|
||||
|
||||
return (
|
||||
<div className="totp-code-row">
|
||||
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
|
||||
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)
|
||||
TOTP_RING_CIRCUMFERENCE * progress
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
|
||||
}
|
||||
|
||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
||||
const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
|
||||
const [columnCount, setColumnCount] = useState(1);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
let stopped = false;
|
||||
let activeRun = 0;
|
||||
let timer = 0;
|
||||
let currentWindowId = -1;
|
||||
|
||||
const refreshCodes = async () => {
|
||||
const runId = ++activeRun;
|
||||
const nextCodes: Record<string, string | null> = {};
|
||||
const nextCodes: Record<string, TotpCodeResult | 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);
|
||||
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
batch.map(async (cipher) => {
|
||||
try {
|
||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||
return [cipher.id, next?.code || null] as const;
|
||||
return [cipher.id, next] as const;
|
||||
} catch {
|
||||
return [cipher.id, null] as const;
|
||||
}
|
||||
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
if (stopped || runId !== activeRun) return;
|
||||
setTotpCodes((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, string | null> = { ...prev };
|
||||
const next: Record<string, TotpCodeResult | 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;
|
||||
for (const [id, live] of Object.entries(nextCodes)) {
|
||||
const prevLive = next[id];
|
||||
if (
|
||||
prevLive?.code === live?.code &&
|
||||
prevLive?.remain === live?.remain &&
|
||||
prevLive?.period === live?.period
|
||||
) continue;
|
||||
next[id] = live;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
const next = getTotpTimeState();
|
||||
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
|
||||
if (next.windowId === currentWindowId) return;
|
||||
currentWindowId = next.windowId;
|
||||
void refreshCodes();
|
||||
};
|
||||
|
||||
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
<TotpRow
|
||||
key={cipher.id}
|
||||
cipher={cipher}
|
||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
||||
live={totpCodes[cipher.id] || null}
|
||||
onCopy={(value) => void copyToClipboard(value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
import { calcTotpNow } from '@/lib/crypto';
|
||||
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
|
||||
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
||||
import { ChevronLeft } from 'lucide-preact';
|
||||
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
@@ -109,7 +109,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [renameFolderName, setRenameFolderName] = useState('');
|
||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
||||
const [totpLive, setTotpLive] = useState<TotpCodeResult | null>(null);
|
||||
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
||||
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
||||
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -2,11 +2,11 @@ import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { TotpCodeResult } from '@/lib/crypto';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CardBrandIcon,
|
||||
TOTP_PERIOD_SECONDS,
|
||||
TOTP_RING_CIRCUMFERENCE,
|
||||
VaultListIcon,
|
||||
copyToClipboard,
|
||||
@@ -25,7 +25,7 @@ interface VaultDetailViewProps {
|
||||
selectedCipher: Cipher;
|
||||
repromptApprovedCipherId: string | null;
|
||||
showPassword: boolean;
|
||||
totpLive: { code: string; remain: number } | null;
|
||||
totpLive: TotpCodeResult | null;
|
||||
passkeyCreatedAt: string | null;
|
||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||
folderName: (id: string | null | undefined) => string;
|
||||
@@ -42,6 +42,11 @@ interface VaultDetailViewProps {
|
||||
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function totpProgress(live: TotpCodeResult | null): number {
|
||||
const period = Math.max(1, live?.period || 30);
|
||||
return live ? Math.max(0, Math.min(period, live.remain)) / period : 0;
|
||||
}
|
||||
|
||||
function PasswordHistoryDialog(props: {
|
||||
open: boolean;
|
||||
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
||||
@@ -191,8 +196,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||
strokeDashoffset: String(
|
||||
TOTP_RING_CIRCUMFERENCE -
|
||||
TOTP_RING_CIRCUMFERENCE *
|
||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||
TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr
|
||||
import jsQR from 'jsqr';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import { normalizeTotpInput } from '@/lib/crypto';
|
||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { cardBrand } from '@/lib/import-format-shared';
|
||||
@@ -161,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
};
|
||||
|
||||
const applyTotpQrValue = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
props.onUpdateDraft({ loginTotp: trimmed });
|
||||
const normalized = normalizeTotpInput(value);
|
||||
if (!normalized) return false;
|
||||
props.onUpdateDraft({ loginTotp: normalized });
|
||||
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
||||
setTotpQrOpen(false);
|
||||
return true;
|
||||
|
||||
@@ -207,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s
|
||||
];
|
||||
}
|
||||
|
||||
export const TOTP_PERIOD_SECONDS = 30;
|
||||
export const TOTP_RING_RADIUS = 14;
|
||||
const TOTP_RING_RADIUS = 14;
|
||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
|
||||
export function CreateTypeIcon({ type }: { type: number }) {
|
||||
|
||||
Reference in New Issue
Block a user