feat: enhance TOTP formatting and improve responsive styles for TOTP codes display

This commit is contained in:
shuaiplus
2026-06-16 19:17:05 +08:00
parent 7b3be2c819
commit 9e0908f43c
5 changed files with 55 additions and 14 deletions
+1 -8
View File
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import WebsiteIcon from '@/components/vault/WebsiteIcon'; import WebsiteIcon from '@/components/vault/WebsiteIcon';
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers'; import { formatTotp, isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps { interface TotpCodesPageProps {
ciphers: Cipher[]; ciphers: Cipher[];
@@ -26,13 +26,6 @@ function getTotpTimeState(): { windowId: number; remain: number } {
}; };
} }
function formatTotp(code: string): string {
if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
if (code.length < 6) return code;
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
}
function TotpListIcon({ cipher }: { cipher: Cipher }) { function TotpListIcon({ cipher }: { cipher: Cipher }) {
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />; return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
} }
@@ -507,8 +507,9 @@ export function maskSecret(value: string): string {
export function formatTotp(code: string): string { export function formatTotp(code: string): string {
if (!code) return code; if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`; if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
if (code.length < 6) return code; if (code.length <= 4) return code;
return `${code.slice(0, 3)} ${code.slice(3, 6)}`; if (code.length === 8) return `${code.slice(0, 4)} ${code.slice(4)}`;
return code.replace(/(.{3})(?=.)/g, '$1 ');
} }
export function formatHistoryTime(value: string | null | undefined): string { export function formatHistoryTime(value: string | null | undefined): string {
+31 -2
View File
@@ -220,6 +220,25 @@ function normalizeTotpSecret(secret: string): string {
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
} }
function readOtpAuthParam(raw: string, name: string): string {
const queryStart = raw.indexOf('?');
if (queryStart < 0) return '';
const fragmentStart = raw.indexOf('#', queryStart + 1);
const query = raw.slice(queryStart + 1, fragmentStart > queryStart ? fragmentStart : undefined);
for (const part of query.split('&')) {
const eq = part.indexOf('=');
const key = eq >= 0 ? part.slice(0, eq) : part;
if (key.trim().toLowerCase() !== name.toLowerCase()) continue;
const value = eq >= 0 ? part.slice(eq + 1) : '';
try {
return decodeURIComponent(value.replace(/\+/g, ' '));
} catch {
return value;
}
}
return '';
}
function parseSteamSecret(raw: string): string { function parseSteamSecret(raw: string): string {
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i); const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
if (!match?.[1]) return ''; if (!match?.[1]) return '';
@@ -276,7 +295,8 @@ function parseTotpConfig(raw: string): TotpConfig {
if (/^otpauth:\/\//i.test(s)) { if (/^otpauth:\/\//i.test(s)) {
try { try {
const u = new URL(s); const u = new URL(s);
if (u.hostname.toLowerCase() !== 'totp') { const otpType = u.hostname.toLowerCase();
if (otpType !== 'totp') {
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG }; return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
} }
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase(); const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
@@ -291,7 +311,16 @@ function parseTotpConfig(raw: string): TotpConfig {
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600), period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
}; };
} catch { } catch {
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG }; const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
const steam = issuer === 'steam' || algorithm === 'steam';
return {
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
steam,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
};
} }
} }
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG }; return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
+18
View File
@@ -681,6 +681,20 @@
gap: 10px; gap: 10px;
} }
.totp-codes-list {
grid-template-columns: 1fr;
}
.totp-code-row {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.totp-code-main {
justify-content: space-between;
width: 100%;
}
.totp-qr { .totp-qr {
min-height: 180px; min-height: 180px;
} }
@@ -892,6 +906,10 @@
font-size: 13px; font-size: 13px;
} }
.totp-code-main strong {
font-size: 20px;
}
.settings-module .field, .settings-module .field,
.auth-card .field { .auth-card .field {
margin-bottom: 8px; margin-bottom: 8px;
+2 -2
View File
@@ -952,7 +952,7 @@ select.input.duplicate-mode-toolbar-select {
.totp-codes-list { .totp-codes-list {
@apply grid w-full items-start gap-2.5; @apply grid w-full items-start gap-2.5;
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr)); grid-template-columns: repeat(var(--totp-columns, 1), minmax(0, 1fr));
} }
.totp-code-row { .totp-code-row {
@@ -977,7 +977,7 @@ select.input.duplicate-mode-toolbar-select {
} }
.totp-code-main strong { .totp-code-main strong {
@apply whitespace-nowrap text-[22px] leading-none; @apply min-w-0 whitespace-nowrap text-[22px] leading-none;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }