/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ 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 { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { SECRET_MASK } from '@/lib/jmapUtils'; interface OtpAuthValue { otpUrl?: string | null; otpCode?: string | null; } interface OtpAuthFieldProps { value: unknown; onChange: (value: unknown) => void; readOnly: boolean; } const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png'; function buildOtpAuthUrl(totp: OTPAuth.TOTP): string { const base = totp.toString(); const sep = base.includes('?') ? '&' : '?'; return `${base}${sep}image=${encodeURIComponent(STALWART_IMAGE_URL)}`; } function generateTotp(): { totp: OTPAuth.TOTP; url: string } { const totp = new OTPAuth.TOTP({ issuer: 'Stalwart', label: 'account', algorithm: 'SHA1', digits: 6, period: 30, secret: new OTPAuth.Secret({ size: 20 }), }); return { totp, url: buildOtpAuthUrl(totp) }; } export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) { const { t } = useTranslation(); const objValue = (value as OtpAuthValue | null | undefined) ?? {}; const isConfigured = objValue.otpUrl != null && objValue.otpUrl !== ''; const [setupTotp, setSetupTotp] = useState(null); const [setupUrl, setSetupUrl] = useState(null); const [qrDataUrl, setQrDataUrl] = useState(null); const [setupCode, setSetupCode] = useState(''); const [setupError, setSetupError] = useState(null); useEffect(() => { if (!setupUrl) return; let cancelled = false; QRCode.toDataURL(setupUrl, { width: 220, margin: 1 }) .then((dataUrl) => { if (!cancelled) setQrDataUrl(dataUrl); }) .catch((err) => { console.error('Failed to render QR code', err); }); return () => { cancelled = true; }; }, [setupUrl]); const startSetup = () => { const { totp, url } = generateTotp(); setSetupTotp(totp); setSetupUrl(url); setSetupCode(''); setSetupError(null); }; const confirmSetup = () => { if (!setupTotp || !setupUrl) return; if (!setupCode.trim()) { setSetupError(t('otp.enterCodePrompt', 'Enter the code shown in your authenticator.')); return; } const delta = setupTotp.validate({ token: setupCode.trim(), window: 1 }); if (delta === null) { setSetupError(t('otp.codeIncorrect', 'That code is incorrect. Make sure your authenticator clock is in sync.')); return; } setSetupError(null); onChange({ otpUrl: setupUrl, otpCode: setupCode.trim() }); setSetupTotp(null); setSetupUrl(null); setSetupCode(''); }; const cancelSetup = () => { setSetupTotp(null); setSetupUrl(null); setSetupCode(''); setSetupError(null); }; const otpCodeValue = useMemo( () => (typeof objValue.otpCode === 'string' && objValue.otpCode !== SECRET_MASK ? objValue.otpCode : ''), [objValue.otpCode], ); const handleCodeChange = (code: string) => { onChange({ ...objValue, otpCode: code }); }; const handleDisable = () => { onChange({ ...objValue, otpUrl: null }); }; if (readOnly) { return (
{isConfigured ? t('otp.statusEnabled', 'Two-factor authentication is enabled.') : t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
); } if (!isConfigured && setupUrl) { return (

{t('otp.scanPrompt', 'Scan with your authenticator app')}

{t( 'otp.scanDescription', 'Scan the QR code below with Google Authenticator, 1Password, Authy, or any other TOTP app, then enter the 6-digit code it shows to confirm setup.', )}

{qrDataUrl ? ( {t('otp.qrCodeAlt', ) : (
)}
setSetupCode(e.target.value.replace(/\s+/g, ''))} maxLength={10} className="font-mono tracking-widest text-center max-w-[12rem]" /> {setupError &&

{setupError}

}
); } if (!isConfigured) { return (
{t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
); } return (
{t('otp.statusEnabled', 'Two-factor authentication is enabled.')}

{t( 'otp.currentCodePrompt', 'Enter your current 6-digit code to authorise any change to this account (including disabling two-factor authentication).', )}

handleCodeChange(e.target.value.replace(/\s+/g, ''))} maxLength={10} className="font-mono tracking-widest text-center max-w-[12rem]" />
); }