165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
|
|
import { useState } from 'react';
|
||
|
|
import { useMutation } from '@tanstack/react-query';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
|
||
|
|
import { enableTotp, setupTotp } from '@/api/auth';
|
||
|
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||
|
|
import { Field } from '@/components/ui/Field';
|
||
|
|
import { Button, Card } from '@/components/ui/primitives';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Включение двухфакторной аутентификации: setup → секрет (и QR) → код →
|
||
|
|
* одноразовые коды восстановления, которые показываются ровно один раз.
|
||
|
|
*/
|
||
|
|
export function TwoFactorSection({
|
||
|
|
enabled,
|
||
|
|
onEnabled,
|
||
|
|
}: {
|
||
|
|
enabled: boolean;
|
||
|
|
onEnabled: () => void;
|
||
|
|
}) {
|
||
|
|
const { t } = useTranslation();
|
||
|
|
const [secret, setSecret] = useState<string | null>(null);
|
||
|
|
const [otpauthUrl, setOtpauthUrl] = useState('');
|
||
|
|
const [code, setCode] = useState('');
|
||
|
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||
|
|
const [qrFailed, setQrFailed] = useState(false);
|
||
|
|
const [copied, setCopied] = useState(false);
|
||
|
|
|
||
|
|
const setup = useMutation({
|
||
|
|
mutationFn: () => setupTotp(),
|
||
|
|
onSuccess: (payload) => {
|
||
|
|
setSecret(payload.secret);
|
||
|
|
setOtpauthUrl(payload.otpauth_url);
|
||
|
|
setRecoveryCodes(null);
|
||
|
|
setQrFailed(false);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const enable = useMutation({
|
||
|
|
mutationFn: () => enableTotp(code.trim()),
|
||
|
|
onSuccess: (codes) => {
|
||
|
|
setRecoveryCodes(codes);
|
||
|
|
setSecret(null);
|
||
|
|
setCode('');
|
||
|
|
onEnabled();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const copyRecovery = (): void => {
|
||
|
|
if (recoveryCodes === null) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
void navigator.clipboard?.writeText(recoveryCodes.join('\n'));
|
||
|
|
setCopied(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<div className="flex items-start justify-between gap-4">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-lg font-semibold">{t('settings.security.twoFactorTitle')}</h2>
|
||
|
|
<p className="mt-1 text-sm text-fg-muted">
|
||
|
|
{t(
|
||
|
|
enabled
|
||
|
|
? 'settings.security.twoFactorEnabled'
|
||
|
|
: 'settings.security.twoFactorDisabled',
|
||
|
|
)}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{enabled || secret !== null ? null : (
|
||
|
|
<Button disabled={setup.isPending} onClick={() => setup.mutate()}>
|
||
|
|
{t('settings.security.twoFactorEnable')}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{setup.isError ? <ErrorNotice className="mt-3" error={setup.error} /> : null}
|
||
|
|
|
||
|
|
{secret !== null ? (
|
||
|
|
<div className="mt-4 flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3">
|
||
|
|
<p className="text-sm text-fg-muted">{t('settings.security.twoFactorSetupHint')}</p>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap items-start gap-4">
|
||
|
|
{qrFailed || otpauthUrl === '' ? (
|
||
|
|
<p className="text-xs text-fg-muted">
|
||
|
|
{t('settings.security.twoFactorQrUnavailable')}
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<img
|
||
|
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(
|
||
|
|
otpauthUrl,
|
||
|
|
)}`}
|
||
|
|
alt={t('settings.security.twoFactorQrAlt')}
|
||
|
|
width={160}
|
||
|
|
height={160}
|
||
|
|
className="rounded-[var(--radius-sm)] bg-white p-1"
|
||
|
|
onError={() => setQrFailed(true)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
<dl className="text-sm">
|
||
|
|
<dt className="text-fg-muted">{t('settings.security.twoFactorSecret')}</dt>
|
||
|
|
<dd className="mt-1 font-mono text-xs break-all" data-testid="totp-secret">
|
||
|
|
{secret}
|
||
|
|
</dd>
|
||
|
|
<dt className="mt-3 text-fg-muted">{t('settings.security.twoFactorUri')}</dt>
|
||
|
|
<dd className="mt-1 font-mono text-xs break-all">{otpauthUrl}</dd>
|
||
|
|
</dl>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form
|
||
|
|
className="flex flex-col gap-3"
|
||
|
|
onSubmit={(event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
if (code.trim() !== '') {
|
||
|
|
enable.mutate();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Field
|
||
|
|
label={t('settings.security.twoFactorCode')}
|
||
|
|
name="totp_code"
|
||
|
|
inputMode="numeric"
|
||
|
|
autoComplete="one-time-code"
|
||
|
|
pattern="[0-9]*"
|
||
|
|
maxLength={8}
|
||
|
|
value={code}
|
||
|
|
onChange={(event) => setCode(event.target.value)}
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
{enable.isError ? <ErrorNotice error={enable.error} /> : null}
|
||
|
|
<div className="flex justify-end">
|
||
|
|
<Button type="submit" disabled={enable.isPending || code.trim() === ''}>
|
||
|
|
{t(enable.isPending ? 'common.saving' : 'settings.security.twoFactorConfirm')}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{recoveryCodes !== null ? (
|
||
|
|
<div className="mt-4 rounded-[var(--radius-md)] border border-success/50 bg-success/10 p-3">
|
||
|
|
<p className="text-sm text-success" role="status">
|
||
|
|
{t('settings.security.twoFactorEnabledDone')}
|
||
|
|
</p>
|
||
|
|
<h3 className="mt-2 text-sm font-semibold">{t('settings.security.recoveryTitle')}</h3>
|
||
|
|
<p className="text-xs text-fg-muted">{t('settings.security.recoveryHint')}</p>
|
||
|
|
<ul
|
||
|
|
className="mt-2 grid grid-cols-2 gap-1 font-mono text-xs"
|
||
|
|
data-testid="recovery-codes"
|
||
|
|
>
|
||
|
|
{recoveryCodes.map((recoveryCode) => (
|
||
|
|
<li key={recoveryCode}>{recoveryCode}</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
<div className="mt-3 flex items-center gap-3">
|
||
|
|
<Button variant="ghost" onClick={copyRecovery}>
|
||
|
|
{t(copied ? 'common.copied' : 'settings.security.recoveryCopyAll')}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|