Files
Stalwart-webui/src/components/forms/OtpAuthField.tsx
T

233 lines
7.7 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<OTPAuth.TOTP | null>(null);
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [setupCode, setSetupCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(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 (
<div className="text-sm text-muted-foreground">
{isConfigured
? t('otp.statusEnabled', 'Two-factor authentication is enabled.')
: t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
</div>
);
}
if (!isConfigured && setupUrl) {
return (
<div className="rounded-md border bg-muted/30 p-4 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">{t('otp.scanPrompt', 'Scan with your authenticator app')}</p>
<p className="text-xs text-muted-foreground">
{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.',
)}
</p>
</div>
<div className="flex justify-center">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt={t('otp.qrCodeAlt', 'TOTP QR code')}
width={220}
height={220}
className="rounded-md border bg-white p-2"
/>
) : (
<div className="flex h-[220px] w-[220px] items-center justify-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.confirmationCodeLabel', 'Confirmation code')}</Label>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={setupCode}
onChange={(e) => setSetupCode(e.target.value.replace(/\s+/g, ''))}
maxLength={10}
className="font-mono tracking-widest text-center max-w-[12rem]"
/>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
</div>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelSetup}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" size="sm" onClick={confirmSetup}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('common.confirm', 'Confirm')}
</Button>
</div>
</div>
);
}
if (!isConfigured) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
</span>
<Button type="button" variant="outline" size="sm" onClick={startSetup}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('otp.setUp', 'Set up TOTP')}
</Button>
</div>
);
}
return (
<div className="rounded-md border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">{t('otp.statusEnabled', 'Two-factor authentication is enabled.')}</span>
</div>
<p className="text-xs text-muted-foreground">
{t(
'otp.currentCodePrompt',
'Enter your current 6-digit code to authorise any change to this account (including disabling two-factor authentication).',
)}
</p>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.currentCodeLabel', 'Current code')}</Label>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={otpCodeValue}
onChange={(e) => handleCodeChange(e.target.value.replace(/\s+/g, ''))}
maxLength={10}
className="font-mono tracking-widest text-center max-w-[12rem]"
/>
</div>
<div className="flex items-center justify-end">
<Button type="button" variant="destructive" size="sm" onClick={handleDisable} disabled={!otpCodeValue.trim()}>
<ShieldOff className="mr-2 h-4 w-4" />
{t('otp.disable', 'Disable')}
</Button>
</div>
</div>
);
}