diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb9436..dbbffcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file. This projec ### Added - OIDC: - Include `email` and `profile` scopes in OIDC authentication requests. +- TOTP: + - Add "Copy Secret" button to TOTP setup flow. ### Changed diff --git a/src/components/forms/OtpAuthField.tsx b/src/components/forms/OtpAuthField.tsx index 5043cde..3062c1a 100644 --- a/src/components/forms/OtpAuthField.tsx +++ b/src/components/forms/OtpAuthField.tsx @@ -8,11 +8,12 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as OTPAuth from 'otpauth'; import QRCode from 'qrcode'; -import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react'; +import { Check, Copy, Loader2, ShieldCheck, ShieldOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { toast } from '@/hooks/use-toast'; import { SECRET_MASK } from '@/lib/jmapUtils'; interface OtpAuthValue { @@ -56,6 +57,27 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) { const [qrDataUrl, setQrDataUrl] = useState(null); const [setupCode, setSetupCode] = useState(''); const [setupError, setSetupError] = useState(null); + const [secretCopied, setSecretCopied] = useState(false); + + const setupSecret = useMemo(() => { + if (!setupTotp) return null; + return setupTotp.secret.base32.replace(/(.{4})/g, '$1 ').trim(); + }, [setupTotp]); + + const copySecret = async () => { + if (!setupTotp) return; + try { + await navigator.clipboard.writeText(setupTotp.secret.base32); + setSecretCopied(true); + setTimeout(() => setSecretCopied(false), 1500); + } catch { + toast({ + title: t('otp.copyFailed', 'Copy failed'), + description: t('otp.clipboardBlocked', 'Your browser blocked clipboard access.'), + variant: 'destructive', + }); + } + }; useEffect(() => { if (!setupUrl) return; @@ -96,6 +118,7 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) { setSetupTotp(null); setSetupUrl(null); setSetupCode(''); + setSecretCopied(false); }; const cancelSetup = () => { @@ -103,6 +126,7 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) { setSetupUrl(null); setSetupCode(''); setSetupError(null); + setSecretCopied(false); }; const otpCodeValue = useMemo( @@ -155,6 +179,29 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) { )} + {setupSecret && ( +
+ +

+ {t( + 'otp.manualEntryDescription', + 'If you cannot scan the QR code, enter this secret into your authenticator app instead.', + )} +

+
+ {setupSecret} + +
+
+ )}