mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-27 21:47:14 +00:00
b9eda09da9
npm install was failing with ERESOLVE: the lockfile pinned storybook 10.5.7 and vitest 4.1.10 as peers while package.json asked for ^10.5.9 and ^4.1.11, and npm would not move either. Neither npm update, a targeted install, nor --package-lock-only broke the cycle, so node_modules and package-lock.json were regenerated from scratch (601 packages, 0 vulnerabilities). oxlint 1.79.0 then promoted five React Compiler rules into the correctness category, flagging 101 pre-existing sites. 1.78.0 exits 0 on the same tree, so nothing in our code changed - the rule set grew. They are fixed rather than suppressed: - refs (31): latest-value ref writes moved out of render into an effect. onlineClientsRef turned out to be write-only and is gone; expireDiffRef and trafficDiffRef were replaced by reading the values directly. - set-state-in-effect (55): reset-on-open modals now adjust state during render; where an effect mixed a synchronous reset with an async fetch, the reset moved to render and the effect kept only the request. useMediaQuery became useSyncExternalStore. - preserve-manual-memoization (11): optional-chained deps the compiler cannot match, hoisted to locals or dropped where the memo wrapped a string concat. - purity (3): Date.now() in render replaced by a state-backed clock, which also refreshes the expiry tag every 60s instead of freezing it until the next unrelated re-render. - immutability (1): applyClientStatsEvent merged websocket traffic into DBInbound rows in place; it now rebuilds only the rows it touches. Two things fell out of that. clientCount is derived with useMemo instead of an imperative rebuildClientCount() called from five sites, which also fixes a staleness bug where changing the expiry or traffic threshold left the counts alone until some later rebuild. statsVersion existed only to force a re-render after an in-place mutation, is meaningless now that rows are replaced, and nothing read it, so it is removed. Also adds a lint:fix script - oxlint --fix was previously only reachable through the lint-staged hook.
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>
|
|
</>
|
|
);
|
|
}
|