Files
l-ui/frontend/src/pages/login/LoginPage.tsx
T
Muse 1bd63e6625 settings trim: remove dead settings, upstream remnants, JSON/Clash subscriptions
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.
2026-10-22 19:21:07 +00:00

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>
);
}