Initial commit
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
interface OtpAuthValue {
|
||||
otpUrl?: string | null;
|
||||
otpCode?: string | null;
|
||||
}
|
||||
|
||||
interface OtpAuthFieldProps {
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const SECRET_MASK = '*****';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user