mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +03:00
fd7b3559bc
Monthly sponsor placements need to change without cutting a panel release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in four slots: an overview banner, a rotating sidebar card (max three), the login page and a new Sponsors page that also lists open placements. An entry shows only while enable is not false and until is in the future; links must be https and logos are png/webp/jpg by name only. The list is cached for an hour and the last good copy survives upstream failures; logos are proxied through /sponsors/logo/:name with failures cached, so CSP stays 'self' and admin browsers never reach a third party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads a sibling ../sponsors/3X checkout so edits can be previewed before push.
260 lines
8.1 KiB
TypeScript
260 lines
8.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Button,
|
|
ConfigProvider,
|
|
Form,
|
|
Input,
|
|
Layout,
|
|
Menu,
|
|
Popover,
|
|
Space,
|
|
Spin,
|
|
message,
|
|
} from 'antd';
|
|
import {
|
|
KeyOutlined,
|
|
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 SponsorSlot from '@/components/sponsor/SponsorSlot';
|
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
|
import { LoginFormSchema, TwoFactorCodeSchema, 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 [fetched, setFetched] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
|
|
const [headlineIndex, setHeadlineIndex] = useState(0);
|
|
const methods = useForm<LoginForm>({
|
|
defaultValues: { username: '', password: '', twoFactorCode: '' },
|
|
});
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
|
if (cancelled) return;
|
|
if (msg.success) setTwoFactorEnable(!!msg.obj);
|
|
setFetched(true);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
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">
|
|
{!fetched ? (
|
|
<div className="login-loading">
|
|
<Spin size="large" />
|
|
</div>
|
|
) : (
|
|
<div className="login-card">
|
|
<div className="brand">
|
|
<span className="brand-name">3X-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>
|
|
|
|
{twoFactorEnable && (
|
|
<FormField
|
|
name="twoFactorCode"
|
|
label={t('twoFactorCode')}
|
|
rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
|
|
>
|
|
<Input
|
|
prefix={<KeyOutlined />}
|
|
autoComplete="one-time-code"
|
|
size="large"
|
|
placeholder={t('twoFactorCode')}
|
|
/>
|
|
</FormField>
|
|
)}
|
|
|
|
<Form.Item className="submit-row">
|
|
<Button
|
|
type="primary"
|
|
htmlType="submit"
|
|
loading={submitting}
|
|
size="large"
|
|
block
|
|
>
|
|
{t('login')}
|
|
</Button>
|
|
</Form.Item>
|
|
</Form>
|
|
</FormProvider>
|
|
<SponsorSlot slot="login" variant="compact" className="login-sponsor" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Layout.Content>
|
|
</Layout>
|
|
</ConfigProvider>
|
|
);
|
|
}
|