diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index 9ee9c80..1bffc9b 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -6,7 +6,7 @@ import { t } from '@/lib/i18n'; import type { Cipher } from '@/lib/types'; import LoadingState from '@/components/LoadingState'; 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 { 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 }) { return } />; } diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 125588d..42e9151 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -507,8 +507,9 @@ export function maskSecret(value: string): string { export 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)}`; + if (code.length <= 4) return code; + 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 { diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts index 9bb2f1f..af59509 100644 --- a/webapp/src/lib/crypto.ts +++ b/webapp/src/lib/crypto.ts @@ -220,6 +220,25 @@ function normalizeTotpSecret(secret: string): string { 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 { const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i); if (!match?.[1]) return ''; @@ -276,7 +295,8 @@ function parseTotpConfig(raw: string): TotpConfig { if (/^otpauth:\/\//i.test(s)) { try { 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 }; } 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), }; } 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 }; diff --git a/webapp/src/styles/responsive.css b/webapp/src/styles/responsive.css index b4c43a4..d03a3f3 100644 --- a/webapp/src/styles/responsive.css +++ b/webapp/src/styles/responsive.css @@ -681,6 +681,20 @@ 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 { min-height: 180px; } @@ -892,6 +906,10 @@ font-size: 13px; } + .totp-code-main strong { + font-size: 20px; + } + .settings-module .field, .auth-card .field { margin-bottom: 8px; diff --git a/webapp/src/styles/vault.css b/webapp/src/styles/vault.css index d60224a..87c5ef9 100644 --- a/webapp/src/styles/vault.css +++ b/webapp/src/styles/vault.css @@ -952,7 +952,7 @@ select.input.duplicate-mode-toolbar-select { .totp-codes-list { @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 { @@ -977,7 +977,7 @@ select.input.duplicate-mode-toolbar-select { } .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; }