Per SETTINGS-AUDIT (all three tiers approved 2026-10-23): Dead settings: - panelOutbound, ipLimitAllowlist (+ orphan parser job), outboundDownThreshold - tg fake events outbound.down/outbound.up/xray.crash (no publisher in lite) - phantom routing sub-tabs (Happ/Clash/Incy) and subJsonRules direct-rules tab (no backend keys); subHideSettings moved to the subscription info tab first Upstream remnants: - ldap* / smtp* model+schema keys, EmailNotifications orphan component - twoFactorEnable/twoFactorToken + login-page probe (no backend handler) - externalTrafficInform* Newly approved removals: - JSON subscription family (producer, routes, UA auto-detect, mux/finalmask/ observatory) and Clash subscription family (producer, routes, UA detect) - SubBalancer (table, service, controller, settings tab) - subThemeDir (subscription page falls back to the built-in template) - datepicker jalalian option (gregorian only; Persian picker removed) JSON/Clash endpoints now 404; link-format output pinned by regression tests. Guarded keys untouched: webListen/webPort/webBasePath/certs, tg* chain, expireDiff/trafficDiff/tgBotProxy, subHideSettings, pageSize/tgLang.
210 lines
6.6 KiB
TypeScript
210 lines
6.6 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, ConfigProvider, Form, Input, Layout, Menu, Popover, Space, message } from 'antd';
|
|
import {
|
|
LockOutlined,
|
|
MoonFilled,
|
|
MoonOutlined,
|
|
SunOutlined,
|
|
TranslationOutlined,
|
|
UserOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
import { FormProvider, useForm } from 'react-hook-form';
|
|
import { HttpUtil, LanguageManager } from '@/utils';
|
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
|
import { setMessageInstance } from '@/utils/messageBus';
|
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
|
import { LoginFormSchema, type LoginFormValues } from '@/schemas/login';
|
|
import './LoginPage.css';
|
|
|
|
const HEADLINE_INTERVAL_MS = 2000;
|
|
|
|
type LoginForm = LoginFormValues;
|
|
|
|
const basePath = window.X_UI_BASE_PATH || '';
|
|
|
|
export default function LoginPage() {
|
|
const { t } = useTranslation();
|
|
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
|
|
useEffect(() => {
|
|
setMessageInstance(messageApi);
|
|
}, [messageApi]);
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [headlineIndex, setHeadlineIndex] = useState(0);
|
|
const methods = useForm<LoginForm>({
|
|
defaultValues: { username: '', password: '' },
|
|
});
|
|
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
|
|
|
const headlineWords = useMemo(() => [t('pages.login.hello'), t('pages.login.title')], [t]);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => {
|
|
setHeadlineIndex((i) => (i + 1) % headlineWords.length);
|
|
}, HEADLINE_INTERVAL_MS);
|
|
return () => window.clearInterval(timer);
|
|
}, [headlineWords.length]);
|
|
|
|
const onSubmit = useCallback(async (values: LoginForm) => {
|
|
setSubmitting(true);
|
|
try {
|
|
const msg = await HttpUtil.post('/login', values);
|
|
if (msg.success) window.location.href = basePath + 'panel/';
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, []);
|
|
|
|
const onLangChange = useCallback((next: string) => {
|
|
setLang(next);
|
|
LanguageManager.setLanguage(next);
|
|
}, []);
|
|
|
|
const cycleTheme = useCallback(() => {
|
|
pauseAnimationsUntilLeave('login-theme-cycle');
|
|
if (!isDark) {
|
|
toggleTheme();
|
|
if (isUltra) toggleUltra();
|
|
} else if (!isUltra) {
|
|
toggleUltra();
|
|
} else {
|
|
toggleUltra();
|
|
toggleTheme();
|
|
}
|
|
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
|
|
|
const pageClass = useMemo(() => {
|
|
const classes = ['login-app'];
|
|
if (isDark) classes.push('is-dark');
|
|
if (isUltra) classes.push('is-ultra');
|
|
return classes.join(' ');
|
|
}, [isDark, isUltra]);
|
|
|
|
const langMenuItems = useMemo(
|
|
() =>
|
|
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
|
|
(l) => ({
|
|
key: l.value,
|
|
label: (
|
|
<Space size={8}>
|
|
<span aria-hidden="true">{l.icon}</span>
|
|
<span>{l.name}</span>
|
|
</Space>
|
|
),
|
|
}),
|
|
),
|
|
[],
|
|
);
|
|
|
|
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
|
|
|
return (
|
|
<ConfigProvider theme={antdThemeConfig}>
|
|
{messageContextHolder}
|
|
<Layout className={pageClass}>
|
|
<Layout.Content className="login-content">
|
|
<div className="login-toolbar">
|
|
<Button
|
|
id="login-theme-cycle"
|
|
shape="circle"
|
|
size="large"
|
|
className="toolbar-btn"
|
|
aria-label={t('menu.theme')}
|
|
title={t('menu.theme')}
|
|
icon={themeIcon}
|
|
onClick={cycleTheme}
|
|
/>
|
|
<Popover
|
|
rootClassName={isDark ? 'dark' : 'light'}
|
|
placement="bottomRight"
|
|
trigger="click"
|
|
styles={{ content: { padding: 4 } }}
|
|
content={
|
|
<Menu
|
|
mode="vertical"
|
|
selectable
|
|
selectedKeys={[lang]}
|
|
items={langMenuItems}
|
|
onClick={({ key }) => onLangChange(key)}
|
|
style={{ border: 'none', minWidth: 160 }}
|
|
/>
|
|
}
|
|
>
|
|
<Button
|
|
shape="circle"
|
|
size="large"
|
|
className="toolbar-btn"
|
|
aria-label={t('pages.settings.language')}
|
|
icon={<TranslationOutlined />}
|
|
/>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="login-wrapper">
|
|
<div className="login-card">
|
|
<div className="brand">
|
|
<span className="brand-name">L-UI</span>
|
|
<span className="brand-accent" aria-hidden="true" />
|
|
</div>
|
|
<h2 className="welcome">
|
|
<b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
|
|
</h2>
|
|
|
|
<FormProvider {...methods}>
|
|
<Form
|
|
layout="vertical"
|
|
className="login-form"
|
|
onFinish={methods.handleSubmit(onSubmit)}
|
|
>
|
|
<FormField
|
|
name="username"
|
|
label={t('username')}
|
|
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }}
|
|
>
|
|
<Input
|
|
prefix={<UserOutlined />}
|
|
autoComplete="username"
|
|
size="large"
|
|
placeholder={t('username')}
|
|
autoFocus
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField
|
|
name="password"
|
|
label={t('password')}
|
|
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }}
|
|
>
|
|
<Input.Password
|
|
prefix={<LockOutlined />}
|
|
autoComplete="current-password"
|
|
size="large"
|
|
placeholder={t('password')}
|
|
/>
|
|
</FormField>
|
|
|
|
<Form.Item className="submit-row">
|
|
<Button
|
|
type="primary"
|
|
htmlType="submit"
|
|
loading={submitting}
|
|
size="large"
|
|
block
|
|
>
|
|
{t('login')}
|
|
</Button>
|
|
</Form.Item>
|
|
</Form>
|
|
</FormProvider>
|
|
</div>
|
|
</div>
|
|
</Layout.Content>
|
|
</Layout>
|
|
</ConfigProvider>
|
|
);
|
|
}
|