From d50ec74b242d077d055e25da762102e9868065a6 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 21 May 2026 21:48:15 +0200 Subject: [PATCH] refactor(frontend): port settings to react+ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 of the planned vue->react migration. Settings is the first entry whose state model didn't translate to the Vue-style "parent passes a reactive object, children mutate it in place" pattern, so the React port flips it to lifted state + a typed updateSetting patch function. * models/setting.ts — typed AllSetting class with the same field defaults and equals() behavior the vue version had. The .js twin is deleted; nothing else imported it. * hooks/useAllSetting.ts — owns allSetting + oldAllSetting state, exposes updateSetting(patch), saveDisabled is derived via useMemo off equals() (no more 1Hz dirty-check timer). * components/SettingListItem.tsx — children-based wrapper instead of named slots. The vue twin stays alive because xray (BasicsTab, DnsTab) still imports it; deleted when xray migrates. The five tab components and the TwoFactorModal each accept { allSetting, updateSetting } and render with AntD v5's Collapse items[] API. Every v-model:value="x" became value={...} onChange={(e) => updateSetting({ key: e.target.value })} or onChange={(v) => updateSetting({ key: v })} for non-input controls. SubscriptionFormatsTab is the trickiest — fragment / noises[] / mux / direct routing rules are stored as JSON-encoded strings on the wire. Parsing them once via useMemo per field, mutating the parsed object on edit, and stringifying back into the patch keeps the round-trip identical to the vue version. SettingsPage hosts the tab navigation (with hash sync), the save / restart action bar, the security-warnings alert banner, and the restart flow that rebuilds the panel URL after the new host/port/cert settings take effect. --- frontend/settings.html | 2 +- frontend/src/components/SettingListItem.tsx | 30 ++ frontend/src/entries/settings.js | 23 - frontend/src/entries/settings.tsx | 28 ++ frontend/src/hooks/useAllSetting.ts | 69 +++ frontend/src/models/setting.js | 108 ----- frontend/src/models/setting.ts | 100 ++++ frontend/src/pages/settings/GeneralTab.tsx | 352 ++++++++++++++ frontend/src/pages/settings/GeneralTab.vue | 437 ------------------ frontend/src/pages/settings/SecurityTab.css | 84 ++++ frontend/src/pages/settings/SecurityTab.tsx | 385 +++++++++++++++ frontend/src/pages/settings/SecurityTab.vue | 404 ---------------- frontend/src/pages/settings/SettingsPage.css | 87 ++++ frontend/src/pages/settings/SettingsPage.tsx | 331 +++++++++++++ frontend/src/pages/settings/SettingsPage.vue | 375 --------------- .../pages/settings/SubscriptionFormatsTab.css | 4 + .../pages/settings/SubscriptionFormatsTab.tsx | 434 +++++++++++++++++ .../pages/settings/SubscriptionFormatsTab.vue | 433 ----------------- .../pages/settings/SubscriptionGeneralTab.tsx | 140 ++++++ .../pages/settings/SubscriptionGeneralTab.vue | 204 -------- frontend/src/pages/settings/TelegramTab.tsx | 106 +++++ frontend/src/pages/settings/TelegramTab.vue | 109 ----- .../src/pages/settings/TwoFactorModal.css | 20 + .../src/pages/settings/TwoFactorModal.tsx | 129 ++++++ .../src/pages/settings/TwoFactorModal.vue | 126 ----- frontend/src/pages/settings/useAllSetting.js | 80 ---- 26 files changed, 2300 insertions(+), 2300 deletions(-) create mode 100644 frontend/src/components/SettingListItem.tsx delete mode 100644 frontend/src/entries/settings.js create mode 100644 frontend/src/entries/settings.tsx create mode 100644 frontend/src/hooks/useAllSetting.ts delete mode 100644 frontend/src/models/setting.js create mode 100644 frontend/src/models/setting.ts create mode 100644 frontend/src/pages/settings/GeneralTab.tsx delete mode 100644 frontend/src/pages/settings/GeneralTab.vue create mode 100644 frontend/src/pages/settings/SecurityTab.css create mode 100644 frontend/src/pages/settings/SecurityTab.tsx delete mode 100644 frontend/src/pages/settings/SecurityTab.vue create mode 100644 frontend/src/pages/settings/SettingsPage.css create mode 100644 frontend/src/pages/settings/SettingsPage.tsx delete mode 100644 frontend/src/pages/settings/SettingsPage.vue create mode 100644 frontend/src/pages/settings/SubscriptionFormatsTab.css create mode 100644 frontend/src/pages/settings/SubscriptionFormatsTab.tsx delete mode 100644 frontend/src/pages/settings/SubscriptionFormatsTab.vue create mode 100644 frontend/src/pages/settings/SubscriptionGeneralTab.tsx delete mode 100644 frontend/src/pages/settings/SubscriptionGeneralTab.vue create mode 100644 frontend/src/pages/settings/TelegramTab.tsx delete mode 100644 frontend/src/pages/settings/TelegramTab.vue create mode 100644 frontend/src/pages/settings/TwoFactorModal.css create mode 100644 frontend/src/pages/settings/TwoFactorModal.tsx delete mode 100644 frontend/src/pages/settings/TwoFactorModal.vue delete mode 100644 frontend/src/pages/settings/useAllSetting.js diff --git a/frontend/settings.html b/frontend/settings.html index 0ef6413b..e753ffe1 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -8,6 +8,6 @@
- + diff --git a/frontend/src/components/SettingListItem.tsx b/frontend/src/components/SettingListItem.tsx new file mode 100644 index 00000000..46f74c87 --- /dev/null +++ b/frontend/src/components/SettingListItem.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; +import { Col, List, Row } from 'antd'; + +interface SettingListItemProps { + paddings?: 'small' | 'default'; + title?: ReactNode; + description?: ReactNode; + children?: ReactNode; +} + +export default function SettingListItem({ + paddings = 'default', + title, + description, + children, +}: SettingListItemProps) { + const padding = paddings === 'small' ? '10px 20px' : '20px'; + return ( + + + + + + + {children} + + + + ); +} diff --git a/frontend/src/entries/settings.js b/frontend/src/entries/settings.js deleted file mode 100644 index ca3e6f29..00000000 --- a/frontend/src/entries/settings.js +++ /dev/null @@ -1,23 +0,0 @@ -import { createApp } from 'vue'; -import Antd, { message } from 'ant-design-vue'; -import 'ant-design-vue/dist/reset.css'; - -import { setupAxios } from '@/api/axios-init.js'; -// Importing useTheme triggers the boot side-effect that applies the -// stored theme to / before Vue mounts. -import '@/composables/useTheme.js'; -import { i18n, readyI18n } from '@/i18n/index.js'; -import { applyDocumentTitle } from '@/utils'; -import SettingsPage from '@/pages/settings/SettingsPage.vue'; - -setupAxios(); -applyDocumentTitle(); - -const messageContainer = document.getElementById('message'); -if (messageContainer) { - message.config({ getContainer: () => messageContainer }); -} - -readyI18n().then(() => { - createApp(SettingsPage).use(Antd).use(i18n).mount('#app'); -}); diff --git a/frontend/src/entries/settings.tsx b/frontend/src/entries/settings.tsx new file mode 100644 index 00000000..b6397963 --- /dev/null +++ b/frontend/src/entries/settings.tsx @@ -0,0 +1,28 @@ +import { createRoot } from 'react-dom/client'; +import { message } from 'antd'; +import 'antd/dist/reset.css'; + +import { setupAxios } from '@/api/axios-init.js'; +import { applyDocumentTitle } from '@/utils'; +import { readyI18n } from '@/i18n/react'; +import { ThemeProvider } from '@/hooks/useTheme'; +import SettingsPage from '@/pages/settings/SettingsPage'; + +setupAxios(); +applyDocumentTitle(); + +const messageContainer = document.getElementById('message'); +if (messageContainer) { + message.config({ getContainer: () => messageContainer }); +} + +readyI18n().then(() => { + const root = document.getElementById('app'); + if (root) { + createRoot(root).render( + + + , + ); + } +}); diff --git a/frontend/src/hooks/useAllSetting.ts b/frontend/src/hooks/useAllSetting.ts new file mode 100644 index 00000000..89c3b536 --- /dev/null +++ b/frontend/src/hooks/useAllSetting.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { HttpUtil } from '@/utils'; +import { AllSetting } from '@/models/setting'; + +interface ApiMsg { + success?: boolean; + obj?: T; +} + +export function useAllSetting() { + const [allSetting, setAllSetting] = useState(() => new AllSetting()); + const [oldAllSetting, setOldAllSetting] = useState(() => new AllSetting()); + const [fetched, setFetched] = useState(false); + const [spinning, setSpinning] = useState(false); + const fetchedRef = useRef(false); + + const applyServerState = useCallback((obj: unknown) => { + setAllSetting(new AllSetting(obj)); + setOldAllSetting(new AllSetting(obj)); + }, []); + + const fetchAll = useCallback(async () => { + const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg; + if (msg?.success) { + applyServerState(msg.obj); + fetchedRef.current = true; + setFetched(true); + } + }, [applyServerState]); + + const saveAll = useCallback(async () => { + setSpinning(true); + try { + const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg; + if (msg?.success) await fetchAll(); + } finally { + setSpinning(false); + } + }, [allSetting, fetchAll]); + + const updateSetting = useCallback((patch: Partial) => { + setAllSetting((prev) => { + const next = new AllSetting(prev); + Object.assign(next, patch); + return next; + }); + }, []); + + const saveDisabled = useMemo( + () => allSetting.equals(oldAllSetting), + [allSetting, oldAllSetting], + ); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchAll(); + }, [fetchAll]); + + return { + allSetting, + updateSetting, + fetched, + spinning, + setSpinning, + saveDisabled, + fetchAll, + saveAll, + }; +} diff --git a/frontend/src/models/setting.js b/frontend/src/models/setting.js deleted file mode 100644 index 4bcbcefb..00000000 --- a/frontend/src/models/setting.js +++ /dev/null @@ -1,108 +0,0 @@ -// Mirrors web/assets/js/model/setting.js — every field on this class is -// round-tripped through `/panel/setting/all` and `/panel/setting/update`, -// so adding a field here without a matching Go-side change will silently -// drop it on save. Defaults match the legacy panel. - -import { ObjectUtil } from '@/utils'; - -export class AllSetting { - - constructor(data) { - this.webListen = ""; - this.webDomain = ""; - this.webPort = 2053; - this.webCertFile = ""; - this.webKeyFile = ""; - this.webBasePath = "/"; - this.sessionMaxAge = 360; - this.trustedProxyCIDRs = "127.0.0.1/32,::1/128"; - this.pageSize = 25; - this.expireDiff = 0; - this.trafficDiff = 0; - this.remarkModel = "-ieo"; - this.datepicker = "gregorian"; - this.tgBotEnable = false; - this.tgBotToken = ""; - this.tgBotProxy = ""; - this.tgBotAPIServer = ""; - this.tgBotChatId = ""; - this.tgRunTime = "@daily"; - this.tgBotBackup = false; - this.tgBotLoginNotify = true; - this.tgCpu = 80; - this.tgLang = "en-US"; - this.twoFactorEnable = false; - this.twoFactorToken = ""; - this.xrayTemplateConfig = ""; - this.subEnable = true; - this.subJsonEnable = false; - this.subTitle = ""; - this.subSupportUrl = ""; - this.subProfileUrl = ""; - this.subAnnounce = ""; - this.subEnableRouting = true; - this.subRoutingRules = ""; - this.subListen = ""; - this.subPort = 2096; - this.subPath = "/sub/"; - this.subJsonPath = "/json/"; - this.subClashEnable = true; - this.subClashPath = "/clash/"; - this.subDomain = ""; - this.externalTrafficInformEnable = false; - this.externalTrafficInformURI = ""; - this.restartXrayOnClientDisable = true; - this.subCertFile = ""; - this.subKeyFile = ""; - this.subUpdates = 12; - this.subEncrypt = true; - this.subShowInfo = true; - this.subEmailInRemark = true; - this.subURI = ""; - this.subJsonURI = ""; - this.subClashURI = ""; - this.subJsonFragment = ""; - this.subJsonNoises = ""; - this.subJsonMux = ""; - this.subJsonRules = ""; - - this.timeLocation = "Local"; - - // LDAP settings - this.ldapEnable = false; - this.ldapHost = ""; - this.ldapPort = 389; - this.ldapUseTLS = false; - this.ldapBindDN = ""; - this.ldapPassword = ""; - this.ldapBaseDN = ""; - this.ldapUserFilter = "(objectClass=person)"; - this.ldapUserAttr = "mail"; - this.ldapVlessField = "vless_enabled"; - this.ldapSyncCron = "@every 1m"; - this.ldapFlagField = ""; - this.ldapTruthyValues = "true,1,yes,on"; - this.ldapInvertFlag = false; - this.ldapInboundTags = ""; - this.ldapAutoCreate = false; - this.ldapAutoDelete = false; - this.ldapDefaultTotalGB = 0; - this.ldapDefaultExpiryDays = 0; - this.ldapDefaultLimitIP = 0; - this.hasTgBotToken = false; - this.hasTwoFactorToken = false; - this.hasLdapPassword = false; - this.hasApiToken = false; - this.hasWarpSecret = false; - this.hasNordSecret = false; - - if (data == null) { - return - } - ObjectUtil.cloneProps(this, data); - } - - equals(other) { - return ObjectUtil.equals(this, other); - } -} diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts new file mode 100644 index 00000000..d4d09d91 --- /dev/null +++ b/frontend/src/models/setting.ts @@ -0,0 +1,100 @@ +import { ObjectUtil } from '@/utils'; + +export class AllSetting { + webListen = ''; + webDomain = ''; + webPort = 2053; + webCertFile = ''; + webKeyFile = ''; + webBasePath = '/'; + sessionMaxAge = 360; + trustedProxyCIDRs = '127.0.0.1/32,::1/128'; + pageSize = 25; + expireDiff = 0; + trafficDiff = 0; + remarkModel = '-ieo'; + datepicker: 'gregorian' | 'jalalian' = 'gregorian'; + tgBotEnable = false; + tgBotToken = ''; + tgBotProxy = ''; + tgBotAPIServer = ''; + tgBotChatId = ''; + tgRunTime = '@daily'; + tgBotBackup = false; + tgBotLoginNotify = true; + tgCpu = 80; + tgLang = 'en-US'; + twoFactorEnable = false; + twoFactorToken = ''; + xrayTemplateConfig = ''; + subEnable = true; + subJsonEnable = false; + subTitle = ''; + subSupportUrl = ''; + subProfileUrl = ''; + subAnnounce = ''; + subEnableRouting = true; + subRoutingRules = ''; + subListen = ''; + subPort = 2096; + subPath = '/sub/'; + subJsonPath = '/json/'; + subClashEnable = true; + subClashPath = '/clash/'; + subDomain = ''; + externalTrafficInformEnable = false; + externalTrafficInformURI = ''; + restartXrayOnClientDisable = true; + subCertFile = ''; + subKeyFile = ''; + subUpdates = 12; + subEncrypt = true; + subShowInfo = true; + subEmailInRemark = true; + subURI = ''; + subJsonURI = ''; + subClashURI = ''; + subJsonFragment = ''; + subJsonNoises = ''; + subJsonMux = ''; + subJsonRules = ''; + + timeLocation = 'Local'; + + ldapEnable = false; + ldapHost = ''; + ldapPort = 389; + ldapUseTLS = false; + ldapBindDN = ''; + ldapPassword = ''; + ldapBaseDN = ''; + ldapUserFilter = '(objectClass=person)'; + ldapUserAttr = 'mail'; + ldapVlessField = 'vless_enabled'; + ldapSyncCron = '@every 1m'; + ldapFlagField = ''; + ldapTruthyValues = 'true,1,yes,on'; + ldapInvertFlag = false; + ldapInboundTags = ''; + ldapAutoCreate = false; + ldapAutoDelete = false; + ldapDefaultTotalGB = 0; + ldapDefaultExpiryDays = 0; + ldapDefaultLimitIP = 0; + hasTgBotToken = false; + hasTwoFactorToken = false; + hasLdapPassword = false; + hasApiToken = false; + hasWarpSecret = false; + hasNordSecret = false; + + constructor(data?: unknown) { + if (data != null) { + ObjectUtil.cloneProps(this, data); + } + } + + equals(other: AllSetting): boolean { + return ObjectUtil.equals(this, other); + } +} diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx new file mode 100644 index 00000000..e620c724 --- /dev/null +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -0,0 +1,352 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Collapse, + Input, + InputNumber, + Select, + Space, + Switch, +} from 'antd'; +import type { AllSetting } from '@/models/setting'; +import { HttpUtil, LanguageManager } from '@/utils'; +import SettingListItem from '@/components/SettingListItem'; + +interface ApiMsg { + success?: boolean; + obj?: T; +} + +interface GeneralTabProps { + allSetting: AllSetting; + updateSetting: (patch: Partial) => void; +} + +const REMARK_MODELS: Record = { i: 'Inbound', e: 'Email', o: 'Other' }; +const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/']; +const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [ + { name: 'Gregorian (Standard)', value: 'gregorian' }, + { name: 'Jalalian (شمسی)', value: 'jalalian' }, +]; + +export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) { + const { t } = useTranslation(); + + const [lang, setLang] = useState(() => LanguageManager.getLanguage()); + const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]); + + useEffect(() => { + let cancelled = false; + (async () => { + const msg = await HttpUtil.get('/panel/api/inbounds/list') as ApiMsg<{ + tag: string; protocol: string; port: number; + }[]>; + if (cancelled) return; + if (msg?.success && Array.isArray(msg.obj)) { + setInboundOptions(msg.obj.map((ib) => ({ + label: `${ib.tag} (${ib.protocol}@${ib.port})`, + value: ib.tag, + }))); + } else { + setInboundOptions([]); + } + })(); + return () => { cancelled = true; }; + }, []); + + const remarkModel = useMemo(() => { + const rm = allSetting.remarkModel || ''; + return rm.length > 1 ? rm.substring(1).split('') : []; + }, [allSetting.remarkModel]); + + const remarkSeparator = useMemo(() => { + const rm = allSetting.remarkModel || '-'; + return rm.length > 1 ? rm.charAt(0) : '-'; + }, [allSetting.remarkModel]); + + const remarkSample = useMemo(() => { + const parts = remarkModel.map((k) => REMARK_MODELS[k]); + return parts.length === 0 ? '' : parts.join(remarkSeparator); + }, [remarkModel, remarkSeparator]); + + function setRemarkModel(parts: string[]) { + updateSetting({ remarkModel: remarkSeparator + parts.join('') }); + } + + function setRemarkSeparator(sep: string) { + const tail = (allSetting.remarkModel || '-').substring(1); + updateSetting({ remarkModel: sep + tail }); + } + + const ldapInboundTagList = useMemo(() => { + const csv = allSetting.ldapInboundTags || ''; + return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : []; + }, [allSetting.ldapInboundTags]); + + function setLdapInboundTagList(list: string[]) { + updateSetting({ ldapInboundTags: Array.isArray(list) ? list.join(',') : '' }); + } + + function onLangChange(value: string) { + setLang(value); + LanguageManager.setLanguage(value); + } + + const langOptions = useMemo( + () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({ + value: l.value, + label: ( + <> + {l.icon} +   {l.name} + + ), + })), + [], + ); + + return ( + + {t('pages.settings.sampleRemark')}: #{remarkSample}} + > + + ({ value: s, label: s }))} + /> + + + + + updateSetting({ webListen: e.target.value })} /> + + + + updateSetting({ webDomain: e.target.value })} /> + + + + updateSetting({ webPort: Number(v) || 0 })} /> + + + + updateSetting({ webBasePath: e.target.value })} /> + + + + updateSetting({ sessionMaxAge: Number(v) || 0 })} /> + + + + updateSetting({ trustedProxyCIDRs: e.target.value })} + /> + + + + updateSetting({ pageSize: Number(v) || 0 })} /> + + + + updateSetting({ webCertFile: e.target.value })} /> + + + updateSetting({ webKeyFile: e.target.value })} /> + + + ), + }, + { + key: '4', + label: t('pages.settings.externalTraffic'), + children: ( + <> + + updateSetting({ externalTrafficInformEnable: v })} /> + + + updateSetting({ externalTrafficInformURI: e.target.value })} + /> + + + updateSetting({ restartXrayOnClientDisable: v })} /> + + + ), + }, + { + key: '5', + label: t('pages.settings.dateAndTime'), + children: ( + <> + + updateSetting({ timeLocation: e.target.value })} /> + + + updateSetting({ ldapHost: e.target.value })} /> + + + updateSetting({ ldapPort: Number(v) || 0 })} /> + + + updateSetting({ ldapUseTLS: v })} /> + + + updateSetting({ ldapBindDN: e.target.value })} /> + + + updateSetting({ ldapPassword: e.target.value })} + /> + + + updateSetting({ ldapBaseDN: e.target.value })} /> + + + updateSetting({ ldapUserFilter: e.target.value })} /> + + + updateSetting({ ldapUserAttr: e.target.value })} /> + + + updateSetting({ ldapVlessField: e.target.value })} /> + + + updateSetting({ ldapFlagField: e.target.value })} /> + + + updateSetting({ ldapTruthyValues: e.target.value })} /> + + + updateSetting({ ldapInvertFlag: v })} /> + + + updateSetting({ ldapSyncCron: e.target.value })} /> + + + <> + updateUserField('oldUsername', e.target.value)} /> + + + updateUserField('oldPassword', e.target.value)} /> + + + updateUserField('newUsername', e.target.value)} /> + + + updateUserField('newPassword', e.target.value)} /> + + + + + + + + ), + }, + { + key: '2', + label: t('pages.settings.security.twoFactor'), + children: ( + + + + ), + }, + { + key: '3', + label: t('pages.nodes.apiToken'), + children: ( +
+
+

{t('pages.nodes.apiTokenHint')}

+ +
+ + {!apiTokens.length && !apiTokensLoading && ( + + )} + {apiTokens.map((row) => ( +
+
+
+ {row.name} + {formatTokenDate(row.createdAt)} +
+
+ toggleTokenEnabled(row)} /> + +
+
+
+ + {visibleTokenIds.has(row.id) ? row.token : maskToken(row.token)} + + + +
+
+ ))} +
+
+ ), + }, + ]} /> + + setCreateOpen(false)} + > +
+ + setCreateName(e.target.value)} + onPressEnter={confirmCreateToken} + /> + +
+
+ + setTfa((prev) => ({ ...prev, open }))} + /> + + ); +} diff --git a/frontend/src/pages/settings/SecurityTab.vue b/frontend/src/pages/settings/SecurityTab.vue deleted file mode 100644 index 1ecb7080..00000000 --- a/frontend/src/pages/settings/SecurityTab.vue +++ /dev/null @@ -1,404 +0,0 @@ - - - - - diff --git a/frontend/src/pages/settings/SettingsPage.css b/frontend/src/pages/settings/SettingsPage.css new file mode 100644 index 00000000..b59e6950 --- /dev/null +++ b/frontend/src/pages/settings/SettingsPage.css @@ -0,0 +1,87 @@ +.settings-page { + --bg-page: #e6e8ec; + --bg-card: #ffffff; + min-height: 100vh; + background: var(--bg-page); +} + +.settings-page.is-dark { + --bg-page: #1e1e1e; + --bg-card: #252526; +} + +.settings-page.is-dark.is-ultra { + --bg-page: #050505; + --bg-card: #0c0e12; +} + +.settings-page .ant-layout, +.settings-page .ant-layout-content { + background: transparent; +} + +.settings-page .content-shell { + background: transparent; +} + +.settings-page .content-area { + padding: 24px; +} + +.settings-page .loading-spacer { + min-height: calc(100vh - 120px); +} + +.settings-page .conf-alert { + margin-bottom: 10px; +} + +.settings-page .header-row { + display: flex; + flex-wrap: wrap; + align-items: center; +} + +.settings-page .header-actions { + padding: 4px; +} + +.settings-page .header-info { + display: flex; + justify-content: flex-end; +} + +.icons-only .ant-tabs-nav { + margin-bottom: 8px; +} + +.icons-only .ant-tabs-nav-wrap { + width: 100%; +} + +.icons-only .ant-tabs-nav-list { + display: flex; + width: 100%; +} + +.icons-only .ant-tabs-tab { + flex: 1 1 0; + justify-content: center; + margin: 0; + padding: 10px 0; +} + +.icons-only .ant-tabs-tab .anticon { + margin: 0; + font-size: 18px; +} + +.icons-only .ant-tabs-nav-operations { + display: none; +} + +.ldap-no-inbounds { + margin-top: 6px; + color: #999; + font-size: 12px; +} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx new file mode 100644 index 00000000..1e5bdca9 --- /dev/null +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -0,0 +1,331 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Alert, + BackTop, + Button, + Card, + Col, + ConfigProvider, + Layout, + Modal, + Row, + Space, + Spin, + Tabs, + Tooltip, +} from 'antd'; +import { + CloudServerOutlined, + CodeOutlined, + MessageOutlined, + SafetyOutlined, + SettingOutlined, +} from '@ant-design/icons'; + +import { HttpUtil, PromiseUtil } from '@/utils'; +import { useTheme } from '@/hooks/useTheme'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useAllSetting } from '@/hooks/useAllSetting'; +import AppSidebar from '@/components/AppSidebar'; +import GeneralTab from './GeneralTab'; +import SecurityTab from './SecurityTab'; +import TelegramTab from './TelegramTab'; +import SubscriptionGeneralTab from './SubscriptionGeneralTab'; +import SubscriptionFormatsTab from './SubscriptionFormatsTab'; +import './SettingsPage.css'; + +interface ApiMsg { + success?: boolean; +} + +const basePath = window.X_UI_BASE_PATH || ''; +const requestUri = window.location.pathname; +const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats']; + +function slugToKey(slug: string): string { + const i = tabSlugs.indexOf(slug); + return i >= 0 ? String(i + 1) : '1'; +} + +function keyToSlug(key: string): string { + return tabSlugs[Number(key) - 1] || tabSlugs[0]; +} + +function isIp(h: string): boolean { + if (typeof h !== 'string') return false; + const v4 = h.split('.'); + if (v4.length === 4 && v4.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return true; + if (!h.includes(':') || h.includes(':::')) return false; + const parts = h.split('::'); + if (parts.length > 2) return false; + const split = (s: string) => (s ? s.split(':').filter(Boolean) : []); + const head = split(parts[0]); + const tail = split(parts[1]); + const valid = (seg: string) => /^[0-9a-fA-F]{1,4}$/.test(seg); + if (![...head, ...tail].every(valid)) return false; + const groups = head.length + tail.length; + return parts.length === 2 ? groups < 8 : groups === 8; +} + +function scrollTarget() { + return document.getElementById('content-layout') as HTMLElement; +} + +export default function SettingsPage() { + const { t } = useTranslation(); + const { isDark, isUltra, antdThemeConfig } = useTheme(); + const { isMobile } = useMediaQuery(); + const [modal, modalContextHolder] = Modal.useModal(); + + const { + allSetting, + updateSetting, + fetched, + spinning, + setSpinning, + saveDisabled, + saveAll, + } = useAllSetting(); + + const [entryHost, setEntryHost] = useState(''); + const [entryPort, setEntryPort] = useState(''); + const [entryIsIP, setEntryIsIP] = useState(false); + + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect */ + const host = window.location.hostname; + setEntryHost(host); + setEntryPort(window.location.port); + setEntryIsIP(isIp(host)); + /* eslint-enable react-hooks/set-state-in-effect */ + }, []); + + const [alertVisible, setAlertVisible] = useState(true); + const [activeTabKey, setActiveTabKey] = useState(() => slugToKey(window.location.hash.slice(1))); + + useEffect(() => { + const onHashChange = () => setActiveTabKey(slugToKey(window.location.hash.slice(1))); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + function onTabChange(key: string) { + setActiveTabKey(key); + const slug = keyToSlug(key); + if (window.location.hash !== `#${slug}`) { + history.replaceState(null, '', `#${slug}`); + } + } + + function rebuildUrlAfterRestart(): string { + const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting; + const newProtocol = (webCertFile || webKeyFile) ? 'https:' : 'http:'; + + let base = webBasePath ? webBasePath.replace(/^\//, '') : ''; + if (base && !base.endsWith('/')) base += '/'; + + if (!entryIsIP) { + const url = new URL(window.location.href); + url.pathname = `/${base}panel/settings`; + url.protocol = newProtocol; + return url.toString(); + } + + let finalHost = entryHost; + let finalPort = entryPort || ''; + if (webDomain && isIp(webDomain)) finalHost = webDomain; + if (webPort && Number(webPort) !== Number(entryPort)) finalPort = String(webPort); + + const url = new URL(`${newProtocol}//${finalHost}`); + if (finalPort) url.port = finalPort; + url.pathname = `/${base}panel/settings`; + return url.toString(); + } + + function restartPanel() { + modal.confirm({ + title: t('pages.settings.restartPanel'), + content: t('pages.settings.restartPanelDesc'), + okText: t('pages.settings.restartPanel'), + okButtonProps: { danger: true }, + cancelText: t('cancel'), + onOk: async () => { + setSpinning(true); + try { + const msg = await HttpUtil.post('/panel/setting/restartPanel') as ApiMsg; + if (!msg?.success) return; + await PromiseUtil.sleep(5000); + window.location.replace(rebuildUrlAfterRestart()); + } finally { + setSpinning(false); + } + }, + }); + } + + const confAlerts = useMemo(() => { + const out: string[] = []; + if (window.location.protocol !== 'https:') { + out.push('Panel is served over plain HTTP — set up TLS for production.'); + } + if (allSetting.webPort === 2053) { + out.push('Default port 2053 is well-known — change it to a random port.'); + } + const segs = window.location.pathname.split('/').length < 4; + if (segs && allSetting.webBasePath === '/') { + out.push('Default base path "/" is well-known — change it to a random path.'); + } + if (allSetting.subEnable) { + let subPath = allSetting.subPath; + if (allSetting.subURI) { + try { subPath = new URL(allSetting.subURI).pathname; } catch { /* noop */ } + } + if (subPath === '/sub/') { + out.push('Default subscription path "/sub/" is well-known — change it.'); + } + } + if (allSetting.subJsonEnable) { + let p = allSetting.subJsonPath; + if (allSetting.subJsonURI) { + try { p = new URL(allSetting.subJsonURI).pathname; } catch { /* noop */ } + } + if (p === '/json/') { + out.push('Default JSON subscription path "/json/" is well-known — change it.'); + } + } + return out; + }, [allSetting]); + + const pageClass = useMemo(() => { + const classes = ['settings-page']; + if (isDark) classes.push('is-dark'); + if (isUltra) classes.push('is-ultra'); + return classes.join(' '); + }, [isDark, isUltra]); + + const tabItems = useMemo(() => { + const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [ + { + key: '1', + label: ( + + {!isMobile && <> {t('pages.settings.panelSettings')}} + + ), + children: , + }, + { + key: '2', + label: ( + + {!isMobile && <> {t('pages.settings.securitySettings')}} + + ), + children: , + }, + { + key: '3', + label: ( + + {!isMobile && <> {t('pages.settings.TGBotSettings')}} + + ), + children: , + }, + { + key: '4', + label: ( + + {!isMobile && <> {t('pages.settings.subSettings')}} + + ), + children: , + }, + ]; + if (allSetting.subJsonEnable || allSetting.subClashEnable) { + items.push({ + key: '5', + label: ( + + {!isMobile && <> {t('pages.settings.subSettings')} (Formats)} + + ), + children: , + }); + } + return items; + }, [allSetting, updateSetting, isMobile, t]); + + return ( + + {modalContextHolder} + + + + + + + {!fetched ? ( +
+ ) : ( + <> + {confAlerts.length > 0 && alertVisible && ( + setAlertVisible(false)} + message="Security warnings" + description={( + <> + Your panel may be exposed: +
    + {confAlerts.map((msg, i) =>
  • {msg}
  • )} +
+ + )} + /> + )} + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + ); +} diff --git a/frontend/src/pages/settings/SettingsPage.vue b/frontend/src/pages/settings/SettingsPage.vue deleted file mode 100644 index 052ae727..00000000 --- a/frontend/src/pages/settings/SettingsPage.vue +++ /dev/null @@ -1,375 +0,0 @@ - - - - - diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.css b/frontend/src/pages/settings/SubscriptionFormatsTab.css new file mode 100644 index 00000000..318f341f --- /dev/null +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.css @@ -0,0 +1,4 @@ +.nested-block { + padding: 10px 20px; + display: block !important; +} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx new file mode 100644 index 00000000..a6694c00 --- /dev/null +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx @@ -0,0 +1,434 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Collapse, + Input, + InputNumber, + List, + Select, + Space, + Switch, +} from 'antd'; +import type { AllSetting } from '@/models/setting'; +import SettingListItem from '@/components/SettingListItem'; +import './SubscriptionFormatsTab.css'; + +interface SubscriptionFormatsTabProps { + allSetting: AllSetting; + updateSetting: (patch: Partial) => void; +} + +const DEFAULT_FRAGMENT = { + packets: 'tlshello', + length: '100-200', + interval: '10-20', + maxSplit: '300-400', +}; +const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [ + { type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }, +]; +const DEFAULT_MUX = { + enabled: true, + concurrency: 8, + xudpConcurrency: 16, + xudpProxyUDP443: 'reject', +}; +const DEFAULT_RULES: { type: string; outboundTag: string; domain?: string[]; ip?: string[] }[] = [ + { type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] }, + { type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] }, +]; + +const directIPsOptions = [ + { label: 'Private IP', value: 'geoip:private' }, + { label: '🇮🇷 Iran', value: 'geoip:ir' }, + { label: '🇨🇳 China', value: 'geoip:cn' }, + { label: '🇷🇺 Russia', value: 'geoip:ru' }, + { label: '🇻🇳 Vietnam', value: 'geoip:vn' }, + { label: '🇪🇸 Spain', value: 'geoip:es' }, + { label: '🇮🇩 Indonesia', value: 'geoip:id' }, + { label: '🇺🇦 Ukraine', value: 'geoip:ua' }, + { label: '🇹🇷 Türkiye', value: 'geoip:tr' }, + { label: '🇧🇷 Brazil', value: 'geoip:br' }, +]; +const directDomainsOptions = [ + { label: 'Private DNS', value: 'geosite:private' }, + { label: '🇮🇷 Iran', value: 'geosite:category-ir' }, + { label: '🇨🇳 China', value: 'geosite:cn' }, + { label: '🇷🇺 Russia', value: 'geosite:category-ru' }, + { label: 'Apple', value: 'geosite:apple' }, + { label: 'Meta', value: 'geosite:meta' }, + { label: 'Google', value: 'geosite:google' }, +]; + +function sanitizePath(input: string): string { + return String(input ?? '').replace(/[:*]/g, ''); +} + +function normalizePath(input: string): string { + let p = input || '/'; + if (!p.startsWith('/')) p = '/' + p; + if (!p.endsWith('/')) p += '/'; + p = p.replace(/\/+/g, '/'); + return p; +} + +function readJson(raw: string, fallback: T): T { + try { + if (!raw) return fallback; + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) { + const { t } = useTranslation(); + + const fragment = allSetting.subJsonFragment !== ''; + const noisesEnabled = allSetting.subJsonNoises !== ''; + const muxEnabled = allSetting.subJsonMux !== ''; + const directEnabled = allSetting.subJsonRules !== ''; + + const fragmentObj = useMemo( + () => (fragment ? readJson(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT), + [allSetting.subJsonFragment, fragment], + ); + + function setFragmentEnabled(v: boolean) { + updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' }); + } + + function setFragmentField(key: K, value: string) { + if (value === '') return; + const next = { ...fragmentObj, [key]: value }; + updateSetting({ subJsonFragment: JSON.stringify(next) }); + } + + const noisesArray = useMemo( + () => (noisesEnabled ? readJson(allSetting.subJsonNoises, DEFAULT_NOISES) : []), + [allSetting.subJsonNoises, noisesEnabled], + ); + + function setNoisesEnabled(v: boolean) { + updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' }); + } + + function setNoisesArray(next: typeof DEFAULT_NOISES) { + if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) }); + } + + function addNoise() { + setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]); + } + + function removeNoise(index: number) { + const next = [...noisesArray]; + next.splice(index, 1); + setNoisesArray(next); + } + + function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) { + const next = [...noisesArray]; + next[index] = { ...next[index], [field]: value }; + setNoisesArray(next); + } + + const muxObj = useMemo( + () => (muxEnabled ? readJson(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), + [allSetting.subJsonMux, muxEnabled], + ); + + function setMuxEnabled(v: boolean) { + updateSetting({ subJsonMux: v ? JSON.stringify(DEFAULT_MUX) : '' }); + } + + function setMuxField(key: K, value: typeof DEFAULT_MUX[K]) { + const next = { ...muxObj, [key]: value }; + updateSetting({ subJsonMux: JSON.stringify(next) }); + } + + const ruleArray = useMemo(() => { + if (!directEnabled) return null; + return readJson(allSetting.subJsonRules, null); + }, [allSetting.subJsonRules, directEnabled]); + + const directIPs = useMemo(() => { + if (!ruleArray) return []; + const ipRule = ruleArray.find((r) => r.ip); + return ipRule?.ip ?? []; + }, [ruleArray]); + + const directDomains = useMemo(() => { + if (!ruleArray) return []; + const dRule = ruleArray.find((r) => r.domain); + return dRule?.domain ?? []; + }, [ruleArray]); + + function setDirectEnabled(v: boolean) { + updateSetting({ subJsonRules: v ? JSON.stringify(DEFAULT_RULES) : '' }); + } + + function setDirectIPs(value: string[]) { + if (!ruleArray) return; + let rules = [...ruleArray]; + if (value.length === 0) { + rules = rules.filter((r) => !r.ip); + } else { + let idx = rules.findIndex((r) => r.ip); + if (idx === -1) { + rules.push({ ...DEFAULT_RULES[1] }); + idx = rules.length - 1; + } + rules[idx] = { ...rules[idx], ip: [...value] }; + } + updateSetting({ subJsonRules: JSON.stringify(rules) }); + } + + function setDirectDomains(value: string[]) { + if (!ruleArray) return; + let rules = [...ruleArray]; + if (value.length === 0) { + rules = rules.filter((r) => !r.domain); + } else { + let idx = rules.findIndex((r) => r.domain); + if (idx === -1) { + rules.push({ ...DEFAULT_RULES[0] }); + idx = rules.length - 1; + } + rules[idx] = { ...rules[idx], domain: [...value] }; + } + updateSetting({ subJsonRules: JSON.stringify(rules) }); + } + + return ( + + {allSetting.subJsonEnable && ( + <> + JSON {t('pages.settings.subPath')}} description={t('pages.settings.subPathDesc')}> + updateSetting({ subJsonPath: sanitizePath(e.target.value) })} + onBlur={() => updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })} + /> + + JSON {t('pages.settings.subURI')}} description={t('pages.settings.subURIDesc')}> + updateSetting({ subJsonURI: e.target.value })} + /> + + + )} + {allSetting.subClashEnable && ( + <> + Clash {t('pages.settings.subPath')}} description={t('pages.settings.subPathDesc')}> + updateSetting({ subClashPath: sanitizePath(e.target.value) })} + onBlur={() => updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })} + /> + + Clash {t('pages.settings.subURI')}} description={t('pages.settings.subURIDesc')}> + updateSetting({ subClashURI: e.target.value })} + /> + + + )} + + ), + }, + { + key: '2', + label: t('pages.settings.fragment'), + children: ( + <> + + + + {fragment && ( + + + + setFragmentField('packets', e.target.value)} /> + + + setFragmentField('length', e.target.value)} /> + + + setFragmentField('interval', e.target.value)} /> + + + setFragmentField('maxSplit', e.target.value)} /> + + + ), + }, + ]} /> + + )} + + ), + }, + { + key: '3', + label: 'Noises', + children: ( + <> + + + + {noisesEnabled && ( + + ({ + key: String(index), + label: `Noise №${index + 1}`, + children: ( + <> + + updateNoiseField(index, 'packet', e.target.value)} /> + + + updateNoiseField(index, 'delay', e.target.value)} /> + + + setMuxField('xudpProxyUDP443', v)} + options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))} + /> + + + ), + }, + ]} /> + + )} + + ), + }, + { + key: '5', + label: t('pages.settings.direct'), + children: ( + <> + + + + {directEnabled && ( + + + {t('pages.settings.direct')} IPs}> + + + + ), + }, + ]} /> + + )} + + ), + }, + ]} /> + ); +} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.vue b/frontend/src/pages/settings/SubscriptionFormatsTab.vue deleted file mode 100644 index 26301b4c..00000000 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.vue +++ /dev/null @@ -1,433 +0,0 @@ - - - - - diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx new file mode 100644 index 00000000..8cdd1038 --- /dev/null +++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx @@ -0,0 +1,140 @@ +import { Collapse, Divider, Input, InputNumber, Switch } from 'antd'; +import { useTranslation } from 'react-i18next'; +import type { AllSetting } from '@/models/setting'; +import SettingListItem from '@/components/SettingListItem'; + +interface SubscriptionGeneralTabProps { + allSetting: AllSetting; + updateSetting: (patch: Partial) => void; +} + +function sanitizePath(input: string): string { + return String(input ?? '').replace(/[:*]/g, ''); +} + +function normalizePath(input: string): string { + let p = input || '/'; + if (!p.startsWith('/')) p = '/' + p; + if (!p.endsWith('/')) p += '/'; + p = p.replace(/\/+/g, '/'); + return p; +} + +export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) { + const { t } = useTranslation(); + + return ( + + + updateSetting({ subEnable: v })} /> + + + updateSetting({ subJsonEnable: v })} /> + + + updateSetting({ subClashEnable: v })} /> + + + updateSetting({ subListen: e.target.value })} /> + + + updateSetting({ subDomain: e.target.value })} /> + + + updateSetting({ subPort: Number(v) || 0 })} /> + + + updateSetting({ subPath: sanitizePath(e.target.value) })} + onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })} + /> + + + updateSetting({ subURI: e.target.value })} /> + + + ), + }, + { + key: '2', + label: t('pages.settings.information'), + children: ( + <> + + updateSetting({ subEncrypt: v })} /> + + + updateSetting({ subShowInfo: v })} /> + + + updateSetting({ subEmailInRemark: v })} /> + + + {t('pages.settings.subTitle')} + + + updateSetting({ subTitle: e.target.value })} /> + + + updateSetting({ subSupportUrl: e.target.value })} /> + + + updateSetting({ subProfileUrl: e.target.value })} /> + + + updateSetting({ subAnnounce: e.target.value })} /> + + + Happ + + + updateSetting({ subEnableRouting: v })} /> + + + updateSetting({ subRoutingRules: e.target.value })} /> + + + ), + }, + { + key: '3', + label: t('pages.settings.certs'), + children: ( + <> + + updateSetting({ subCertFile: e.target.value })} /> + + + updateSetting({ subKeyFile: e.target.value })} /> + + + ), + }, + { + key: '4', + label: t('pages.settings.intervals'), + children: ( + <> + + updateSetting({ subUpdates: Number(v) || 0 })} /> + + + ), + }, + ]} /> + ); +} diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.vue b/frontend/src/pages/settings/SubscriptionGeneralTab.vue deleted file mode 100644 index cc6eaa0f..00000000 --- a/frontend/src/pages/settings/SubscriptionGeneralTab.vue +++ /dev/null @@ -1,204 +0,0 @@ - - - diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx new file mode 100644 index 00000000..1d3094d7 --- /dev/null +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -0,0 +1,106 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Collapse, Input, InputNumber, Select, Switch } from 'antd'; +import { LanguageManager } from '@/utils'; +import type { AllSetting } from '@/models/setting'; +import SettingListItem from '@/components/SettingListItem'; + +interface TelegramTabProps { + allSetting: AllSetting; + updateSetting: (patch: Partial) => void; +} + +export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) { + const { t } = useTranslation(); + + const langOptions = useMemo( + () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({ + value: l.value, + label: ( + <> + {l.icon} +   {l.name} + + ), + })), + [], + ); + + return ( + + + updateSetting({ tgBotEnable: v })} /> + + + + updateSetting({ tgBotToken: e.target.value })} + /> + + + + updateSetting({ tgBotChatId: e.target.value })} /> + + + + updateSetting({ tgRunTime: e.target.value })} /> + + + updateSetting({ tgBotBackup: v })} /> + + + updateSetting({ tgBotLoginNotify: v })} /> + + + updateSetting({ tgCpu: Number(v) || 0 })} /> + + + ), + }, + { + key: '3', + label: t('pages.settings.proxyAndServer'), + children: ( + <> + + updateSetting({ tgBotProxy: e.target.value })} /> + + + updateSetting({ tgBotAPIServer: e.target.value })} /> + + + ), + }, + ]} /> + ); +} diff --git a/frontend/src/pages/settings/TelegramTab.vue b/frontend/src/pages/settings/TelegramTab.vue deleted file mode 100644 index 15111307..00000000 --- a/frontend/src/pages/settings/TelegramTab.vue +++ /dev/null @@ -1,109 +0,0 @@ - - - diff --git a/frontend/src/pages/settings/TwoFactorModal.css b/frontend/src/pages/settings/TwoFactorModal.css new file mode 100644 index 00000000..e72f272f --- /dev/null +++ b/frontend/src/pages/settings/TwoFactorModal.css @@ -0,0 +1,20 @@ +.qr-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.qr-code { + cursor: pointer; + padding: 0 !important; + background: #fff; + border-radius: 6px; +} + +.qr-token { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + word-break: break-all; + text-align: center; +} diff --git a/frontend/src/pages/settings/TwoFactorModal.tsx b/frontend/src/pages/settings/TwoFactorModal.tsx new file mode 100644 index 00000000..3262ae77 --- /dev/null +++ b/frontend/src/pages/settings/TwoFactorModal.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, 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 './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 [enteredCode, setEnteredCode] = useState(''); + const [qrValue, setQrValue] = useState(''); + const totpRef = useRef(null); + + useEffect(() => { + if (!open) return; + /* eslint-disable react-hooks/set-state-in-effect */ + setEnteredCode(''); + totpRef.current = null; + setQrValue(''); + if (token) { + const totp = new OTPAuth.TOTP({ + issuer: '3x-ui', + label: 'Administrator', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: token, + }); + totpRef.current = totp; + setQrValue(totp.toString()); + } + /* eslint-enable react-hooks/set-state-in-effect */ + }, [open, token]); + + function close(success: boolean, code = '') { + onConfirm(success, code); + onOpenChange(false); + setEnteredCode(''); + } + + function onOk() { + if (type === 'confirm' && !token) { + close(true, enteredCode); + return; + } + if (!totpRef.current) return; + if (totpRef.current.generate() === enteredCode) { + close(true); + } else { + message.error(t('pages.settings.security.twoFactorModalError')); + } + } + + function onCancel() { + close(false); + } + + async function copyToken() { + const ok = await ClipboardManager.copyText(token); + if (ok) message.success(t('copied')); + } + + return ( + {t('cancel')}, + , + ]} + > + {type === 'set' ? ( + <> +

{t('pages.settings.security.twoFactorModalSteps')}

+ +

{t('pages.settings.security.twoFactorModalFirstStep')}

+
+ + {token} +
+ +

{t('pages.settings.security.twoFactorModalSecondStep')}

+ setEnteredCode(e.target.value)} style={{ width: '100%' }} /> + + ) : ( + <> +

{description}

+ setEnteredCode(e.target.value)} style={{ width: '100%' }} /> + + )} +
+ ); +} diff --git a/frontend/src/pages/settings/TwoFactorModal.vue b/frontend/src/pages/settings/TwoFactorModal.vue deleted file mode 100644 index 944259ea..00000000 --- a/frontend/src/pages/settings/TwoFactorModal.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - - - diff --git a/frontend/src/pages/settings/useAllSetting.js b/frontend/src/pages/settings/useAllSetting.js deleted file mode 100644 index 8b09d00d..00000000 --- a/frontend/src/pages/settings/useAllSetting.js +++ /dev/null @@ -1,80 +0,0 @@ -// Centralizes the AllSetting fetch/save lifecycle the legacy panel -// scattered across data() + methods + a busy-loop dirty checker. -// -// The dirty flag is recomputed once per second (matching the legacy -// `while (true) sleep(1000)` poll) — we don't deep-watch because the -// settings tree has many nested fields and a poll is cheap enough. - -import { onMounted, onUnmounted, reactive, ref } from 'vue'; -import { HttpUtil } from '@/utils'; -import { AllSetting } from '@/models/setting.js'; - -const DIRTY_POLL_MS = 1000; - -export function useAllSetting() { - const fetched = ref(false); - const spinning = ref(false); - const saveDisabled = ref(true); - - // Two reactive snapshots: the last server-side state and the one the - // user is editing. `equals` compares enumerable props field-by-field. - const oldAllSetting = reactive(new AllSetting()); - const allSetting = reactive(new AllSetting()); - - function applyServerState(obj) { - const fresh = new AllSetting(obj); - Object.assign(oldAllSetting, fresh); - Object.assign(allSetting, fresh); - saveDisabled.value = true; - } - - async function fetchAll() { - const msg = await HttpUtil.post('/panel/setting/all'); - if (msg?.success) { - fetched.value = true; - applyServerState(msg.obj); - } - } - - async function saveAll() { - spinning.value = true; - try { - const msg = await HttpUtil.post('/panel/setting/update', allSetting); - if (msg?.success) await fetchAll(); - } finally { - spinning.value = false; - } - } - - let timer = null; - function startDirtyPoll() { - if (timer != null) return; - timer = setInterval(() => { - // ObjectUtil.equals walks own enumerable props; reactive proxies - // expose them transparently so this works without cloning. - saveDisabled.value = oldAllSetting.equals(allSetting); - }, DIRTY_POLL_MS); - } - function stopDirtyPoll() { - if (timer != null) { - clearInterval(timer); - timer = null; - } - } - - onMounted(() => { - fetchAll(); - startDirtyPoll(); - }); - onUnmounted(stopDirtyPoll); - - return { - fetched, - spinning, - saveDisabled, - oldAllSetting, - allSetting, - fetchAll, - saveAll, - }; -}