280 lines
9.4 KiB
TypeScript
280 lines
9.4 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* 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 { 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 {
|
|
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);
|
|
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;
|
|
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('');
|
|
setSecretCopied(false);
|
|
};
|
|
|
|
const cancelSetup = () => {
|
|
setSetupTotp(null);
|
|
setSetupUrl(null);
|
|
setSetupCode('');
|
|
setSetupError(null);
|
|
setSecretCopied(false);
|
|
};
|
|
|
|
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>
|
|
{setupSecret && (
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm font-medium">{t('otp.manualEntryLabel', 'Or enter this code manually')}</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
'otp.manualEntryDescription',
|
|
'If you cannot scan the QR code, enter this secret into your authenticator app instead.',
|
|
)}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<code className="flex-1 rounded bg-muted p-2 text-sm font-mono break-all select-all">{setupSecret}</code>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={copySecret}
|
|
aria-label={t('otp.copySecret', 'Copy secret')}
|
|
>
|
|
{secretCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
</Button>
|
|
</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>
|
|
);
|
|
}
|