Release 3X-UI for Docker / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
162 lines
4.2 KiB
TypeScript
162 lines
4.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
|
|
import * as OTPAuth from 'otpauth';
|
|
|
|
import { ClipboardManager } from '@/utils';
|
|
import { activateOnKey } from '@/utils/a11y';
|
|
import { TotpCodeSchema } from '@/schemas/login';
|
|
import './TwoFactorModal.css';
|
|
|
|
type Type = 'set' | 'confirm';
|
|
|
|
interface TwoFactorModalProps {
|
|
open: boolean;
|
|
title?: string;
|
|
description?: string;
|
|
token?: string;
|
|
type?: Type;
|
|
onConfirm: (success: boolean, code?: string) => void;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export default function TwoFactorModal({
|
|
open,
|
|
title = '',
|
|
description = '',
|
|
token = '',
|
|
type = 'set',
|
|
onConfirm,
|
|
onOpenChange,
|
|
}: TwoFactorModalProps) {
|
|
const { t } = useTranslation();
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
const [enteredCode, setEnteredCode] = useState('');
|
|
|
|
const totp = useMemo(() => {
|
|
if (!open || !token) return null;
|
|
return new OTPAuth.TOTP({
|
|
issuer: '3x-ui',
|
|
label: 'Administrator',
|
|
algorithm: 'SHA1',
|
|
digits: 6,
|
|
period: 30,
|
|
secret: token,
|
|
});
|
|
}, [open, token]);
|
|
const qrValue = totp ? totp.toString() : '';
|
|
|
|
const [wasOpen, setWasOpen] = useState(false);
|
|
if (open !== wasOpen) {
|
|
setWasOpen(open);
|
|
if (open) setEnteredCode('');
|
|
}
|
|
|
|
function close(success: boolean, code = '') {
|
|
onConfirm(success, code);
|
|
onOpenChange(false);
|
|
setEnteredCode('');
|
|
}
|
|
|
|
function onOk() {
|
|
const codeOk = TotpCodeSchema.safeParse(enteredCode);
|
|
if (!codeOk.success) {
|
|
messageApi.error(
|
|
t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'),
|
|
);
|
|
return;
|
|
}
|
|
if (type === 'confirm' && !token) {
|
|
close(true, codeOk.data);
|
|
return;
|
|
}
|
|
if (!totp) return;
|
|
if (totp.generate() === codeOk.data) {
|
|
close(true);
|
|
} else {
|
|
messageApi.error(t('pages.settings.security.twoFactorModalError'));
|
|
}
|
|
}
|
|
|
|
function onCancel() {
|
|
close(false);
|
|
}
|
|
|
|
async function copyToken() {
|
|
const ok = await ClipboardManager.copyText(token);
|
|
if (ok) messageApi.success(t('copied'));
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{messageContextHolder}
|
|
<Modal
|
|
open={open}
|
|
title={title}
|
|
closable
|
|
onCancel={onCancel}
|
|
footer={[
|
|
<Button key="cancel" onClick={onCancel}>
|
|
{t('cancel')}
|
|
</Button>,
|
|
<Button
|
|
key="ok"
|
|
type="primary"
|
|
disabled={!TotpCodeSchema.safeParse(enteredCode).success}
|
|
onClick={onOk}
|
|
>
|
|
{t('confirm')}
|
|
</Button>,
|
|
]}
|
|
>
|
|
{type === 'set' ? (
|
|
<>
|
|
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
|
|
<Divider />
|
|
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
|
|
<div
|
|
className="qr-wrap"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t('copy')}
|
|
onClick={copyToken}
|
|
onKeyDown={activateOnKey(copyToken)}
|
|
>
|
|
<QRCode
|
|
className="qr-code"
|
|
value={qrValue}
|
|
size={180}
|
|
type="svg"
|
|
bordered={false}
|
|
color="#000000"
|
|
bgColor="#ffffff"
|
|
errorLevel="L"
|
|
title={t('copy')}
|
|
/>
|
|
<span className="qr-token">{token}</span>
|
|
</div>
|
|
<Divider />
|
|
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
|
|
<Input
|
|
value={enteredCode}
|
|
onChange={(e) => setEnteredCode(e.target.value)}
|
|
style={{ width: '100%' }}
|
|
aria-label={t('twoFactorCode')}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p>{description}</p>
|
|
<Input
|
|
value={enteredCode}
|
|
onChange={(e) => setEnteredCode(e.target.value)}
|
|
style={{ width: '100%' }}
|
|
aria-label={t('twoFactorCode')}
|
|
/>
|
|
</>
|
|
)}
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|