18 Commits 210c25cf13 ... adc262a238

Tác giả SHA1 Thông báo Ngày
  MHSanaei adc262a238 fix(warp): set license against Cloudflare API and surface errors inline 1 ngày trước cách đây
  Vladislav Kasperov 67b098dfd3 Add possibility to remove client email from sub (#4297) 1 ngày trước cách đây
  MHSanaei 5543466fcc fix(forms): validate JSON tabs before applying or saving 1 ngày trước cách đây
  MHSanaei b10a9f1de7 fix(inbounds): hide node UI when no enabled node exists 1 ngày trước cách đây
  Amirmohammad Sadat Shokouhi 4399fe2a85 add log rotate to 3xui.log file to avoid disk space consumption (#4277) 1 ngày trước cách đây
  MHSanaei 6c6b40e063 fix(outbound): accept JSON-only configs and sync JSON to basic form on tab switch 1 ngày trước cách đây
  MHSanaei b97ff40ad6 feat(api-tokens): manage multiple named tokens; add tab/section anchor URLs 1 ngày trước cách đây
  MHSanaei 46b6f8c66c feat(routing): drag-reorder rules, split balancer column, mobile card layout 1 ngày trước cách đây
  Abdalrahman 102df7a290 style(api-docs): redesign TOC, section icons, endpoint rows, and code blocks with ultra-dark support (#4332) 1 ngày trước cách đây
  Abdalrahman f29c8a5e29 fix: single inbound traffic reset resets all inbounds (#4334) (#4338) 1 ngày trước cách đây
  Abdalrahman ad81649c16 fix: strip main-panel TLS cert file paths when sending inbound to remote node (#4339) 1 ngày trước cách đây
  Abdalrahman b47f794ed0 fix: reality random target/sni buttons not working (#4337) (#4340) 1 ngày trước cách đây
  MHSanaei 4e1b597914 feat(ui): use the host as the browser tab title prefix 1 ngày trước cách đây
  MHSanaei bbefe91011 fix(auth): invalidate sessions when 2FA is enabled, fix dev 401 loop 2 ngày trước cách đây
  MHSanaei e40554a7d5 fix(inbound): require email when adding or updating a client 2 ngày trước cách đây
  MHSanaei 3569b1be73 ci(codeql): run on push to main 2 ngày trước cách đây
  MHSanaei 38da210ded fix(security): SSRF-guard node and remote HTTP clients 2 ngày trước cách đây
  MHSanaei 9fc47b3d41 ci: gate workflows on relevant source paths 2 ngày trước cách đây
73 tập tin đã thay đổi với 2031 bổ sung508 xóa
  1. 28 0
      .github/workflows/ci.yml
  2. 22 3
      .github/workflows/codeql.yml
  3. 11 0
      .github/workflows/release.yml
  4. 62 24
      database/db.go
  5. 8 0
      database/model/model.go
  6. 1 1
      frontend/api-docs.html
  7. 0 3
      frontend/eslint.config.js
  8. 1 1
      frontend/inbounds.html
  9. 1 1
      frontend/index.html
  10. 1 1
      frontend/login.html
  11. 1 1
      frontend/nodes.html
  12. 1 1
      frontend/settings.html
  13. 2 6
      frontend/src/api/axios-init.js
  14. 3 1
      frontend/src/composables/useNodeList.js
  15. 2 0
      frontend/src/entries/api-docs.js
  16. 2 0
      frontend/src/entries/inbounds.js
  17. 2 0
      frontend/src/entries/index.js
  18. 2 0
      frontend/src/entries/login.js
  19. 2 0
      frontend/src/entries/nodes.js
  20. 2 0
      frontend/src/entries/settings.js
  21. 2 0
      frontend/src/entries/xray.js
  22. 2 3
      frontend/src/models/inbound.js
  23. 1 0
      frontend/src/models/setting.js
  24. 239 116
      frontend/src/pages/api-docs/ApiDocsPage.vue
  25. 56 34
      frontend/src/pages/api-docs/CodeBlock.vue
  26. 47 13
      frontend/src/pages/api-docs/EndpointRow.vue
  27. 47 11
      frontend/src/pages/api-docs/EndpointSection.vue
  28. 58 16
      frontend/src/pages/api-docs/endpoints.js
  29. 56 5
      frontend/src/pages/inbounds/InboundFormModal.vue
  30. 4 3
      frontend/src/pages/inbounds/InboundList.vue
  31. 4 3
      frontend/src/pages/inbounds/InboundsPage.vue
  32. 212 47
      frontend/src/pages/settings/SecurityTab.vue
  33. 31 2
      frontend/src/pages/settings/SettingsPage.vue
  34. 8 0
      frontend/src/pages/settings/SubscriptionGeneralTab.vue
  35. 41 17
      frontend/src/pages/xray/OutboundFormModal.vue
  36. 334 21
      frontend/src/pages/xray/RoutingTab.vue
  37. 23 3
      frontend/src/pages/xray/WarpModal.vue
  38. 48 6
      frontend/src/pages/xray/XrayPage.vue
  39. 7 0
      frontend/src/utils/index.js
  40. 1 1
      frontend/xray.html
  41. 1 0
      go.mod
  42. 2 0
      go.sum
  43. 24 19
      logger/logger.go
  44. 8 1
      sub/subService.go
  45. 80 0
      util/netsafe/netsafe.go
  46. 2 1
      web/controller/api.go
  47. 19 0
      web/controller/inbound.go
  48. 0 1
      web/controller/index.go
  49. 57 17
      web/controller/setting.go
  50. 1 0
      web/entity/entity.go
  51. 72 8
      web/runtime/remote.go
  52. 119 0
      web/service/api_token.go
  53. 36 42
      web/service/config.json
  54. 2 2
      web/service/custom_geo.go
  55. 7 1
      web/service/inbound.go
  56. 30 6
      web/service/node.go
  57. 10 45
      web/service/setting.go
  58. 2 1
      web/service/setting_security_test.go
  59. 8 0
      web/service/user.go
  60. 20 7
      web/service/warp.go
  61. 12 1
      web/translation/ar-EG.json
  62. 12 1
      web/translation/en-US.json
  63. 12 1
      web/translation/es-ES.json
  64. 12 1
      web/translation/fa-IR.json
  65. 12 1
      web/translation/id-ID.json
  66. 12 1
      web/translation/ja-JP.json
  67. 12 1
      web/translation/pt-BR.json
  68. 12 1
      web/translation/ru-RU.json
  69. 12 1
      web/translation/tr-TR.json
  70. 12 1
      web/translation/uk-UA.json
  71. 12 1
      web/translation/vi-VN.json
  72. 12 1
      web/translation/zh-CN.json
  73. 12 1
      web/translation/zh-TW.json

+ 28 - 0
.github/workflows/ci.yml

@@ -2,9 +2,37 @@ name: CI
 
 on:
   pull_request:
+    paths:
+      - "**.go"
+      - "go.mod"
+      - "go.sum"
+      - "**.js"
+      - "**.mjs"
+      - "**.cjs"
+      - "**.ts"
+      - "**.vue"
+      - "**.html"
+      - "**.css"
+      - "frontend/package.json"
+      - "frontend/package-lock.json"
+      - ".nvmrc"
   push:
     branches:
       - main
+    paths:
+      - "**.go"
+      - "go.mod"
+      - "go.sum"
+      - "**.js"
+      - "**.mjs"
+      - "**.cjs"
+      - "**.ts"
+      - "**.vue"
+      - "**.html"
+      - "**.css"
+      - "frontend/package.json"
+      - "frontend/package-lock.json"
+      - ".nvmrc"
 
 permissions:
   contents: read

+ 22 - 3
.github/workflows/codeql.yml

@@ -2,9 +2,31 @@ name: "CodeQL Advanced"
 
 on:
   push:
+    branches:
+      - main
     tags-ignore:
       - "v*"
+    paths:
+      - "**.go"
+      - "go.mod"
+      - "go.sum"
+      - "**.js"
+      - "**.mjs"
+      - "**.cjs"
+      - "**.ts"
+      - "**.vue"
+      - "frontend/package-lock.json"
   pull_request:
+    paths:
+      - "**.go"
+      - "go.mod"
+      - "go.sum"
+      - "**.js"
+      - "**.mjs"
+      - "**.cjs"
+      - "**.ts"
+      - "**.vue"
+      - "frontend/package-lock.json"
   schedule:
     - cron: "18 2 * * 2"
 
@@ -35,9 +57,6 @@ jobs:
       - name: Checkout repository
         uses: actions/checkout@v6
 
-      # The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
-      # web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
-      # "pattern all:dist: no matching files found" unless vite emits it first.
       - name: Setup Node.js
         if: matrix.language == 'go'
         uses: actions/setup-node@v6

+ 11 - 0
.github/workflows/release.yml

@@ -19,6 +19,17 @@ on:
       - "x-ui.service.arch"
       - "x-ui.service.rhel"
   pull_request:
+    paths:
+      - "**.js"
+      - "**.css"
+      - "**.html"
+      - "**.sh"
+      - "**.go"
+      - "go.mod"
+      - "go.sum"
+      - "x-ui.service.debian"
+      - "x-ui.service.arch"
+      - "x-ui.service.rhel"
 
 jobs:
   build:

+ 62 - 24
database/db.go

@@ -40,6 +40,7 @@ func initModels() error {
 		&model.HistoryOfSeeders{},
 		&model.CustomGeoResource{},
 		&model.Node{},
+		&model.ApiToken{},
 	}
 	for _, model := range models {
 		if err := db.AutoMigrate(model); err != nil {
@@ -86,43 +87,80 @@ func runSeeders(isUsersEmpty bool) error {
 		hashSeeder := &model.HistoryOfSeeders{
 			SeederName: "UserPasswordHash",
 		}
-		return db.Create(hashSeeder).Error
-	} else {
-		var seedersHistory []string
-		if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
-			log.Printf("Error fetching seeder history: %v", err)
+		if err := db.Create(hashSeeder).Error; err != nil {
+			return err
+		}
+		return seedApiTokens()
+	}
+
+	var seedersHistory []string
+	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
+		log.Printf("Error fetching seeder history: %v", err)
+		return err
+	}
+
+	if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
+		var users []model.User
+		if err := db.Find(&users).Error; err != nil {
+			log.Printf("Error fetching users for password migration: %v", err)
 			return err
 		}
 
-		if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
-			var users []model.User
-			if err := db.Find(&users).Error; err != nil {
-				log.Printf("Error fetching users for password migration: %v", err)
+		for _, user := range users {
+			hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
+			if err != nil {
+				log.Printf("Error hashing password for user '%s': %v", user.Username, err)
 				return err
 			}
-
-			for _, user := range users {
-				hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
-				if err != nil {
-					log.Printf("Error hashing password for user '%s': %v", user.Username, err)
-					return err
-				}
-				if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
-					log.Printf("Error updating password for user '%s': %v", user.Username, err)
-					return err
-				}
+			if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
+				log.Printf("Error updating password for user '%s': %v", user.Username, err)
+				return err
 			}
+		}
 
-			hashSeeder := &model.HistoryOfSeeders{
-				SeederName: "UserPasswordHash",
-			}
-			return db.Create(hashSeeder).Error
+		hashSeeder := &model.HistoryOfSeeders{
+			SeederName: "UserPasswordHash",
+		}
+		if err := db.Create(hashSeeder).Error; err != nil {
+			return err
 		}
 	}
 
+	if !slices.Contains(seedersHistory, "ApiTokensTable") {
+		if err := seedApiTokens(); err != nil {
+			return err
+		}
+	}
 	return nil
 }
 
+// seedApiTokens copies the legacy `apiToken` setting into the new
+// api_tokens table as a row named "default" so existing central panels
+// keep working after the upgrade. Idempotent — records itself in
+// history_of_seeders and only runs when api_tokens is empty.
+func seedApiTokens() error {
+	empty, err := isTableEmpty("api_tokens")
+	if err != nil {
+		return err
+	}
+	if empty {
+		var legacy model.Setting
+		err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
+		if err == nil && legacy.Value != "" {
+			row := &model.ApiToken{
+				Name:    "default",
+				Token:   legacy.Value,
+				Enabled: true,
+			}
+			if err := db.Create(row).Error; err != nil {
+				log.Printf("Error migrating legacy apiToken: %v", err)
+				return err
+			}
+		}
+	}
+	return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
+}
+
 // isTableEmpty returns true if the named table contains zero rows.
 func isTableEmpty(tableName string) (bool, error) {
 	var count int64

+ 8 - 0
database/model/model.go

@@ -88,6 +88,14 @@ type HistoryOfSeeders struct {
 	SeederName string `json:"seederName"`
 }
 
+type ApiToken struct {
+	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	Name      string `json:"name" gorm:"uniqueIndex;not null"`
+	Token     string `json:"token" gorm:"not null"`
+	Enabled   bool   `json:"enabled" gorm:"default:true"`
+	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime"`
+}
+
 // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
 func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 	listen := i.Listen

+ 1 - 1
frontend/api-docs.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>3x-ui · API Docs</title>
+    <title>API Docs</title>
   </head>
   <body>
     <div id="message"></div>

+ 0 - 3
frontend/eslint.config.js

@@ -19,9 +19,6 @@ export default [
       globals: {
         ...globals.browser,
         ...globals.node,
-        // Legacy script tags inject a couple of helpers on window before
-        // the SPA boots; declared here so no-undef stops flagging them.
-        getRandomRealityTarget: 'readonly',
       },
     },
     rules: {

+ 1 - 1
frontend/inbounds.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>3x-ui · Inbounds</title>
+    <title>Inbounds</title>
   </head>
   <body>
     <div id="message"></div>

+ 1 - 1
frontend/index.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>3x-ui</title>
+    <title>Overview</title>
   </head>
   <body>
     <div id="message"></div>

+ 1 - 1
frontend/login.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="robots" content="noindex,nofollow" />
-    <title>3x-ui — Sign in</title>
+    <title>Sign in</title>
   </head>
   <body>
     <div id="message"></div>

+ 1 - 1
frontend/nodes.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>3x-ui · Nodes</title>
+    <title>Nodes</title>
   </head>
   <body>
     <div id="message"></div>

+ 1 - 1
frontend/settings.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>3x-ui · Settings</title>
+    <title>Settings</title>
   </head>
   <body>
     <div id="message"></div>

+ 2 - 6
frontend/src/api/axios-init.js

@@ -85,12 +85,8 @@ export function setupAxios() {
       if (status === 401) {
         if (!sessionExpired) {
           sessionExpired = true;
-          if (import.meta.env.DEV) {
-            const basePath = window.X_UI_BASE_PATH || '/';
-            window.location.href = `${basePath}login.html`;
-          } else {
-            window.location.reload();
-          }
+          const basePath = window.X_UI_BASE_PATH || '/';
+          window.location.replace(basePath);
         }
         return new Promise(() => { });
       }

+ 3 - 1
frontend/src/composables/useNodeList.js

@@ -36,7 +36,9 @@ export function useNodeList() {
     return n != null && n.enable && n.status === 'online';
   }
 
+  const hasActive = computed(() => nodes.value.some((n) => n.enable));
+
   onMounted(refresh);
 
-  return { nodes, fetched, refresh, byId, nameFor, isOnline };
+  return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
 }

+ 2 - 0
frontend/src/entries/api-docs.js

@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import { i18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
 import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
 
 setupAxios();
+applyDocumentTitle();
 
 const messageContainer = document.getElementById('message');
 if (messageContainer) {

+ 2 - 0
frontend/src/entries/inbounds.js

@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import { i18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
 import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
 
 setupAxios();
+applyDocumentTitle();
 
 const messageContainer = document.getElementById('message');
 if (messageContainer) {

+ 2 - 0
frontend/src/entries/index.js

@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
 // stored theme to <body>/<html> before Vue mounts.
 import '@/composables/useTheme.js';
 import { i18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
 import IndexPage from '@/pages/index/IndexPage.vue';
 
 setupAxios();
+applyDocumentTitle();
 
 const messageContainer = document.getElementById('message');
 if (messageContainer) {

+ 2 - 0
frontend/src/entries/login.js

@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
 // stored theme to <body>/<html> before Vue renders anything.
 import '@/composables/useTheme.js';
 import { i18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
 import LoginPage from '@/pages/login/LoginPage.vue';
 
 setupAxios();
+applyDocumentTitle();
 
 // Toasts attach to a #message div the page provides — keeps theme
 // styling in sync with the rest of the panel.

+ 2 - 0
frontend/src/entries/nodes.js

@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import { i18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
 import NodesPage from '@/pages/nodes/NodesPage.vue';
 
 setupAxios();
+applyDocumentTitle();
 
 const messageContainer = document.getElementById('message');
 if (messageContainer) {

+ 2 - 0
frontend/src/entries/settings.js

@@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
 // stored theme to <body>/<html> before Vue mounts.
 import '@/composables/useTheme.js';
 import { i18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
 import SettingsPage from '@/pages/settings/SettingsPage.vue';
 
 setupAxios();
+applyDocumentTitle();
 
 const messageContainer = document.getElementById('message');
 if (messageContainer) {

+ 2 - 0
frontend/src/entries/xray.js

@@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import { i18n } from '@/i18n/index.js';
+import { applyDocumentTitle } from '@/utils';
 import XrayPage from '@/pages/xray/XrayPage.vue';
 
 setupAxios();
+applyDocumentTitle();
 
 const messageContainer = document.getElementById('message');
 if (messageContainer) {

+ 2 - 3
frontend/src/models/inbound.js

@@ -1,5 +1,6 @@
 import dayjs from 'dayjs';
 import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
+import { getRandomRealityTarget } from '@/models/reality-targets';
 
 export const Protocols = {
     VMESS: 'vmess',
@@ -897,9 +898,7 @@ export class RealityStreamSettings extends XrayCommonClass {
         super();
         // If target/serverNames are not provided, use random values
         if (!target && !serverNames) {
-            const randomTarget = typeof getRandomRealityTarget !== 'undefined'
-                ? getRandomRealityTarget()
-                : { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' };
+            const randomTarget = getRandomRealityTarget();
             target = randomTarget.target;
             serverNames = randomTarget.sni;
         }

+ 1 - 0
frontend/src/models/setting.js

@@ -57,6 +57,7 @@ export class AllSetting {
         this.subUpdates = 12;
         this.subEncrypt = true;
         this.subShowInfo = true;
+        this.subEmailInRemark = true;
         this.subURI = "";
         this.subJsonURI = "";
         this.subClashURI = "";

+ 239 - 116
frontend/src/pages/api-docs/ApiDocsPage.vue

@@ -1,37 +1,49 @@
 <script setup>
-import { ref, computed, onMounted } from 'vue';
-import { useI18n } from 'vue-i18n';
-import { Modal, message } from 'ant-design-vue';
+import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
 import {
   KeyOutlined,
-  ReloadOutlined,
-  CopyOutlined,
-  EyeOutlined,
-  EyeInvisibleOutlined,
   SearchOutlined,
   ExpandOutlined,
   CompressOutlined,
+  ApiOutlined,
+  SafetyCertificateOutlined,
+  CloudServerOutlined,
+  ClusterOutlined,
+  GlobalOutlined,
+  SaveOutlined,
+  SettingOutlined,
+  WifiOutlined,
+  LinkOutlined,
+  NodeIndexOutlined,
 } from '@ant-design/icons-vue';
 
 import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
 import AppSidebar from '@/components/AppSidebar.vue';
-import { HttpUtil, ClipboardManager } from '@/utils/index.js';
 import { sections as allSections } from './endpoints.js';
 import EndpointSection from './EndpointSection.vue';
 import CodeBlock from './CodeBlock.vue';
 
-const { t } = useI18n();
-
 const basePath = window.X_UI_BASE_PATH || '';
 const requestUri = window.location.pathname;
-
-const apiToken = ref('');
-const tokenLoading = ref(false);
-const tokenRotating = ref(false);
-const tokenVisible = ref(false);
+const settingsHref = `${basePath}panel/settings#security`;
 
 const searchQuery = ref('');
 const collapsedSections = ref(new Set());
+const activeSection = ref('');
+
+const sectionIcons = {
+  authentication: SafetyCertificateOutlined,
+  inbounds: NodeIndexOutlined,
+  server: CloudServerOutlined,
+  nodes: ClusterOutlined,
+  'custom-geo': GlobalOutlined,
+  backup: SaveOutlined,
+  settings: SettingOutlined,
+  'api-tokens': KeyOutlined,
+  'xray-settings': WifiOutlined,
+  subscription: LinkOutlined,
+  websocket: ApiOutlined,
+};
 
 const curlExample = `curl -X GET \\
   -H "Authorization: Bearer YOUR_API_TOKEN" \\
@@ -57,7 +69,7 @@ const endpointCount = computed(() =>
   allSections.reduce((sum, s) => sum + s.endpoints.length, 0)
 );
 
-const visibleSections = computed(() =>
+const visibleEndpoints = computed(() =>
   sections.value.reduce((sum, s) => sum + s.endpoints.length, 0)
 );
 
@@ -79,50 +91,53 @@ function collapseAll() {
   collapsedSections.value = new Set(allSections.map(s => s.id));
 }
 
-async function loadApiToken() {
-  tokenLoading.value = true;
-  try {
-    const msg = await HttpUtil.get('/panel/setting/getApiToken');
-    if (msg?.success) apiToken.value = msg.obj || '';
-  } finally {
-    tokenLoading.value = false;
+function scrollToSection(id) {
+  const el = document.getElementById(id);
+  if (!el) return;
+  el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+  if (window.location.hash !== `#${id}`) {
+    history.replaceState(null, '', `#${id}`);
   }
 }
 
-function regenerateApiToken() {
-  Modal.confirm({
-    title: t('pages.nodes.regenerateConfirm'),
-    okText: t('confirm'),
-    cancelText: t('cancel'),
-    okType: 'danger',
-    onOk: async () => {
-      tokenRotating.value = true;
-      try {
-        const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
-        if (msg?.success) {
-          apiToken.value = msg.obj || '';
-          message.success(t('success'));
-        }
-      } finally {
-        tokenRotating.value = false;
-      }
-    },
-  });
-}
-
-async function copyApiToken() {
-  if (!apiToken.value) return;
-  const ok = await ClipboardManager.copyText(apiToken.value);
-  if (ok) message.success(t('success'));
-}
-
-function scrollToSection(id) {
+function scrollToHash() {
+  const id = window.location.hash.slice(1);
+  if (!id) return;
   const el = document.getElementById(id);
-  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+  if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
+}
+
+let scrollObserver = null;
+function onScroll() {
+  const toc = document.querySelector('.toc-nav');
+  const tocHeight = toc ? toc.offsetHeight : 56;
+  let current = '';
+  for (const s of sections.value) {
+    const el = document.getElementById(s.id);
+    if (!el) continue;
+    const rect = el.getBoundingClientRect();
+    if (rect.top <= tocHeight + 20) {
+      current = s.id;
+    }
+  }
+  activeSection.value = current;
 }
 
 onMounted(() => {
-  loadApiToken();
+  scrollObserver = onScroll;
+  window.addEventListener('scroll', scrollObserver, { passive: true });
+  window.addEventListener('hashchange', scrollToHash);
+  requestAnimationFrame(() => {
+    scrollToHash();
+    onScroll();
+  });
+});
+
+onBeforeUnmount(() => {
+  if (scrollObserver) {
+    window.removeEventListener('scroll', scrollObserver);
+  }
+  window.removeEventListener('hashchange', scrollToHash);
 });
 </script>
 
@@ -148,38 +163,17 @@ onMounted(() => {
               <div class="token-card-head">
                 <div class="token-card-title">
                   <KeyOutlined />
-                  <span>API Token</span>
+                  <span>API Tokens</span>
                 </div>
-                <a-space size="small" wrap>
-                  <a-button size="small" @click="tokenVisible = !tokenVisible">
-                    <template #icon>
-                      <EyeInvisibleOutlined v-if="tokenVisible" />
-                      <EyeOutlined v-else />
-                    </template>
-                    {{ tokenVisible ? 'Hide' : 'Show' }}
-                  </a-button>
-                  <a-button size="small" :disabled="!apiToken" @click="copyApiToken">
-                    <template #icon>
-                      <CopyOutlined />
-                    </template>
-                    Copy
-                  </a-button>
-                  <a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
-                    <template #icon>
-                      <ReloadOutlined />
-                    </template>
-                    Regenerate
-                  </a-button>
-                </a-space>
+                <a-button type="primary" size="small" :href="settingsHref">
+                  Manage tokens
+                </a-button>
               </div>
-              <a-spin :spinning="tokenLoading" size="small">
-                <pre
-                  class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
-              </a-spin>
               <p class="token-hint">
-                Send it on every request as <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated
-                callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
-                running bots will need the new value.
+                Create, enable, or revoke named Bearer tokens in
+                <a :href="settingsHref">Settings → Security</a>. Send each request as
+                <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated callers skip CSRF and don't
+                need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
               </p>
             </a-card>
 
@@ -197,7 +191,7 @@ onMounted(() => {
                 <template #prefix><SearchOutlined /></template>
               </a-input-search>
               <span class="match-count" v-if="searchQuery">
-                {{ visibleSections }} / {{ endpointCount }} endpoints
+                {{ visibleEndpoints }} / {{ endpointCount }} endpoints
               </span>
               <a-space size="small">
                 <a-button size="small" @click="expandAll">
@@ -213,16 +207,27 @@ onMounted(() => {
 
             <nav class="toc-nav">
               <span class="toc-label">On this page:</span>
-              <a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
-                @click.prevent="scrollToSection(s.id)">
-                {{ s.title }} ({{ s.endpoints.length }})
-              </a>
+              <div class="toc-links">
+                <a
+                  v-for="s in sections"
+                  :key="s.id"
+                  class="toc-link"
+                  :class="{ active: activeSection === s.id }"
+                  :href="`#${s.id}`"
+                  @click.prevent="scrollToSection(s.id)"
+                >
+                  <component :is="sectionIcons[s.id]" class="toc-icon" />
+                  <span class="toc-text">{{ s.title }}</span>
+                  <span class="toc-badge">{{ s.endpoints.length }}</span>
+                </a>
+              </div>
             </nav>
 
             <EndpointSection
               v-for="s in sections"
               :key="s.id"
               :section="s"
+              :icon="sectionIcons[s.id]"
               :collapsed="isCollapsed(s.id)"
               @toggle="toggleSection(s.id)"
             />
@@ -273,20 +278,25 @@ onMounted(() => {
 }
 
 .docs-header {
-  margin-bottom: 18px;
+  margin-bottom: 20px;
+  padding: 24px;
+  background: var(--bg-card);
+  border: 1px solid rgba(128, 128, 128, 0.12);
+  border-radius: 10px;
 }
 
 .docs-title {
-  font-size: 26px;
-  font-weight: 700;
+  font-size: 28px;
+  font-weight: 800;
   margin: 0 0 8px;
   color: rgba(0, 0, 0, 0.88);
+  letter-spacing: -0.3px;
 }
 
 .docs-lead {
   margin: 0;
   color: rgba(0, 0, 0, 0.65);
-  line-height: 1.6;
+  line-height: 1.65;
   font-size: 14px;
 }
 
@@ -310,7 +320,8 @@ onMounted(() => {
   justify-content: space-between;
   gap: 12px;
   flex-wrap: wrap;
-  margin-bottom: 8px;
+  margin-bottom: 10px;
+  min-height: 32px;
 }
 
 .token-card-title {
@@ -321,18 +332,6 @@ onMounted(() => {
   font-size: 14px;
 }
 
-.token-value {
-  background: rgba(128, 128, 128, 0.08);
-  border: 1px solid rgba(128, 128, 128, 0.15);
-  border-radius: 6px;
-  padding: 10px 12px;
-  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: 13px;
-  margin: 0;
-  word-break: break-all;
-  white-space: pre-wrap;
-}
-
 .token-hint {
   margin: 10px 0 0;
   color: rgba(0, 0, 0, 0.55);
@@ -377,32 +376,87 @@ onMounted(() => {
 .toc-nav {
   display: flex;
   flex-wrap: wrap;
-  align-items: center;
-  gap: 8px 14px;
+  align-items: flex-start;
+  gap: 8px 12px;
   padding: 12px 16px;
-  background: rgba(128, 128, 128, 0.08);
-  border-radius: 6px;
+  background: var(--bg-card);
+  border: 1px solid rgba(128, 128, 128, 0.12);
+  border-radius: 8px;
   margin-bottom: 16px;
 }
 
 .toc-label {
-  font-size: 12px;
+  font-size: 11px;
   font-weight: 600;
   text-transform: uppercase;
-  letter-spacing: 0.5px;
+  letter-spacing: 0.6px;
   color: rgba(0, 0, 0, 0.5);
+  padding-top: 3px;
+  flex-shrink: 0;
+}
+
+.toc-links {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
 }
 
 .toc-link {
-  color: #1677ff;
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 4px 10px;
+  border-radius: 20px;
+  font-size: 12.5px;
+  color: rgba(0, 0, 0, 0.65);
+  background: rgba(128, 128, 128, 0.06);
+  border: 1px solid transparent;
   text-decoration: none;
   cursor: pointer;
-  font-size: 13px;
+  transition: all 0.2s;
+  white-space: nowrap;
 }
 
 .toc-link:hover {
-  color: #4096ff;
-  text-decoration: underline;
+  background: rgba(22, 119, 255, 0.08);
+  color: #1677ff;
+  border-color: rgba(22, 119, 255, 0.2);
+}
+
+.toc-link.active {
+  background: rgba(22, 119, 255, 0.12);
+  color: #1677ff;
+  border-color: rgba(22, 119, 255, 0.3);
+  font-weight: 600;
+}
+
+.toc-icon {
+  font-size: 13px;
+  opacity: 0.8;
+}
+
+.toc-text {
+  font-size: 12.5px;
+}
+
+.toc-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 18px;
+  height: 18px;
+  padding: 0 5px;
+  border-radius: 9px;
+  font-size: 10.5px;
+  font-weight: 700;
+  background: rgba(22, 119, 255, 0.12);
+  color: #1677ff;
+  line-height: 1;
+}
+
+.toc-link.active .toc-badge {
+  background: #1677ff;
+  color: #fff;
 }
 </style>
 
@@ -411,28 +465,97 @@ body.dark .docs-title {
   color: rgba(255, 255, 255, 0.92);
 }
 
+html[data-theme='ultra-dark'] .docs-title {
+  color: rgba(255, 255, 255, 0.95);
+}
+
+body.dark .docs-header {
+  background: #252526;
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
+html[data-theme='ultra-dark'] .docs-header {
+  background: #0a0a0a;
+  border-color: rgba(255, 255, 255, 0.06);
+}
+
 body.dark .docs-lead,
 body.dark .token-hint {
   color: rgba(255, 255, 255, 0.7);
 }
 
+html[data-theme='ultra-dark'] .docs-lead,
+html[data-theme='ultra-dark'] .token-hint {
+  color: rgba(255, 255, 255, 0.75);
+}
+
 body.dark .docs-lead code,
 body.dark .token-hint code {
   background: rgba(255, 255, 255, 0.1);
 }
 
-body.dark .token-value,
+html[data-theme='ultra-dark'] .docs-lead code,
+html[data-theme='ultra-dark'] .token-hint code {
+  background: rgba(255, 255, 255, 0.12);
+}
+
 body.dark .code-block {
   background: rgba(255, 255, 255, 0.04);
   border-color: rgba(255, 255, 255, 0.1);
   color: rgba(255, 255, 255, 0.88);
 }
 
+html[data-theme='ultra-dark'] .code-block {
+  background: rgba(255, 255, 255, 0.02);
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
 body.dark .toc-nav {
-  background: rgba(255, 255, 255, 0.04);
+  background: #252526;
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
+html[data-theme='ultra-dark'] .toc-nav {
+  background: #0a0a0a;
+  border-color: rgba(255, 255, 255, 0.06);
 }
 
 body.dark .toc-label {
   color: rgba(255, 255, 255, 0.55);
 }
+
+html[data-theme='ultra-dark'] .toc-label {
+  color: rgba(255, 255, 255, 0.6);
+}
+
+body.dark .toc-link {
+  color: rgba(255, 255, 255, 0.65);
+  background: rgba(255, 255, 255, 0.06);
+}
+
+html[data-theme='ultra-dark'] .toc-link {
+  background: rgba(255, 255, 255, 0.04);
+}
+
+body.dark .toc-link:hover {
+  background: rgba(88, 166, 255, 0.12);
+  color: #58a6ff;
+  border-color: rgba(88, 166, 255, 0.25);
+}
+
+body.dark .toc-link.active {
+  background: rgba(88, 166, 255, 0.15);
+  color: #58a6ff;
+  border-color: rgba(88, 166, 255, 0.35);
+}
+
+body.dark .toc-badge {
+  background: rgba(88, 166, 255, 0.15);
+  color: #58a6ff;
+}
+
+body.dark .toc-link.active .toc-badge {
+  background: #58a6ff;
+  color: #0d1117;
+}
 </style>

+ 56 - 34
frontend/src/pages/api-docs/CodeBlock.vue

@@ -50,10 +50,13 @@ async function copyCode() {
 
 <template>
   <div class="code-block-wrapper">
-    <button class="copy-btn" :class="{ copied }" @click="copyCode" :title="copied ? 'Copied' : 'Copy'">
-      <CheckOutlined v-if="copied" />
-      <CopyOutlined v-else />
-    </button>
+    <div class="code-toolbar">
+      <span class="lang-badge">{{ lang.toUpperCase() }}</span>
+      <button class="copy-btn" :class="{ copied }" @click="copyCode" :title="copied ? 'Copied' : 'Copy'">
+        <CheckOutlined v-if="copied" />
+        <CopyOutlined v-else />
+      </button>
+    </div>
     <pre class="code-block" :class="`lang-${lang}`"><code v-html="highlighted"></code></pre>
   </div>
 </template>
@@ -63,30 +66,40 @@ async function copyCode() {
   position: relative;
   border-radius: 6px;
   overflow: hidden;
+  border: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.code-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 4px 8px;
+  background: rgba(128, 128, 128, 0.06);
+  border-bottom: 1px solid rgba(128, 128, 128, 0.1);
+}
+
+.lang-badge {
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 0.5px;
+  color: rgba(0, 0, 0, 0.4);
+  text-transform: uppercase;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
 }
 
 .copy-btn {
-  position: absolute;
-  top: 6px;
-  right: 6px;
-  z-index: 1;
   display: inline-flex;
   align-items: center;
   justify-content: center;
-  width: 28px;
-  height: 28px;
-  border: 1px solid rgba(128, 128, 128, 0.2);
+  width: 26px;
+  height: 26px;
+  border: 1px solid rgba(128, 128, 128, 0.15);
   border-radius: 4px;
-  background: rgba(255, 255, 255, 0.85);
-  color: rgba(0, 0, 0, 0.5);
+  background: rgba(255, 255, 255, 0.7);
+  color: rgba(0, 0, 0, 0.45);
   cursor: pointer;
-  font-size: 13px;
-  opacity: 0;
-  transition: opacity 0.15s, background 0.15s, color 0.15s;
-}
-
-.code-block-wrapper:hover .copy-btn {
-  opacity: 1;
+  font-size: 12px;
+  transition: all 0.15s;
 }
 
 .copy-btn:hover {
@@ -96,27 +109,24 @@ async function copyCode() {
 }
 
 .copy-btn.copied {
-  opacity: 1;
   background: #52c41a;
   color: #fff;
   border-color: #52c41a;
 }
 
 .code-block {
-  background: rgba(128, 128, 128, 0.08);
-  border: 1px solid rgba(128, 128, 128, 0.15);
-  border-radius: 6px;
-  padding: 12px;
+  background: rgba(128, 128, 128, 0.04);
+  padding: 10px 12px;
   margin: 0;
   font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
   font-size: 12.5px;
-  line-height: 1.55;
+  line-height: 1.6;
   white-space: pre-wrap;
   word-break: break-word;
   overflow-x: auto;
+  border: none;
+  border-radius: 0;
 }
-
-
 </style>
 
 <style>
@@ -126,9 +136,21 @@ async function copyCode() {
 .json-boolean { color: #cf222e; }
 .json-null { color: #8250df; }
 
-body.dark .code-block {
-  background: rgba(255, 255, 255, 0.04);
+body.dark .code-block-wrapper {
   border-color: rgba(255, 255, 255, 0.1);
+}
+
+body.dark .code-toolbar {
+  background: rgba(255, 255, 255, 0.03);
+  border-color: rgba(255, 255, 255, 0.06);
+}
+
+body.dark .lang-badge {
+  color: rgba(255, 255, 255, 0.4);
+}
+
+body.dark .code-block {
+  background: rgba(255, 255, 255, 0.03);
   color: rgba(255, 255, 255, 0.88);
 }
 
@@ -139,13 +161,13 @@ body.dark .json-boolean { color: #ff7b72; }
 body.dark .json-null { color: #d2a8ff; }
 
 body.dark .copy-btn {
-  background: rgba(255, 255, 255, 0.08);
-  color: rgba(255, 255, 255, 0.5);
-  border-color: rgba(255, 255, 255, 0.15);
+  background: rgba(255, 255, 255, 0.06);
+  color: rgba(255, 255, 255, 0.45);
+  border-color: rgba(255, 255, 255, 0.12);
 }
 
 body.dark .copy-btn:hover {
-  background: rgba(255, 255, 255, 0.12);
+  background: rgba(255, 255, 255, 0.1);
   color: #58a6ff;
   border-color: #58a6ff;
 }

+ 47 - 13
frontend/src/pages/api-docs/EndpointRow.vue

@@ -51,11 +51,22 @@ const paramColumns = [
 
 <style scoped>
 .endpoint-row {
-  padding: 12px 0;
+  padding: 14px 0;
+  margin: 0;
+  transition: background 0.15s;
+  border-radius: 6px;
+  padding-left: 8px;
+  padding-right: 8px;
+  margin-left: -8px;
+  margin-right: -8px;
+}
+
+.endpoint-row:hover {
+  background: rgba(128, 128, 128, 0.03);
 }
 
 .endpoint-row + .endpoint-row {
-  border-top: 1px solid rgba(128, 128, 128, 0.15);
+  border-top: 1px solid rgba(128, 128, 128, 0.1);
 }
 
 .endpoint-header {
@@ -66,35 +77,45 @@ const paramColumns = [
 }
 
 .method-tag {
-  font-weight: 600;
+  font-weight: 700;
   font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 11px;
   letter-spacing: 0.5px;
-  min-width: 60px;
+  min-width: 56px;
   text-align: center;
+  text-transform: uppercase;
+  border-radius: 4px;
+  padding: 2px 8px;
+  line-height: 1.6;
 }
 
 .endpoint-path {
   font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: 13px;
+  font-size: 13.5px;
   word-break: break-all;
+  color: rgba(0, 0, 0, 0.8);
+  background: rgba(128, 128, 128, 0.06);
+  padding: 2px 8px;
+  border-radius: 4px;
 }
 
 .endpoint-summary {
   margin: 8px 0 0;
-  color: rgba(0, 0, 0, 0.65);
-  line-height: 1.55;
+  color: rgba(0, 0, 0, 0.6);
+  line-height: 1.6;
+  font-size: 13.5px;
 }
 
 .endpoint-block {
-  margin-top: 12px;
+  margin-top: 14px;
 }
 
 .block-label {
-  font-size: 12px;
+  font-size: 11px;
   font-weight: 600;
   text-transform: uppercase;
-  letter-spacing: 0.5px;
-  color: rgba(0, 0, 0, 0.5);
+  letter-spacing: 0.6px;
+  color: rgba(0, 0, 0, 0.45);
   margin-bottom: 6px;
 }
 
@@ -118,12 +139,25 @@ const paramColumns = [
 </style>
 
 <style>
+body.dark .endpoint-row:hover {
+  background: rgba(255, 255, 255, 0.02);
+}
+
+body.dark .endpoint-row + .endpoint-row {
+  border-top-color: rgba(255, 255, 255, 0.08);
+}
+
+body.dark .endpoint-path {
+  color: rgba(255, 255, 255, 0.82);
+  background: rgba(255, 255, 255, 0.05);
+}
+
 body.dark .endpoint-summary {
-  color: rgba(255, 255, 255, 0.7);
+  color: rgba(255, 255, 255, 0.65);
 }
 
 body.dark .block-label {
-  color: rgba(255, 255, 255, 0.55);
+  color: rgba(255, 255, 255, 0.45);
 }
 
 body.dark .error-label {

+ 47 - 11
frontend/src/pages/api-docs/EndpointSection.vue

@@ -9,6 +9,7 @@ import { safeInlineHtml } from './endpoints.js';
 
 const props = defineProps({
   section: { type: Object, required: true },
+  icon: { type: Object, default: null },
   collapsed: { type: Boolean, default: false },
 });
 
@@ -27,6 +28,7 @@ const endpointLabel = computed(() =>
       <div class="section-header-left">
         <DownOutlined v-if="!collapsed" class="collapse-icon" />
         <RightOutlined v-else class="collapse-icon" />
+        <component v-if="icon" :is="icon" class="section-icon" />
         <h2 class="section-title">{{ section.title }}</h2>
       </div>
       <span class="endpoint-count">{{ endpointLabel }}</span>
@@ -58,11 +60,15 @@ const endpointLabel = computed(() =>
 <style scoped>
 .api-section {
   background: #fff;
-  border: 1px solid rgba(128, 128, 128, 0.15);
+  border: 1px solid rgba(128, 128, 128, 0.12);
   border-radius: 8px;
-  padding: 16px 24px;
+  padding: 20px 24px;
   margin-bottom: 16px;
-  scroll-margin-top: 16px;
+  transition: box-shadow 0.2s, border-color 0.2s;
+}
+
+.api-section:hover {
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
 }
 
 .section-header {
@@ -73,39 +79,52 @@ const endpointLabel = computed(() =>
   user-select: none;
 }
 
-.section-header:hover .collapse-icon {
+.section-header:hover .collapse-icon,
+.section-header:hover .section-icon {
   color: #1677ff;
 }
 
 .section-header-left {
   display: flex;
   align-items: center;
-  gap: 8px;
+  gap: 10px;
 }
 
 .collapse-icon {
   font-size: 12px;
+  color: rgba(0, 0, 0, 0.4);
+  transition: color 0.2s;
+}
+
+.section-icon {
+  font-size: 18px;
   color: rgba(0, 0, 0, 0.45);
   transition: color 0.2s;
 }
 
 .section-title {
   font-size: 20px;
-  font-weight: 600;
+  font-weight: 700;
   margin: 0;
   color: rgba(0, 0, 0, 0.88);
 }
 
 .endpoint-count {
-  font-size: 12px;
+  font-size: 11px;
+  font-weight: 600;
   color: rgba(0, 0, 0, 0.45);
   white-space: nowrap;
+  background: rgba(128, 128, 128, 0.08);
+  padding: 3px 10px;
+  border-radius: 12px;
+  text-transform: uppercase;
+  letter-spacing: 0.3px;
 }
 
 .section-description {
-  margin: 10px 0 14px;
+  margin: 12px 0 14px;
   color: rgba(0, 0, 0, 0.65);
-  line-height: 1.55;
+  line-height: 1.6;
 }
 
 .sub-header-block {
@@ -134,18 +153,30 @@ const endpointLabel = computed(() =>
 <style>
 body.dark .api-section {
   background: #252526;
-  border-color: rgba(255, 255, 255, 0.1);
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
+body.dark .api-section:hover {
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
 }
 
 html[data-theme='ultra-dark'] .api-section {
   background: #0a0a0a;
-  border-color: rgba(255, 255, 255, 0.08);
+  border-color: rgba(255, 255, 255, 0.06);
+}
+
+html[data-theme='ultra-dark'] .api-section:hover {
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
 }
 
 body.dark .section-title {
   color: rgba(255, 255, 255, 0.92);
 }
 
+body.dark .section-icon {
+  color: rgba(255, 255, 255, 0.5);
+}
+
 body.dark .section-description {
   color: rgba(255, 255, 255, 0.7);
 }
@@ -153,4 +184,9 @@ body.dark .section-description {
 body.dark .block-label {
   color: rgba(255, 255, 255, 0.55);
 }
+
+body.dark .endpoint-count {
+  color: rgba(255, 255, 255, 0.55);
+  background: rgba(255, 255, 255, 0.06);
+}
 </style>

+ 58 - 16
frontend/src/pages/api-docs/endpoints.js

@@ -25,7 +25,7 @@ export function safeInlineHtml(input) {
 
 export const sections = [
   {
-    id: 'auth',
+    id: 'authentication',
     title: 'Authentication',
     description:
       'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
@@ -69,7 +69,7 @@ export const sections = [
 
   {
     id: 'inbounds',
-    title: 'Inbounds API',
+    title: 'Inbounds',
     description:
       'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour forwarded headers only when the request comes from a configured trusted proxy.',
     endpoints: [
@@ -193,6 +193,14 @@ export const sections = [
         body:
           '{\n  "id": 1,\n  "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/inbounds/:id/resetTraffic',
+        summary: 'Zero out upload + download counters for a single inbound. Does not touch per-client counters.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+        ],
+      },
       {
         method: 'POST',
         path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
@@ -289,7 +297,7 @@ export const sections = [
 
   {
     id: 'server',
-    title: 'Server API',
+    title: 'Server',
     description:
       'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
     endpoints: [
@@ -488,7 +496,7 @@ export const sections = [
 
   {
     id: 'nodes',
-    title: 'Nodes API',
+    title: 'Nodes',
     description:
       'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
     endpoints: [
@@ -568,8 +576,8 @@ export const sections = [
   },
 
   {
-    id: 'customGeo',
-    title: 'Custom Geo API',
+    id: 'custom-geo',
+    title: 'Custom Geo',
     description:
       'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
     endpoints: [
@@ -637,9 +645,9 @@ export const sections = [
 
   {
     id: 'settings',
-    title: 'Settings API',
+    title: 'Settings',
     description:
-      'Panel configuration, user credentials, and API token management. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
+      'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
     endpoints: [
       {
         method: 'POST',
@@ -680,24 +688,58 @@ export const sections = [
         path: '/panel/setting/getDefaultJsonConfig',
         summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
       },
+    ],
+  },
+
+  {
+    id: 'api-tokens',
+    title: 'API Tokens',
+    description:
+      'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
+    endpoints: [
       {
         method: 'GET',
-        path: '/panel/setting/getApiToken',
-        summary: 'Return the current API Bearer token. The token is auto-generated on first read so existing installs upgrade transparently.',
-        response: '{\n  "success": true,\n  "obj": "abcdef-12345-..."\n}',
+        path: '/panel/setting/apiTokens',
+        summary: 'List every API token, enabled or not.',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "default",\n      "token": "abcdef-12345-...",\n      "enabled": true,\n      "createdAt": 1736000000\n    }\n  ]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/setting/apiTokens/create',
+        summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
+        params: [
+          { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
+        ],
+        body: '{\n  "name": "central-panel-a"\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "id": 2,\n    "name": "central-panel-a",\n    "token": "new-token-string",\n    "enabled": true,\n    "createdAt": 1736000000\n  }\n}',
+        errorResponse: '{\n  "success": false,\n  "msg": "a token with that name already exists"\n}',
       },
       {
         method: 'POST',
-        path: '/panel/setting/regenerateApiToken',
-        summary: 'Rotate the API Bearer token. Any remote central panel that cached the old value will start failing heartbeats until updated with the new token.',
-        response: '{\n  "success": true,\n  "obj": "new-token-string"\n}',
+        path: '/panel/setting/apiTokens/delete/:id',
+        summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
+        ],
+        response: '{\n  "success": true\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/setting/apiTokens/setEnabled/:id',
+        summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
+          { name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' },
+        ],
+        body: '{\n  "enabled": false\n}',
+        response: '{\n  "success": true\n}',
       },
     ],
   },
 
   {
-    id: 'xraySettings',
-    title: 'Xray Settings API',
+    id: 'xray-settings',
+    title: 'Xray Settings',
     description:
       'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
     endpoints: [

+ 56 - 5
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -12,6 +12,7 @@ import {
   SizeFormatter,
   Wireguard,
 } from '@/utils';
+import { getRandomRealityTarget } from '@/models/reality-targets';
 import {
   Inbound,
   Protocols,
@@ -69,6 +70,7 @@ const inbound = ref(null);
 const dbForm = ref(null);
 const saving = ref(false);
 const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
+const activeTabKey = ref('basic');
 // Cached default cert/key paths from /panel/setting/defaultSettings —
 // powers the "Set default cert" button on the TLS form.
 const defaultCert = ref('');
@@ -240,9 +242,60 @@ watch(() => props.open, (next) => {
     dbForm.value = freshDbForm();
     primeAdvancedJson();
   }
+  activeTabKey.value = 'basic';
   fetchDefaultCertSettings();
 });
 
+function applyAdvancedJsonToBasic() {
+  if (!inbound.value) return true;
+  let parsedSettings;
+  let parsedStream;
+  let parsedSniffing;
+  try {
+    parsedSettings = advancedJson.value.settings.trim()
+      ? JSON.parse(advancedJson.value.settings)
+      : inbound.value.settings?.toJson?.();
+  } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
+  try {
+    parsedStream = advancedJson.value.stream.trim()
+      ? JSON.parse(advancedJson.value.stream)
+      : inbound.value.stream?.toJson?.();
+  } catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
+  try {
+    parsedSniffing = advancedJson.value.sniffing.trim()
+      ? JSON.parse(advancedJson.value.sniffing)
+      : inbound.value.sniffing?.toJson?.();
+  } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
+
+  try {
+    inbound.value = Inbound.fromJson({
+      port: inbound.value.port,
+      listen: inbound.value.listen,
+      protocol: inbound.value.protocol,
+      settings: parsedSettings,
+      streamSettings: parsedStream,
+      tag: inbound.value.tag,
+      sniffing: parsedSniffing,
+      clientStats: inbound.value.clientStats,
+    });
+  } catch (e) {
+    message.error(`Advanced JSON: ${e.message}`);
+    return false;
+  }
+  return true;
+}
+
+let isRevertingTab = false;
+watch(activeTabKey, (next, prev) => {
+  if (isRevertingTab) { isRevertingTab = false; return; }
+  if (prev === 'advanced' && next !== 'advanced') {
+    if (!applyAdvancedJsonToBasic()) {
+      isRevertingTab = true;
+      activeTabKey.value = 'advanced';
+    }
+  }
+});
+
 // In add mode, switching protocol restamps settings + re-syncs port.
 function onProtocolChange(next) {
   if (props.mode === 'edit' || !inbound.value) return;
@@ -339,11 +392,9 @@ function clearMldsa65() {
   inbound.value.stream.reality.settings.mldsa65Verify = '';
 }
 
-// Reality target/SNI randomizer — only available if the helper is loaded
 function randomizeRealityTarget() {
   if (!inbound.value?.stream?.reality) return;
-  if (typeof window.getRandomRealityTarget !== 'function') return;
-  const t = window.getRandomRealityTarget();
+  const t = getRandomRealityTarget();
   inbound.value.stream.reality.target = t.target;
   inbound.value.stream.reality.serverNames = t.sni;
 }
@@ -573,7 +624,7 @@ watch(
 <template>
   <a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving"
     :mask-closable="false" width="780px" @ok="submit" @cancel="close">
-    <a-tabs v-if="inbound && dbForm" default-active-key="basic">
+    <a-tabs v-if="inbound && dbForm" v-model:active-key="activeTabKey">
       <!-- ============================== BASICS ============================== -->
       <a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
         <a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
@@ -583,7 +634,7 @@ watch(
           <a-form-item :label="t('pages.inbounds.remark')">
             <a-input v-model:value="dbForm.remark" />
           </a-form-item>
-          <a-form-item :label="t('pages.inbounds.deployTo')">
+          <a-form-item v-if="selectableNodes.length > 0" :label="t('pages.inbounds.deployTo')">
             <a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
               :placeholder="t('pages.inbounds.localPanel')" allow-clear>
               <a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>

+ 4 - 3
frontend/src/pages/inbounds/InboundList.vue

@@ -49,6 +49,7 @@ const props = defineProps({
   // Map node id -> node row, supplied by the parent page so each
   // inbound row can render its node name without an extra fetch.
   nodesById: { type: Map, default: () => new Map() },
+  hasActiveNode: { type: Boolean, default: false },
 });
 
 const emit = defineEmits([
@@ -234,7 +235,7 @@ const desktopColumns = computed(() => {
   if (hasAnyRemark.value) {
     cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
   }
-  if (props.nodesById.size > 0) {
+  if (props.hasActiveNode) {
     cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
   }
   cols.push(
@@ -374,7 +375,7 @@ function showQrCodeMenu(dbInbound) {
             {{ protocol }}
           </a-select-option>
         </a-select>
-        <a-select v-if="nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
+        <a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
           :placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
           <a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
             {{ node.label }}
@@ -466,7 +467,7 @@ function showQrCodeMenu(dbInbound) {
               <span class="stat-label">{{ t('pages.inbounds.port') }}</span>
               <a-tag>{{ record.port }}</a-tag>
             </div>
-            <div v-if="nodesById.size > 0" class="stat-row">
+            <div v-if="hasActiveNode" class="stat-row">
               <span class="stat-label">{{ t('pages.inbounds.node') }}</span>
               <a-tag v-if="record.nodeId == null" color="default">
                 {{ t('pages.inbounds.localPanel') }}

+ 4 - 3
frontend/src/pages/inbounds/InboundsPage.vue

@@ -66,7 +66,7 @@ useWebSocket({
 const { isMobile } = useMediaQuery();
 // Node list lives on the central panel; the Inbounds page consumes
 // the id→node map for the new "Node" column. Fetched once on mount.
-const { byId: nodesById } = useNodeList();
+const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
 
 const basePath = window.X_UI_BASE_PATH || '';
 const requestUri = window.location.pathname;
@@ -396,7 +396,7 @@ function confirmResetTraffic(dbInbound) {
     okText: 'Reset',
     cancelText: 'Cancel',
     onOk: async () => {
-      const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`);
+      const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
       if (msg?.success) await refresh();
     },
   });
@@ -647,7 +647,8 @@ function onRowAction({ key, dbInbound }) {
                 <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
                   :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
                   :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
-                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
+                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
+                  @refresh="refresh"
                   @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
                   @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
                   @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"

+ 212 - 47
frontend/src/pages/settings/SecurityTab.vue

@@ -75,34 +75,41 @@ function updateUser() {
   }
 }
 
-// === API Token =========================================================
-// Surfaces the panel's API token so a remote central panel can register
-// this instance as a node. Lazy-loaded on tab mount; rotation requires
-// confirmation since it invalidates any cached value upstream.
-const apiToken = ref('');
-const apiTokenLoading = ref(false);
-const apiTokenRotating = ref(false);
-
-async function loadApiToken() {
-  apiTokenLoading.value = true;
+const apiTokens = ref([]);
+const apiTokensLoading = ref(false);
+const visibleTokenIds = ref(new Set());
+const createOpen = ref(false);
+const createName = ref('');
+const creating = ref(false);
+
+async function loadApiTokens() {
+  apiTokensLoading.value = true;
   try {
-    const msg = await HttpUtil.get('/panel/setting/getApiToken');
-    if (msg?.success) apiToken.value = msg.obj || '';
+    const msg = await HttpUtil.get('/panel/setting/apiTokens');
+    if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
   } finally {
-    apiTokenLoading.value = false;
+    apiTokensLoading.value = false;
   }
 }
 
-async function copyApiToken() {
-  if (!apiToken.value) return;
+function isTokenVisible(id) {
+  return visibleTokenIds.value.has(id);
+}
+
+function toggleTokenVisibility(id) {
+  const next = new Set(visibleTokenIds.value);
+  if (next.has(id)) next.delete(id); else next.add(id);
+  visibleTokenIds.value = next;
+}
+
+async function copyToken(token) {
+  if (!token) return;
   try {
-    await navigator.clipboard.writeText(apiToken.value);
+    await navigator.clipboard.writeText(token);
     message.success(t('copySuccess'));
   } catch (_e) {
-    // navigator.clipboard can be undefined on http:// — fall back to
-    // a transient input + execCommand path.
     const ta = document.createElement('textarea');
-    ta.value = apiToken.value;
+    ta.value = token;
     document.body.appendChild(ta);
     ta.select();
     document.execCommand('copy');
@@ -111,28 +118,66 @@ async function copyApiToken() {
   }
 }
 
-function regenerateApiToken() {
+function openCreateModal() {
+  createName.value = '';
+  createOpen.value = true;
+}
+
+async function confirmCreateToken() {
+  const name = createName.value.trim();
+  if (!name) {
+    message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
+    return;
+  }
+  creating.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name });
+    if (msg?.success) {
+      createOpen.value = false;
+      await loadApiTokens();
+      if (msg.obj?.id != null) {
+        const next = new Set(visibleTokenIds.value);
+        next.add(msg.obj.id);
+        visibleTokenIds.value = next;
+      }
+    }
+  } finally {
+    creating.value = false;
+  }
+}
+
+function confirmDeleteToken(row) {
   Modal.confirm({
-    title: t('pages.nodes.regenerateConfirm'),
-    okText: t('confirm'),
+    title: `${t('delete')} "${row.name}"?`,
+    content: t('pages.settings.security.apiTokenDeleteWarning')
+      || 'Any caller using this token will stop authenticating immediately.',
+    okText: t('delete'),
     cancelText: t('cancel'),
     okType: 'danger',
     onOk: async () => {
-      apiTokenRotating.value = true;
-      try {
-        const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
-        if (msg?.success) {
-          apiToken.value = msg.obj || '';
-          message.success(t('success'));
-        }
-      } finally {
-        apiTokenRotating.value = false;
-      }
+      const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
+      if (msg?.success) await loadApiTokens();
     },
   });
 }
 
-onMounted(loadApiToken);
+async function toggleTokenEnabled(row) {
+  const target = !row.enabled;
+  const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target });
+  if (msg?.success) row.enabled = target;
+}
+
+function maskToken(token) {
+  if (!token) return '';
+  return '•'.repeat(Math.min(token.length, 24));
+}
+
+function formatTokenDate(ts) {
+  if (!ts) return '';
+  return new Date(ts * 1000).toLocaleString();
+}
+
+onMounted(loadApiTokens);
 
 function toggleTwoFactor() {
   // Switch read-only — the actual flip happens after the modal succeeds.
@@ -216,24 +261,144 @@ function toggleTwoFactor() {
     </a-collapse-panel>
 
     <a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
-      <SettingListItem paddings="small">
-        <template #title>{{ t('pages.nodes.apiToken') }}</template>
-        <template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
-        <template #control>
-          <a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" />
-        </template>
-      </SettingListItem>
-      <a-list-item>
-        <a-space direction="horizontal" :style="{ padding: '0 20px' }">
-          <a-button :disabled="!apiToken" @click="copyApiToken">{{ t('copy') }}</a-button>
-          <a-button danger :loading="apiTokenRotating" @click="regenerateApiToken">
-            {{ t('pages.nodes.regenerate') }}
+      <div class="api-token-section">
+        <div class="api-token-header">
+          <p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
+          <a-button type="primary" size="small" @click="openCreateModal">
+            + {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
           </a-button>
-        </a-space>
-      </a-list-item>
+        </div>
+
+        <a-spin :spinning="apiTokensLoading">
+          <a-empty v-if="!apiTokens.length && !apiTokensLoading"
+            :description="t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'" />
+
+          <div v-for="row in apiTokens" :key="row.id" class="api-token-row" :class="{ disabled: !row.enabled }">
+            <div class="api-token-row-head">
+              <div class="api-token-name-wrap">
+                <span class="api-token-name">{{ row.name }}</span>
+                <span class="api-token-created">{{ formatTokenDate(row.createdAt) }}</span>
+              </div>
+              <div class="api-token-actions">
+                <a-switch size="small" :checked="row.enabled" @change="toggleTokenEnabled(row)" />
+                <a-button size="small" danger type="text" @click="confirmDeleteToken(row)">
+                  {{ t('delete') }}
+                </a-button>
+              </div>
+            </div>
+            <div class="api-token-value-wrap">
+              <code class="api-token-value">{{ isTokenVisible(row.id) ? row.token : maskToken(row.token) }}</code>
+              <a-button size="small" @click="toggleTokenVisibility(row.id)">
+                {{ isTokenVisible(row.id)
+                  ? (t('pages.settings.security.hide') || 'Hide')
+                  : (t('pages.settings.security.show') || 'Show') }}
+              </a-button>
+              <a-button size="small" @click="copyToken(row.token)">{{ t('copy') }}</a-button>
+            </div>
+          </div>
+        </a-spin>
+      </div>
     </a-collapse-panel>
   </a-collapse>
 
+  <a-modal v-model:open="createOpen" :title="t('pages.settings.security.apiTokenNew') || 'New API token'"
+    :confirm-loading="creating" :ok-text="t('confirm')" :cancel-text="t('cancel')" @ok="confirmCreateToken">
+    <a-form layout="vertical">
+      <a-form-item :label="t('pages.settings.security.apiTokenName') || 'Name'" required>
+        <a-input v-model:value="createName" maxlength="64"
+          :placeholder="t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'"
+          @keyup.enter="confirmCreateToken" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+
   <TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
     :type="tfa.type" @confirm="onTfaConfirm" />
 </template>
+
+<style scoped>
+.api-token-section {
+  padding: 8px 20px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.api-token-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.api-token-hint {
+  margin: 0;
+  font-size: 12.5px;
+  opacity: 0.7;
+  flex: 1;
+  min-width: 200px;
+}
+
+.api-token-row {
+  border: 1px solid rgba(128, 128, 128, 0.18);
+  border-radius: 8px;
+  padding: 10px 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  transition: opacity 0.15s;
+}
+
+.api-token-row.disabled {
+  opacity: 0.55;
+}
+
+.api-token-row-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.api-token-name-wrap {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.api-token-name {
+  font-weight: 600;
+  font-size: 13.5px;
+}
+
+.api-token-created {
+  font-size: 11px;
+  opacity: 0.55;
+}
+
+.api-token-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.api-token-value-wrap {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.api-token-value {
+  flex: 1;
+  min-width: 0;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 12.5px;
+  padding: 4px 8px;
+  background: rgba(128, 128, 128, 0.08);
+  border-radius: 4px;
+  word-break: break-all;
+}
+</style>

+ 31 - 2
frontend/src/pages/settings/SettingsPage.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, onMounted, ref } from 'vue';
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { Modal } from 'ant-design-vue';
 import {
@@ -152,6 +152,35 @@ const confAlerts = computed(() => {
 });
 
 const alertVisible = ref(true);
+
+const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
+const slugToKey = (slug) => {
+  const i = tabSlugs.indexOf(slug);
+  return i >= 0 ? String(i + 1) : '1';
+};
+const keyToSlug = (key) => tabSlugs[Number(key) - 1] || tabSlugs[0];
+
+const activeTabKey = ref(slugToKey(window.location.hash.slice(1)));
+
+function onTabChange(key) {
+  activeTabKey.value = key;
+  const slug = keyToSlug(key);
+  if (window.location.hash !== `#${slug}`) {
+    history.replaceState(null, '', `#${slug}`);
+  }
+}
+
+function syncTabFromHash() {
+  activeTabKey.value = slugToKey(window.location.hash.slice(1));
+}
+
+onMounted(() => {
+  window.addEventListener('hashchange', syncTabFromHash);
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener('hashchange', syncTabFromHash);
+});
 </script>
 
 <template>
@@ -199,7 +228,7 @@ const alertVisible = ref(true);
                 </a-col>
 
                 <a-col :span="24">
-                  <a-tabs default-active-key="1">
+                  <a-tabs :active-key="activeTabKey" @change="onTabChange">
                     <a-tab-pane key="1" class="tab-pane">
                       <template #tab>
                         <SettingOutlined />

+ 8 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.vue

@@ -112,6 +112,14 @@ function normalizeSubPath() {
         </template>
       </SettingListItem>
 
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subEmailInRemark') }}</template>
+        <template #description>{{ t('pages.settings.subEmailInRemarkDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.subEmailInRemark" />
+        </template>
+      </SettingListItem>
+
       <a-divider>{{ t('pages.settings.subTitle') }}</a-divider>
 
       <SettingListItem paddings="small">

+ 41 - 17
frontend/src/pages/xray/OutboundFormModal.vue

@@ -80,8 +80,17 @@ watch(() => props.open, (next) => {
   primeAdvancedJson();
 });
 
-watch(activeKey, (key) => {
-  if (key === '2') primeAdvancedJson();
+let isRevertingTab = false;
+watch(activeKey, (key, prev) => {
+  if (isRevertingTab) { isRevertingTab = false; return; }
+  if (key === '2') {
+    primeAdvancedJson();
+  } else if (key === '1' && prev === '2') {
+    if (!applyAdvancedJsonToForm()) {
+      isRevertingTab = true;
+      activeKey.value = '2';
+    }
+  }
 });
 
 function primeAdvancedJson() {
@@ -93,6 +102,33 @@ function primeAdvancedJson() {
   }
 }
 
+function applyAdvancedJsonToForm() {
+  const raw = advancedJson.value.trim();
+  if (!raw) return true;
+  let currentJson = '';
+  try {
+    currentJson = JSON.stringify(outbound.value?.toJson() ?? {}, null, 2);
+  } catch (_e) { /* fall through */ }
+  if (raw === currentJson.trim()) return true;
+  let parsed;
+  try {
+    parsed = JSON.parse(raw);
+  } catch (e) {
+    message.error(`JSON: ${e.message}`);
+    return false;
+  }
+  try {
+    const fallbackTag = outbound.value?.tag;
+    const next = Outbound.fromJson(parsed);
+    if (!next.tag && fallbackTag) next.tag = fallbackTag;
+    outbound.value = next;
+    return true;
+  } catch (e) {
+    message.error(`JSON: ${e.message}`);
+    return false;
+  }
+}
+
 function close() { emit('update:open', false); }
 
 function onProtocolChange(next) {
@@ -131,27 +167,15 @@ const tagHelp = computed(() => {
 // ============== Submit ==============
 function onOk() {
   if (!outbound.value) return;
+  if (activeKey.value === '2' && !applyAdvancedJsonToForm()) return;
   if (!outbound.value.tag?.trim()) {
-    message.error(t('somethingWentWrong'));
+    message.error('Tag is required');
     return;
   }
   if (duplicateTag.value) {
-    message.error(t('somethingWentWrong'));
+    message.error('Tag already used by another outbound');
     return;
   }
-  // If user spent time in the JSON tab, prefer that body — round-trip
-  // it through Outbound.fromJson so the wire shape stays consistent.
-  if (activeKey.value === '2' && advancedJson.value.trim()) {
-    try {
-      const parsed = JSON.parse(advancedJson.value);
-      const built = Outbound.fromJson(parsed);
-      emit('confirm', built.toJson());
-      return;
-    } catch (e) {
-      message.error(`JSON: ${e.message}`);
-      return;
-    }
-  }
   emit('confirm', outbound.value.toJson());
 }
 

+ 334 - 21
frontend/src/pages/xray/RoutingTab.vue

@@ -10,6 +10,7 @@ import {
   ClusterOutlined,
   ArrowUpOutlined,
   ArrowDownOutlined,
+  HolderOutlined,
 } from '@ant-design/icons-vue';
 import { Modal } from 'ant-design-vue';
 
@@ -22,9 +23,11 @@ const { t } = useI18n();
 // "lead value + N more" pill per criterion (matches the legacy pill
 // layout); full lists surface via tooltip on hover.
 //
-// Reorder uses up/down buttons in the action menu rather than the
-// jQuery-Sortable drag handle the legacy panel used — same effect,
-// no extra dep. The mobile column layout drops source/network/
+// Reorder via Pointer Events on the grip icon — unified mouse +
+// touch + pen path so the same code works on desktop and mobile
+// (HTML5 drag doesn't fire from touch on iOS Safari, hence the
+// switch). Up/down buttons in the action menu stay as a keyboard
+// fallback. The mobile column layout drops source/network/
 // destination criteria for readability.
 
 const props = defineProps({
@@ -162,6 +165,58 @@ function moveDown(idx) {
   [rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
 }
 
+const draggedIndex = ref(null);
+const dropTargetIndex = ref(null);
+let dragStartY = 0;
+let dragMoved = false;
+
+function onHandlePointerDown(idx, ev) {
+  if (ev.button != null && ev.button !== 0) return;
+  ev.preventDefault();
+  draggedIndex.value = idx;
+  dropTargetIndex.value = idx;
+  dragStartY = ev.clientY;
+  dragMoved = false;
+  document.addEventListener('pointermove', onDragPointerMove);
+  document.addEventListener('pointerup', onDragPointerUp);
+  document.addEventListener('pointercancel', onDragPointerUp);
+}
+
+function onDragPointerMove(ev) {
+  if (draggedIndex.value == null) return;
+  if (!dragMoved && Math.abs(ev.clientY - dragStartY) < 5) return;
+  dragMoved = true;
+  const el = document.elementFromPoint(ev.clientX, ev.clientY);
+  if (!el) return;
+  const tr = el.closest('tr[data-row-key]');
+  if (!tr) return;
+  const idx = Number(tr.getAttribute('data-row-key'));
+  if (Number.isFinite(idx)) dropTargetIndex.value = idx;
+}
+
+function onDragPointerUp() {
+  document.removeEventListener('pointermove', onDragPointerMove);
+  document.removeEventListener('pointerup', onDragPointerUp);
+  document.removeEventListener('pointercancel', onDragPointerUp);
+  const from = draggedIndex.value;
+  const to = dropTargetIndex.value;
+  draggedIndex.value = null;
+  dropTargetIndex.value = null;
+  if (!dragMoved || from == null || to == null || from === to) return;
+  const rules = props.templateSettings.routing.rules;
+  const [moved] = rules.splice(from, 1);
+  rules.splice(to, 0, moved);
+}
+
+function rowProps(_record, index) {
+  const classes = [];
+  if (draggedIndex.value === index) classes.push('row-dragging');
+  if (dropTargetIndex.value === index && draggedIndex.value !== index) {
+    classes.push(index > draggedIndex.value ? 'drop-after' : 'drop-before');
+  }
+  return { class: classes.join(' ') };
+}
+
 // === Columns =========================================================
 // Computed so titles re-render after a locale swap.
 const desktopColumns = computed(() => [
@@ -170,14 +225,31 @@ const desktopColumns = computed(() => [
   { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
   { title: 'Destination', align: 'left', key: 'destination' },
   { title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
-  { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' },
+  { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'outbound' },
+  { title: t('pages.xray.Balancers'), align: 'left', width: 150, key: 'balancer' },
 ]);
-const mobileColumns = computed(() => [
-  { title: '#', align: 'center', width: 70, key: 'action' },
-  { title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
-  { title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
-]);
-const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
+const columns = computed(() => desktopColumns.value);
+
+function ruleCriteriaChips(rule) {
+  const chips = [];
+  if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
+  if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
+  if (rule.port) chips.push({ label: 'Port', value: rule.port });
+  if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
+  if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
+  if (rule.network) chips.push({ label: 'L4', value: rule.network });
+  if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
+  if (rule.user) chips.push({ label: 'User', value: rule.user });
+  if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
+  return chips;
+}
+
+function chipPreview(value) {
+  const parts = csv(value);
+  if (parts.length === 0) return '';
+  if (parts.length === 1) return parts[0];
+  return `${parts[0]} +${parts.length - 1}`;
+}
 </script>
 
 <template>
@@ -189,12 +261,84 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
       {{ t('pages.xray.Routings') }}
     </a-button>
 
-    <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
-      :scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
+    <!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
+         phone (~520px of columns alone), so render each rule as a
+         compact card with the routing summary + criteria chips. -->
+    <div v-if="isMobile" class="rule-list">
+      <div v-for="(rule, index) in rows" :key="rule.key" class="rule-card" :class="{
+        'row-dragging': draggedIndex === index,
+        'drop-before': dropTargetIndex === index && draggedIndex != null && index < draggedIndex,
+        'drop-after': dropTargetIndex === index && draggedIndex != null && index > draggedIndex,
+      }" :data-row-key="index">
+        <div class="rule-card-head">
+          <HolderOutlined class="drag-handle" @pointerdown="onHandlePointerDown(index, $event)" />
+          <span class="rule-number">#{{ index + 1 }}</span>
+          <a-dropdown :trigger="['click']">
+            <a-button shape="circle" size="small">
+              <MoreOutlined />
+            </a-button>
+            <template #overlay>
+              <a-menu>
+                <a-menu-item @click="openEdit(index)">
+                  <EditOutlined /> {{ t('edit') }}
+                </a-menu-item>
+                <a-menu-item :disabled="index === 0" @click="moveUp(index)">
+                  <ArrowUpOutlined />
+                </a-menu-item>
+                <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
+                  <ArrowDownOutlined />
+                </a-menu-item>
+                <a-menu-item class="danger" @click="confirmDelete(index)">
+                  <DeleteOutlined /> {{ t('delete') }}
+                </a-menu-item>
+              </a-menu>
+            </template>
+          </a-dropdown>
+        </div>
+
+        <div class="rule-flow">
+          <div class="flow-side">
+            <span class="flow-label">{{ t('pages.xray.Inbounds') }}</span>
+            <a-tag v-if="rule.inboundTag" color="blue" class="flow-tag">
+              {{ chipPreview(rule.inboundTag) }}
+            </a-tag>
+            <span v-else class="criterion-empty">any</span>
+          </div>
+          <span class="flow-arrow">→</span>
+          <div class="flow-side flow-side-target">
+            <span class="flow-label">{{
+              rule.balancerTag ? (t('pages.xray.balancer') || 'Balancer') : t('pages.xray.Outbounds')
+            }}</span>
+            <a-tag v-if="rule.outboundTag" color="green" class="flow-tag">
+              <ExportOutlined /> {{ rule.outboundTag }}
+            </a-tag>
+            <a-tag v-else-if="rule.balancerTag" color="purple" class="flow-tag">
+              <ClusterOutlined /> {{ rule.balancerTag }}
+            </a-tag>
+            <span v-else class="criterion-empty">—</span>
+          </div>
+        </div>
+
+        <div v-if="ruleCriteriaChips(rule).length" class="rule-criteria">
+          <a-tooltip v-for="chip in ruleCriteriaChips(rule)" :key="chip.label" :title="`${chip.label}: ${chip.value}`">
+            <span class="criterion-chip">
+              <span class="criterion-chip-label">{{ chip.label }}</span>
+              <span class="criterion-chip-value">{{ chipPreview(chip.value) }}</span>
+            </span>
+          </a-tooltip>
+        </div>
+      </div>
+      <div v-if="!rows.length" class="rule-empty">—</div>
+    </div>
+
+    <a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
+      :scroll="{ x: 1150 }" size="small" class="routing-table" :custom-row="rowProps">
       <template #bodyCell="{ column, record, index }">
         <!-- ============== # / actions ============== -->
         <template v-if="column.key === 'action'">
           <div class="action-cell">
+            <HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
+              @pointerdown="onHandlePointerDown(index, $event)" />
             <span class="row-index">{{ index + 1 }}</span>
             <a-dropdown :trigger="['click']">
               <a-button shape="circle" size="small">
@@ -228,7 +372,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
                 <span class="criterion-label">IP</span>
                 <span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
                 <span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
-                  }}</span>
+                }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
@@ -259,7 +403,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
                 <span class="criterion-label">L4</span>
                 <span class="criterion-value">{{ csv(record.network)[0] }}</span>
                 <span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
-                  }}</span>
+                }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
@@ -267,7 +411,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
                 <span class="criterion-label">Protocol</span>
                 <span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
                 <span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
-                  }}</span>
+                }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
@@ -295,7 +439,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
                 <span class="criterion-label">Domain</span>
                 <span class="criterion-value">{{ csv(record.domain)[0] }}</span>
                 <span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
-                  }}</span>
+                }}</span>
               </span>
             </a-tooltip>
             <a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
@@ -303,7 +447,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
                 <span class="criterion-label">Port</span>
                 <span class="criterion-value">{{ csv(record.port)[0] }}</span>
                 <span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
-                  }}</span>
+                }}</span>
               </span>
             </a-tooltip>
             <span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
@@ -326,25 +470,32 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
                 <span class="criterion-label">User</span>
                 <span class="criterion-value">{{ csv(record.user)[0] }}</span>
                 <span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
-                  }}</span>
+                }}</span>
               </span>
             </a-tooltip>
             <span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
           </div>
         </template>
 
-        <!-- ============== Outbound / balancer target ============== -->
-        <template v-else-if="column.key === 'target'">
+        <!-- ============== Outbound ============== -->
+        <template v-else-if="column.key === 'outbound'">
           <div class="target-cell">
             <div v-if="record.outboundTag" class="target-row">
               <ExportOutlined class="target-icon" />
               <a-tag color="green">{{ record.outboundTag }}</a-tag>
             </div>
+            <span v-else class="criterion-empty">—</span>
+          </div>
+        </template>
+
+        <!-- ============== Balancer ============== -->
+        <template v-else-if="column.key === 'balancer'">
+          <div class="target-cell">
             <div v-if="record.balancerTag" class="target-row">
               <ClusterOutlined class="target-icon" />
               <a-tag color="purple">{{ record.balancerTag }}</a-tag>
             </div>
-            <span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
+            <span v-else class="criterion-empty">—</span>
           </div>
         </template>
       </template>
@@ -362,6 +513,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
   gap: 6px;
 }
 
+.drag-handle {
+  cursor: grab;
+  opacity: 0.35;
+  font-size: 14px;
+  padding: 4px;
+  margin: -4px;
+  touch-action: none;
+  transition: opacity 0.15s;
+}
+
+.drag-handle:hover {
+  opacity: 0.8;
+}
+
+.drag-handle:active {
+  cursor: grabbing;
+}
+
+:deep(.row-dragging) {
+  opacity: 0.4;
+}
+
+:deep(.drop-before > td) {
+  box-shadow: inset 0 2px 0 0 #1677ff;
+}
+
+:deep(.drop-after > td) {
+  box-shadow: inset 0 -2px 0 0 #1677ff;
+}
+
 .row-index {
   font-weight: 500;
   opacity: 0.7;
@@ -429,4 +610,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
 .danger {
   color: #ff4d4f;
 }
+
+/* === Mobile card list ====================================== */
+.rule-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.rule-card {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 10px 12px;
+  background: var(--bg-card, #fff);
+  border: 1px solid rgba(128, 128, 128, 0.15);
+  border-radius: 8px;
+  transition: opacity 0.15s, box-shadow 0.15s;
+}
+
+.rule-card.row-dragging {
+  opacity: 0.4;
+}
+
+.rule-card.drop-before {
+  box-shadow: inset 0 2px 0 0 #1677ff;
+}
+
+.rule-card.drop-after {
+  box-shadow: inset 0 -2px 0 0 #1677ff;
+}
+
+.rule-card-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.rule-number {
+  font-weight: 600;
+  font-size: 13px;
+  opacity: 0.75;
+  flex: 1;
+}
+
+.rule-flow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.flow-side {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.flow-side-target {
+  align-items: flex-end;
+  text-align: right;
+}
+
+.flow-label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  opacity: 0.55;
+}
+
+.flow-tag {
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin: 0;
+}
+
+.flow-arrow {
+  font-size: 16px;
+  opacity: 0.45;
+  flex-shrink: 0;
+}
+
+.rule-criteria {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  padding-top: 6px;
+  border-top: 1px dashed rgba(128, 128, 128, 0.2);
+}
+
+.criterion-chip {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 4px;
+  padding: 1px 6px;
+  font-size: 11px;
+  background: rgba(128, 128, 128, 0.08);
+  border-radius: 4px;
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.criterion-chip-label {
+  font-size: 9px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  opacity: 0.6;
+}
+
+.criterion-chip-value {
+  font-weight: 500;
+}
+
+.rule-empty {
+  padding: 24px;
+  text-align: center;
+  opacity: 0.4;
+}
+
+:global(body.dark) .rule-card {
+  background: rgba(255, 255, 255, 0.04);
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
+:global(body.dark) .criterion-chip {
+  background: rgba(255, 255, 255, 0.06);
+}
 </style>

+ 23 - 3
frontend/src/pages/xray/WarpModal.vue

@@ -27,6 +27,7 @@ const loading = ref(false);
 const warpData = ref(null);
 const warpConfig = ref(null);
 const warpPlus = ref('');
+const licenseError = ref('');
 // Held in memory so the parent's add/reset handlers receive the same
 // object the modal computed from getConfig().
 const stagedOutbound = ref(null);
@@ -41,6 +42,7 @@ watch(() => props.open, (next) => {
   if (!next) return;
   warpConfig.value = null;
   stagedOutbound.value = null;
+  licenseError.value = '';
   fetchData();
 });
 
@@ -89,12 +91,15 @@ async function getConfig() {
 async function updateLicense() {
   if (warpPlus.value.length < 26) return;
   loading.value = true;
+  licenseError.value = '';
   try {
     const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
     if (msg?.success) {
       warpData.value = JSON.parse(msg.obj);
       warpConfig.value = null;
       warpPlus.value = '';
+    } else {
+      licenseError.value = msg?.msg || 'Failed to set WARP license.';
     }
   } finally {
     loading.value = false;
@@ -233,9 +238,12 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
         <a-collapse-panel header="WARP / WARP+ license key">
           <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
             <a-form-item label="Key">
-              <a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" />
-              <a-button type="primary" class="mt-8" :disabled="warpPlus.length < 26" :loading="loading"
-                @click="updateLicense">Update</a-button>
+              <a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" @update:value="licenseError = ''" />
+              <div class="license-actions mt-8">
+                <a-button type="primary" :disabled="warpPlus.length < 26" :loading="loading"
+                  @click="updateLicense">Update</a-button>
+                <a-alert v-if="licenseError" :message="licenseError" type="error" show-icon class="license-error" />
+              </div>
             </a-form-item>
           </a-form>
         </a-collapse-panel>
@@ -358,4 +366,16 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
 .ml-8 {
   margin-left: 8px;
 }
+
+.license-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.license-error {
+  flex: 1;
+  min-width: 0;
+}
 </style>

+ 48 - 6
frontend/src/pages/xray/XrayPage.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, ref } from 'vue';
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { Modal, message } from 'ant-design-vue';
 import {
@@ -186,9 +186,6 @@ function onRemoveRoutingRules({ prefix }) {
   );
 }
 
-// `message` is used by some of the in-progress UX flows (kept around
-// because future provisioning errors will surface through it).
-void message;
 const { isMobile } = useMediaQuery();
 
 const basePath = window.X_UI_BASE_PATH || '';
@@ -208,6 +205,51 @@ function confirmRestart() {
     onOk: () => restartXray(),
   });
 }
+
+const tabKeys = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
+const slugByKey = {
+  'tpl-basic': 'basic',
+  'tpl-routing': 'routing',
+  'tpl-outbound': 'outbound',
+  'tpl-balancer': 'balancer',
+  'tpl-dns': 'dns',
+  'tpl-advanced': 'advanced',
+};
+const keyBySlug = Object.fromEntries(Object.entries(slugByKey).map(([k, v]) => [v, k]));
+
+const activeTabKey = ref(keyBySlug[window.location.hash.slice(1)] || tabKeys[0]);
+
+function onTabChange(key) {
+  activeTabKey.value = key;
+  const slug = slugByKey[key];
+  if (slug && window.location.hash !== `#${slug}`) {
+    history.replaceState(null, '', `#${slug}`);
+  }
+}
+
+function onSaveAll() {
+  try {
+    JSON.parse(xraySetting.value);
+  } catch (e) {
+    message.error(`Advanced JSON: ${e.message}`);
+    activeTabKey.value = 'tpl-advanced';
+    return;
+  }
+  saveAll();
+}
+
+function syncTabFromHash() {
+  const key = keyBySlug[window.location.hash.slice(1)];
+  if (key) activeTabKey.value = key;
+}
+
+onMounted(() => {
+  window.addEventListener('hashchange', syncTabFromHash);
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener('hashchange', syncTabFromHash);
+});
 </script>
 
 <template>
@@ -234,7 +276,7 @@ function confirmRestart() {
                     <a-row class="header-row">
                       <a-col :xs="24" :sm="14" class="header-actions">
                         <a-space direction="horizontal">
-                          <a-button type="primary" :disabled="saveDisabled" @click="saveAll">
+                          <a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
                             {{ t('pages.xray.save') }}
                           </a-button>
                           <a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
@@ -259,7 +301,7 @@ function confirmRestart() {
 
                 <!-- Tabs -->
                 <a-col :span="24">
-                  <a-tabs default-active-key="tpl-basic">
+                  <a-tabs :active-key="activeTabKey" @change="onTabChange">
                     <a-tab-pane key="tpl-basic" class="tab-pane">
                       <template #tab>
                         <SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>

+ 7 - 0
frontend/src/utils/index.js

@@ -75,6 +75,13 @@ export class HttpUtil {
     }
 }
 
+export function applyDocumentTitle() {
+    const host = window.location.hostname;
+    if (!host) return;
+    const current = document.title.trim();
+    document.title = current ? `${host} - ${current}` : host;
+}
+
 export class PromiseUtil {
     static async sleep(timeout) {
         await new Promise(resolve => {

+ 1 - 1
frontend/xray.html

@@ -3,7 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>3x-ui · Xray</title>
+    <title>Xray Config</title>
   </head>
   <body>
     <div id="message"></div>

+ 1 - 0
go.mod

@@ -26,6 +26,7 @@ require (
 	golang.org/x/sys v0.44.0
 	golang.org/x/text v0.37.0
 	google.golang.org/grpc v1.81.0
+	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gorm.io/driver/sqlite v1.6.0
 	gorm.io/gorm v1.31.1
 )

+ 2 - 0
go.sum

@@ -265,6 +265,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 24 - 19
logger/logger.go

@@ -11,17 +11,25 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/config"
 	"github.com/op/go-logging"
+
+	"gopkg.in/natefinch/lumberjack.v2"
 )
 
 const (
 	maxLogBufferSize = 10240                 // Maximum log entries kept in memory
 	logFileName      = "3xui.log"            // Log file name
 	timeFormat       = "2006/01/02 15:04:05" // Log timestamp format
+
+	// On-disk rotation limits — single file capped, old segments pruned automatically.
+	maxLogFileMB    = 10 // rotate active log when larger than this
+	maxLogBackups   = 5  // rotated files retained (beyond current segment)
+	maxLogAgeDays   = 7  // remove rotated backups older than this (0 disables time-based pruning)
+	compressRotated = true
 )
 
 var (
-	logger  *logging.Logger
-	logFile *os.File
+	logger     *logging.Logger
+	fileRotate *lumberjack.Logger // nil when file backend disabled
 
 	// logBuffer maintains recent log entries in memory for web UI retrieval
 	logBuffer []struct {
@@ -81,8 +89,8 @@ func initDefaultBackend() logging.Backend {
 	return logging.NewBackendFormatter(backend, newFormatter(includeTime))
 }
 
-// initFileBackend creates the file logging backend.
-// Creates log directory and truncates log file on startup for fresh logs.
+// initFileBackend creates the file logging backend with size/age‑bounded rotation
+// so log volume cannot grow without limit on disk.
 func initFileBackend() logging.Backend {
 	logDir := config.GetLogFolder()
 	if err := os.MkdirAll(logDir, 0o750); err != nil {
@@ -91,19 +99,16 @@ func initFileBackend() logging.Backend {
 	}
 
 	logPath := filepath.Join(logDir, logFileName)
-	file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "failed to open log file %s: %v\n", logPath, err)
-		return nil
-	}
-
-	// Close previous log file if exists
-	if logFile != nil {
-		_ = logFile.Close()
+	fileRotate = &lumberjack.Logger{
+		Filename:   logPath,
+		MaxSize:    maxLogFileMB,
+		MaxBackups: maxLogBackups,
+		MaxAge:     maxLogAgeDays,
+		LocalTime:  true,
+		Compress:   compressRotated,
 	}
-	logFile = file
 
-	backend := logging.NewLogBackend(file, "", 0)
+	backend := logging.NewLogBackend(fileRotate, "", 0)
 	return logging.NewBackendFormatter(backend, newFormatter(true))
 }
 
@@ -116,12 +121,12 @@ func newFormatter(withTime bool) logging.Formatter {
 	return logging.MustStringFormatter(format)
 }
 
-// CloseLogger closes the log file and cleans up resources.
+// CloseLogger closes the rotating log writer and cleans up resources.
 // Should be called during application shutdown.
 func CloseLogger() {
-	if logFile != nil {
-		_ = logFile.Close()
-		logFile = nil
+	if fileRotate != nil {
+		_ = fileRotate.Close()
+		fileRotate = nil
 	}
 }
 

+ 8 - 1
sub/subService.go

@@ -28,6 +28,7 @@ type SubService struct {
 	showInfo       bool
 	remarkModel    string
 	datepicker     string
+	emailInRemark  bool
 	inboundService service.InboundService
 	settingService service.SettingService
 	// nodesByID is populated per request from the Node table so
@@ -76,6 +77,12 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 	if err != nil {
 		s.datepicker = "gregorian"
 	}
+
+	s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
+	if err != nil {
+		s.emailInRemark = true
+	}
+
 	seenEmails := make(map[string]struct{})
 	for _, inbound := range inbounds {
 		clients, err := s.inboundService.GetClients(inbound)
@@ -886,7 +893,7 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
 		'e': "",
 		'o': "",
 	}
-	if len(email) > 0 {
+	if len(email) > 0 && s.emailInRemark {
 		orders['e'] = email
 	}
 	if len(inbound.Remark) > 0 {

+ 80 - 0
util/netsafe/netsafe.go

@@ -0,0 +1,80 @@
+package netsafe
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"regexp"
+	"strings"
+	"time"
+)
+
+func IsBlockedIP(ip net.IP) bool {
+	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
+		ip.IsLinkLocalMulticast() || ip.IsUnspecified()
+}
+
+type allowPrivateCtxKey struct{}
+
+func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context {
+	return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
+}
+
+func AllowPrivateFromContext(ctx context.Context) bool {
+	v, _ := ctx.Value(allowPrivateCtxKey{}).(bool)
+	return v
+}
+
+var defaultDialer = &net.Dialer{Timeout: 10 * time.Second}
+
+func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
+	host, port, err := net.SplitHostPort(addr)
+	if err != nil {
+		return nil, err
+	}
+	allowPrivate := AllowPrivateFromContext(ctx)
+	var ips []net.IPAddr
+	if ip := net.ParseIP(host); ip != nil {
+		ips = []net.IPAddr{{IP: ip}}
+	} else {
+		ips, err = net.DefaultResolver.LookupIPAddr(ctx, host)
+		if err != nil {
+			return nil, err
+		}
+	}
+	var lastErr error
+	for _, ipAddr := range ips {
+		if !allowPrivate && IsBlockedIP(ipAddr.IP) {
+			lastErr = fmt.Errorf("blocked private/internal address %s", ipAddr.IP)
+			continue
+		}
+		conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
+		if derr == nil {
+			return conn, nil
+		}
+		lastErr = derr
+	}
+	if lastErr == nil {
+		lastErr = fmt.Errorf("no usable address for %s", host)
+	}
+	return nil, lastErr
+}
+
+var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
+
+func NormalizeHost(addr string) (string, error) {
+	addr = strings.TrimSpace(addr)
+	if addr == "" {
+		return "", fmt.Errorf("address is required")
+	}
+	if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
+		addr = addr[1 : len(addr)-1]
+	}
+	if ip := net.ParseIP(addr); ip != nil {
+		return ip.String(), nil
+	}
+	if len(addr) > 253 || !hostnamePattern.MatchString(addr) {
+		return "", fmt.Errorf("invalid host %q", addr)
+	}
+	return addr, nil
+}

+ 2 - 1
web/controller/api.go

@@ -19,6 +19,7 @@ type APIController struct {
 	nodeController    *NodeController
 	settingService    service.SettingService
 	userService       service.UserService
+	apiTokenService   service.ApiTokenService
 	Tgbot             service.Tgbot
 }
 
@@ -33,7 +34,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
 	auth := c.GetHeader("Authorization")
 	if strings.HasPrefix(auth, "Bearer ") {
 		tok := strings.TrimPrefix(auth, "Bearer ")
-		if a.settingService.MatchApiToken(tok) {
+		if a.apiTokenService.Match(tok) {
 			if u, err := a.userService.GetFirstUser(); err == nil {
 				session.SetAPIAuthUser(c, u)
 			}

+ 19 - 0
web/controller/inbound.go

@@ -77,6 +77,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.POST("/:id/copyClients", a.copyInboundClients)
 	g.POST("/:id/delClient/:clientId", a.delInboundClient)
 	g.POST("/updateClient/:clientId", a.updateInboundClient)
+	g.POST("/:id/resetTraffic", a.resetInboundTraffic)
 	g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
@@ -441,6 +442,24 @@ func (a *InboundController) resetClientTraffic(c *gin.Context) {
 	}
 }
 
+// resetInboundTraffic resets traffic counters for a specific inbound.
+func (a *InboundController) resetInboundTraffic(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
+		return
+	}
+
+	err = a.inboundService.ResetInboundTraffic(id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	} else {
+		a.xrayService.SetToNeedRestart()
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
+}
+
 // resetAllTraffics resets all traffic counters across all inbounds.
 func (a *InboundController) resetAllTraffics(c *gin.Context) {
 	err := a.inboundService.ResetAllTraffics()

+ 0 - 1
web/controller/index.go

@@ -135,7 +135,6 @@ func loginFailureReason(err error) string {
 	return "invalid credentials"
 }
 
-// logout clears the session. The SPA performs the navigation client-side.
 func (a *IndexController) logout(c *gin.Context) {
 	user := session.GetLoginUser(c)
 	if user != nil {

+ 57 - 17
web/controller/setting.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"errors"
+	"strconv"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/util/crypto"
@@ -22,9 +23,10 @@ type updateUserForm struct {
 
 // SettingController handles settings and user management operations.
 type SettingController struct {
-	settingService service.SettingService
-	userService    service.UserService
-	panelService   service.PanelService
+	settingService  service.SettingService
+	userService     service.UserService
+	panelService    service.PanelService
+	apiTokenService service.ApiTokenService
 }
 
 // NewSettingController creates a new SettingController and initializes its routes.
@@ -44,8 +46,10 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/updateUser", a.updateUser)
 	g.POST("/restartPanel", a.restartPanel)
 	g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
-	g.GET("/getApiToken", a.getApiToken)
-	g.POST("/regenerateApiToken", a.regenerateApiToken)
+	g.GET("/apiTokens", a.listApiTokens)
+	g.POST("/apiTokens/create", a.createApiToken)
+	g.POST("/apiTokens/delete/:id", a.deleteApiToken)
+	g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
 }
 
 // getAllSetting retrieves all current settings.
@@ -76,7 +80,13 @@ func (a *SettingController) updateSetting(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 		return
 	}
+	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
 	err = a.settingService.UpdateAllSetting(allSetting)
+	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
+		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
+			err = bumpErr
+		}
+	}
 	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 }
 
@@ -124,26 +134,56 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
 	jsonObj(c, defaultJsonConfig, nil)
 }
 
-// getApiToken returns the panel's API token used by remote central
-// panels to authenticate as Bearer tokens. The token is auto-generated
-// on first read so existing installs upgrade transparently.
-func (a *SettingController) getApiToken(c *gin.Context) {
-	tok, err := a.settingService.GetApiToken()
+type apiTokenCreateForm struct {
+	Name string `json:"name" form:"name"`
+}
+
+type apiTokenEnabledForm struct {
+	Enabled bool `json:"enabled" form:"enabled"`
+}
+
+func (a *SettingController) listApiTokens(c *gin.Context) {
+	rows, err := a.apiTokenService.List()
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
 		return
 	}
-	jsonObj(c, tok, nil)
+	jsonObj(c, rows, nil)
 }
 
-// regenerateApiToken rotates the API token. Any central panel that had
-// the old value cached will start failing heartbeats until it is updated
-// with the new token — that's intentional, it's the whole point of rotation.
-func (a *SettingController) regenerateApiToken(c *gin.Context) {
-	tok, err := a.settingService.RegenerateApiToken()
+func (a *SettingController) createApiToken(c *gin.Context) {
+	form := &apiTokenCreateForm{}
+	if err := c.ShouldBind(form); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+		return
+	}
+	row, err := a.apiTokenService.Create(form.Name)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 		return
 	}
-	jsonObj(c, tok, nil)
+	jsonObj(c, row, nil)
+}
+
+func (a *SettingController) deleteApiToken(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
+}
+
+func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+		return
+	}
+	form := &apiTokenEnabledForm{}
+	if bindErr := c.ShouldBind(form); bindErr != nil {
+		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
 }

+ 1 - 0
web/entity/entity.go

@@ -75,6 +75,7 @@ type AllSetting struct {
 	RestartXrayOnClientDisable  bool   `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`   // Restart Xray when clients are auto-disabled by expiry/traffic limit
 	SubEncrypt                  bool   `json:"subEncrypt" form:"subEncrypt"`                                   // Encrypt subscription responses
 	SubShowInfo                 bool   `json:"subShowInfo" form:"subShowInfo"`                                 // Show client information in subscriptions
+	SubEmailInRemark            bool   `json:"subEmailInRemark" form:"subEmailInRemark"`                       // Include email in subscription remark/name
 	SubURI                      string `json:"subURI" form:"subURI"`                                           // Subscription server URI
 	SubJsonPath                 string `json:"subJsonPath" form:"subJsonPath"`                                 // Path for JSON subscription endpoint
 	SubJsonURI                  string `json:"subJsonURI" form:"subJsonURI"`                                   // JSON subscription server URI

+ 72 - 8
web/runtime/remote.go

@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -16,6 +17,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/netsafe"
 )
 
 const remoteHTTPTimeout = 10 * time.Second
@@ -25,6 +27,7 @@ var remoteHTTPClient = &http.Client{
 		MaxIdleConns:        64,
 		MaxIdleConnsPerHost: 4,
 		IdleConnTimeout:     60 * time.Second,
+		DialContext:         netsafe.SSRFGuardedDialContext,
 	},
 }
 
@@ -50,7 +53,18 @@ func NewRemote(n *model.Node) *Remote {
 
 func (r *Remote) Name() string { return "node:" + r.node.Name }
 
-func (r *Remote) baseURL() string {
+func (r *Remote) baseURL() (string, error) {
+	addr, err := netsafe.NormalizeHost(r.node.Address)
+	if err != nil {
+		return "", err
+	}
+	scheme := r.node.Scheme
+	if scheme != "http" && scheme != "https" {
+		scheme = "https"
+	}
+	if r.node.Port <= 0 || r.node.Port > 65535 {
+		return "", fmt.Errorf("invalid node port %d", r.node.Port)
+	}
 	bp := r.node.BasePath
 	if bp == "" {
 		bp = "/"
@@ -58,7 +72,12 @@ func (r *Remote) baseURL() string {
 	if !strings.HasSuffix(bp, "/") {
 		bp += "/"
 	}
-	return fmt.Sprintf("%s://%s:%d%s", r.node.Scheme, r.node.Address, r.node.Port, bp)
+	u := &url.URL{
+		Scheme: scheme,
+		Host:   net.JoinHostPort(addr, strconv.Itoa(r.node.Port)),
+		Path:   bp,
+	}
+	return u.String(), nil
 }
 
 func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
@@ -66,7 +85,11 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 		return nil, errors.New("node has no API token configured")
 	}
 
-	target := r.baseURL() + strings.TrimPrefix(path, "/")
+	base, err := r.baseURL()
+	if err != nil {
+		return nil, err
+	}
+	target := base + strings.TrimPrefix(path, "/")
 
 	var (
 		reqBody     io.Reader
@@ -78,15 +101,15 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 		reqBody = strings.NewReader(b.Encode())
 		contentType = "application/x-www-form-urlencoded"
 	default:
-		buf, err := json.Marshal(b)
-		if err != nil {
-			return nil, fmt.Errorf("marshal body: %w", err)
+		buf, jerr := json.Marshal(b)
+		if jerr != nil {
+			return nil, fmt.Errorf("marshal body: %w", jerr)
 		}
 		reqBody = bytes.NewReader(buf)
 		contentType = "application/json"
 	}
 
-	cctx, cancel := context.WithTimeout(ctx, remoteHTTPTimeout)
+	cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
 	defer cancel()
 	req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
 	if err != nil {
@@ -311,7 +334,7 @@ func wireInbound(ib *model.Inbound) url.Values {
 	v.Set("port", strconv.Itoa(ib.Port))
 	v.Set("protocol", string(ib.Protocol))
 	v.Set("settings", ib.Settings)
-	v.Set("streamSettings", ib.StreamSettings)
+	v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
 	v.Set("tag", ib.Tag)
 	v.Set("sniffing", ib.Sniffing)
 	if ib.TrafficReset != "" {
@@ -319,3 +342,44 @@ func wireInbound(ib *model.Inbound) url.Values {
 	}
 	return v
 }
+
+// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
+// from the StreamSettings before sending to a remote node. File paths
+// (certificateFile / keyFile) are local to the main panel's filesystem
+// and will cause Xray on the remote node to crash if they don't exist there.
+// Inline certificate content (certificate / key) is kept intact.
+func sanitizeStreamSettingsForRemote(streamSettings string) string {
+	if streamSettings == "" {
+		return streamSettings
+	}
+
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return streamSettings
+	}
+
+	tlsSettings, ok := stream["tlsSettings"].(map[string]any)
+	if !ok {
+		return streamSettings
+	}
+
+	certificates, ok := tlsSettings["certificates"].([]any)
+	if !ok {
+		return streamSettings
+	}
+
+	for _, cert := range certificates {
+		c, ok := cert.(map[string]any)
+		if !ok {
+			continue
+		}
+		delete(c, "certificateFile")
+		delete(c, "keyFile")
+	}
+
+	out, err := json.Marshal(stream)
+	if err != nil {
+		return streamSettings
+	}
+	return string(out)
+}

+ 119 - 0
web/service/api_token.go

@@ -0,0 +1,119 @@
+package service
+
+import (
+	"crypto/subtle"
+	"errors"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/random"
+)
+
+type ApiTokenService struct{}
+
+const apiTokenLength = 48
+
+type ApiTokenView struct {
+	Id        int    `json:"id"`
+	Name      string `json:"name"`
+	Token     string `json:"token"`
+	Enabled   bool   `json:"enabled"`
+	CreatedAt int64  `json:"createdAt"`
+}
+
+func toView(t *model.ApiToken) *ApiTokenView {
+	return &ApiTokenView{
+		Id:        t.Id,
+		Name:      t.Name,
+		Token:     t.Token,
+		Enabled:   t.Enabled,
+		CreatedAt: t.CreatedAt,
+	}
+}
+
+func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
+	db := database.GetDB()
+	var rows []*model.ApiToken
+	if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	out := make([]*ApiTokenView, 0, len(rows))
+	for _, r := range rows {
+		out = append(out, toView(r))
+	}
+	return out, nil
+}
+
+func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return nil, common.NewError("token name is required")
+	}
+	if len(name) > 64 {
+		return nil, common.NewError("token name must be 64 characters or fewer")
+	}
+	db := database.GetDB()
+	var count int64
+	if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
+		return nil, err
+	}
+	if count > 0 {
+		return nil, common.NewError("a token with that name already exists")
+	}
+	row := &model.ApiToken{
+		Name:    name,
+		Token:   random.Seq(apiTokenLength),
+		Enabled: true,
+	}
+	if err := db.Create(row).Error; err != nil {
+		return nil, err
+	}
+	return toView(row), nil
+}
+
+func (s *ApiTokenService) Delete(id int) error {
+	if id <= 0 {
+		return common.NewError("invalid token id")
+	}
+	db := database.GetDB()
+	return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
+}
+
+func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
+	if id <= 0 {
+		return common.NewError("invalid token id")
+	}
+	db := database.GetDB()
+	res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
+	if res.Error != nil {
+		return res.Error
+	}
+	if res.RowsAffected == 0 {
+		return errors.New("token not found")
+	}
+	return nil
+}
+
+// Match returns true when the presented bearer token matches any enabled
+// row in api_tokens. Uses constant-time compare per row so a remote
+// attacker can't time-attack tokens byte-by-byte.
+func (s *ApiTokenService) Match(presented string) bool {
+	if presented == "" {
+		return false
+	}
+	db := database.GetDB()
+	var rows []*model.ApiToken
+	if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
+		return false
+	}
+	presentedBytes := []byte(presented)
+	matched := false
+	for _, r := range rows {
+		if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
+			matched = true
+		}
+	}
+	return matched
+}

+ 36 - 42
web/service/config.json

@@ -1,4 +1,21 @@
 {
+  "api": {
+    "services": [
+      "HandlerService",
+      "LoggerService",
+      "StatsService"
+    ],
+    "tag": "api"
+  },
+  "inbounds": [{
+    "listen": "127.0.0.1",
+    "port": 62789,
+    "protocol": "tunnel",
+    "settings": {
+      "address": "127.0.0.1"
+    },
+    "tag": "api"
+  }],
   "log": {
     "access": "none",
     "dnsLog": false,
@@ -6,39 +23,21 @@
     "loglevel": "warning",
     "maskAddress": ""
   },
-  "api": {
-    "tag": "api",
-    "services": [
-      "HandlerService",
-      "LoggerService",
-      "StatsService"
-    ]
+  "metrics": {
+    "listen": "127.0.0.1:11111",
+    "tag": "metrics_out"
   },
-  "inbounds": [
-    {
-      "tag": "api",
-      "listen": "127.0.0.1",
-      "port": 62789,
-      "protocol": "tunnel",
-      "settings": {
-        "address": "127.0.0.1"
-      }
-    }
-  ],
-  "outbounds": [
-    {
-      "tag": "direct",
+  "outbounds": [{
       "protocol": "freedom",
       "settings": {
-        "domainStrategy": "AsIs",
-        "redirect": "",
-        "noises": []
-      }
+        "domainStrategy": "AsIs"
+      },
+      "tag": "direct"
     },
     {
-      "tag": "blocked",
       "protocol": "blackhole",
-      "settings": {}
+      "settings": {},
+      "tag": "blocked"
     }
   ],
   "policy": {
@@ -57,33 +56,28 @@
   },
   "routing": {
     "domainStrategy": "AsIs",
-    "rules": [
-      {
-        "type": "field",
+    "rules": [{
         "inboundTag": [
           "api"
         ],
-        "outboundTag": "api"
+        "outboundTag": "api",
+        "type": "field"
       },
       {
-        "type": "field",
-        "outboundTag": "blocked",
         "ip": [
           "geoip:private"
-        ]
+        ],
+        "outboundTag": "blocked",
+        "type": "field"
       },
       {
-        "type": "field",
         "outboundTag": "blocked",
         "protocol": [
           "bittorrent"
-        ]
+        ],
+        "type": "field"
       }
     ]
   },
-  "stats": {},
-  "metrics": {
-    "tag": "metrics_out",
-    "listen": "127.0.0.1:11111"
-  }
-}
+  "stats": {}
+}

+ 2 - 2
web/service/custom_geo.go

@@ -18,6 +18,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/netsafe"
 )
 
 const (
@@ -164,8 +165,7 @@ func CustomGeoLocalFileNeedsRepair(path string) bool {
 }
 
 func isBlockedIP(ip net.IP) bool {
-	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
-		ip.IsLinkLocalMulticast() || ip.IsUnspecified()
+	return netsafe.IsBlockedIP(ip)
 }
 
 // checkSSRFDefault validates that the given host does not resolve to a private/internal IP.

+ 7 - 1
web/service/inbound.go

@@ -849,6 +849,9 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
 
 	// Secure client ID
 	for _, client := range clients {
+		if strings.TrimSpace(client.Email) == "" {
+			return false, common.NewError("client email is required")
+		}
 		switch oldInbound.Protocol {
 		case "trojan":
 			if client.Password == "" {
@@ -1317,8 +1320,11 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
 	if newClientId == "" || clientIndex == -1 {
 		return false, common.NewError("empty client ID")
 	}
+	if strings.TrimSpace(clients[0].Email) == "" {
+		return false, common.NewError("client email is required")
+	}
 
-	if len(clients[0].Email) > 0 && clients[0].Email != oldEmail {
+	if clients[0].Email != oldEmail {
 		existEmail, err := s.checkEmailsExistForClients(clients)
 		if err != nil {
 			return false, err

+ 30 - 6
web/service/node.go

@@ -5,7 +5,9 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net"
 	"net/http"
+	"net/url"
 	"strconv"
 	"strings"
 	"time"
@@ -13,6 +15,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/web/runtime"
 )
 
@@ -34,6 +37,7 @@ var nodeHTTPClient = &http.Client{
 		MaxIdleConns:        64,
 		MaxIdleConnsPerHost: 4,
 		IdleConnTimeout:     60 * time.Second,
+		DialContext:         netsafe.SSRFGuardedDialContext,
 	},
 }
 
@@ -69,14 +73,15 @@ func normalizeBasePath(p string) string {
 
 func (s *NodeService) normalize(n *model.Node) error {
 	n.Name = strings.TrimSpace(n.Name)
-	n.Address = strings.TrimSpace(n.Address)
 	n.ApiToken = strings.TrimSpace(n.ApiToken)
 	if n.Name == "" {
 		return common.NewError("node name is required")
 	}
-	if n.Address == "" {
-		return common.NewError("node address is required")
+	addr, err := netsafe.NormalizeHost(n.Address)
+	if err != nil {
+		return common.NewError(err.Error())
 	}
+	n.Address = addr
 	if n.Port <= 0 || n.Port > 65535 {
 		return common.NewError("node port must be 1-65535")
 	}
@@ -175,10 +180,29 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
 
 func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
 	patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
-	url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status",
-		n.Scheme, n.Address, n.Port, normalizeBasePath(n.BasePath))
 
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	addr, err := netsafe.NormalizeHost(n.Address)
+	if err != nil {
+		patch.LastError = err.Error()
+		return patch, err
+	}
+	scheme := n.Scheme
+	if scheme != "http" && scheme != "https" {
+		scheme = "https"
+	}
+	if n.Port <= 0 || n.Port > 65535 {
+		patch.LastError = "node port must be 1-65535"
+		return patch, errors.New(patch.LastError)
+	}
+	probeURL := &url.URL{
+		Scheme: scheme,
+		Host:   net.JoinHostPort(addr, strconv.Itoa(n.Port)),
+		Path:   normalizeBasePath(n.BasePath) + "panel/api/server/status",
+	}
+
+	req, err := http.NewRequestWithContext(
+		netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
+		http.MethodGet, probeURL.String(), nil)
 	if err != nil {
 		patch.LastError = err.Error()
 		return patch, err

+ 10 - 45
web/service/setting.go

@@ -1,7 +1,6 @@
 package service
 
 import (
-	"crypto/subtle"
 	_ "embed"
 	"encoding/json"
 	"errors"
@@ -71,6 +70,7 @@ var defaultValueMap = map[string]string{
 	"subUpdates":                  "12",
 	"subEncrypt":                  "true",
 	"subShowInfo":                 "true",
+	"subEmailInRemark":            "true",
 	"subURI":                      "",
 	"subJsonPath":                 "/json/",
 	"subJsonURI":                  "",
@@ -211,7 +211,10 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
 	view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
 	view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
 	view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
-	view.HasApiToken = secretConfigured(mustString(s.getString("apiToken")))
+	var apiTokenCount int64
+	if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
+		view.HasApiToken = apiTokenCount > 0
+	}
 	view.TgBotToken = ""
 	view.TwoFactorToken = ""
 	view.LdapPassword = ""
@@ -467,48 +470,6 @@ func (s *SettingService) GetSecret() ([]byte, error) {
 	return []byte(secret), err
 }
 
-// GetApiToken returns the panel's API token, lazily generating one on
-// first read so existing installs upgrade transparently. The token is
-// stored plaintext to match how the existing tg/ldap secrets are kept.
-func (s *SettingService) GetApiToken() (string, error) {
-	tok, err := s.getString("apiToken")
-	if err != nil {
-		return "", err
-	}
-	if tok == "" {
-		tok = random.Seq(48)
-		if saveErr := s.saveSetting("apiToken", tok); saveErr != nil {
-			logger.Warning("save apiToken failed:", saveErr)
-			return "", saveErr
-		}
-	}
-	return tok, nil
-}
-
-// RegenerateApiToken rotates the API token, invalidating any central
-// panel that has the old value cached.
-func (s *SettingService) RegenerateApiToken() (string, error) {
-	tok := random.Seq(48)
-	if err := s.saveSetting("apiToken", tok); err != nil {
-		return "", err
-	}
-	return tok, nil
-}
-
-// MatchApiToken returns true when the supplied bearer token matches the
-// stored API token. Uses constant-time compare so a remote attacker
-// can't time-attack the token byte-by-byte.
-func (s *SettingService) MatchApiToken(presented string) bool {
-	if presented == "" {
-		return false
-	}
-	stored, err := s.getString("apiToken")
-	if err != nil || stored == "" {
-		return false
-	}
-	return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1
-}
-
 func (s *SettingService) SetBasePath(basePath string) error {
 	if !strings.HasPrefix(basePath, "/") {
 		basePath = "/" + basePath
@@ -632,6 +593,10 @@ func (s *SettingService) GetSubShowInfo() (bool, error) {
 	return s.getBool("subShowInfo")
 }
 
+func (s *SettingService) GetSubEmailInRemark() (bool, error) {
+	return s.getBool("subEmailInRemark")
+}
+
 func (s *SettingService) GetPageSize() (int, error) {
 	return s.getInt("pageSize")
 }
@@ -877,7 +842,7 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
 
 func (s *SettingService) UpdateSecret(key string, value string) error {
 	switch key {
-	case "tgBotToken", "ldapPassword", "twoFactorToken", "apiToken":
+	case "tgBotToken", "ldapPassword", "twoFactorToken":
 		return s.saveSetting(key, strings.TrimSpace(value))
 	default:
 		return common.NewError("secret key is not replaceable:", key)

+ 2 - 1
web/service/setting_security_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
 )
 
 func setupSettingTestDB(t *testing.T) {
@@ -31,7 +32,7 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
 	if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
 		t.Fatal(err)
 	}
-	if err := s.saveSetting("apiToken", "api-secret"); err != nil {
+	if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
 		t.Fatal(err)
 	}
 

+ 8 - 0
web/service/user.go

@@ -102,6 +102,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
 	return user, nil
 }
 
+func (s *UserService) BumpLoginEpoch() error {
+	db := database.GetDB()
+	return db.Model(model.User{}).
+		Where("1 = 1").
+		Update("login_epoch", gorm.Expr("login_epoch + 1")).
+		Error
+}
+
 func (s *UserService) UpdateUser(id int, username string, password string) error {
 	db := database.GetDB()
 	hashedPassword, err := crypto.HashPasswordAsBcrypt(password)

+ 20 - 7
web/service/warp.go

@@ -152,13 +152,8 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
 	if err := json.Unmarshal(body, &response); err != nil {
 		return "", err
 	}
-	if success, _ := response["success"].(bool); !success {
-		if errorArr, ok := response["errors"].([]any); ok && len(errorArr) > 0 {
-			if errorObj, ok := errorArr[0].(map[string]any); ok {
-				return "", common.NewError(errorObj["code"], errorObj["message"])
-			}
-		}
-		return "", common.NewError("warp set license failed: unknown error")
+	if _, ok := response["id"].(string); !ok {
+		return "", common.NewErrorf("warp set license failed: unexpected response: %s", string(body))
 	}
 
 	warpData["license_key"] = license
@@ -202,8 +197,26 @@ func doWarpRequest(req *http.Request) ([]byte, error) {
 		return nil, err
 	}
 	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		if msg := parseWarpError(body); msg != "" {
+			return nil, common.NewError(msg)
+		}
 		return nil, common.NewErrorf("warp api %s %s returned status %d: %s",
 			req.Method, req.URL.Path, resp.StatusCode, string(body))
 	}
 	return body, nil
 }
+
+func parseWarpError(body []byte) string {
+	var env struct {
+		Errors []struct {
+			Message string `json:"message"`
+		} `json:"errors"`
+	}
+	if err := json.Unmarshal(body, &env); err != nil {
+		return ""
+	}
+	if len(env.Errors) == 0 || env.Errors[0].Message == "" {
+		return ""
+	}
+	return env.Errors[0].Message
+}

+ 12 - 1
web/translation/ar-EG.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل",
         "resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور",
         "resetInboundClientTrafficSuccess": "تم إعادة تعيين حركة المرور",
+        "resetInboundTrafficSuccess": "تم إعادة تعيين حركة مرور الداخل",
         "trafficGetError": "خطأ في الحصول على حركات المرور",
         "getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.",
         "getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "المحتوى اللي هيترجع من خدمة الاشتراك هيكون مشفر بـ Base64.",
       "subShowInfo": "اظهر معلومات الاستخدام",
       "subShowInfoDesc": "هيظهر الترافيك المتبقي والتاريخ في تطبيقات العملاء.",
+      "subEmailInRemark": "تضمين البريد الإلكتروني في الاسم",
+      "subEmailInRemarkDesc": "تضمين بريد العميل الإلكتروني في اسم ملف تعريف الاشتراك.",
       "subURI": "مسار البروكسي العكسي",
       "subURIDesc": "مسار URI لرابط الاشتراك عشان تستخدمه ورا البروكسي.",
       "externalTrafficInformEnable": "تنبيه الترافيك الخارجي",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.",
         "twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح",
         "twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح",
-        "twoFactorModalError": "رمز خاطئ"
+        "twoFactorModalError": "رمز خاطئ",
+        "show": "إظهار",
+        "hide": "إخفاء",
+        "apiTokenNew": "رمز جديد",
+        "apiTokenName": "الاسم",
+        "apiTokenNamePlaceholder": "مثل central-panel-a",
+        "apiTokenNameRequired": "الاسم مطلوب",
+        "apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
+        "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
       },
       "toasts": {
         "modifySettings": "تم تغيير المعلمات.",

+ 12 - 1
web/translation/en-US.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "All traffic from the client has been reset.",
         "resetAllTrafficSuccess": "All traffic has been reset.",
         "resetInboundClientTrafficSuccess": "Traffic has been reset.",
+        "resetInboundTrafficSuccess": "Inbound traffic has been reset.",
         "trafficGetError": "Error getting traffics.",
         "getNewX25519CertError": "Error while obtaining the X25519 certificate.",
         "getNewmldsa65Error": "Error while obtaining mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "The returned content of subscription service will be Base64 encoded.",
       "subShowInfo": "Show Usage Info",
       "subShowInfoDesc": "The remaining traffic and date will be displayed in the client apps.",
+      "subEmailInRemark": "Include Email in Name",
+      "subEmailInRemarkDesc": "Include the client email in the subscription profile name.",
       "subURI": "Reverse Proxy URI",
       "subURIDesc": "The URI path of the subscription URL for use behind proxies.",
       "externalTrafficInformEnable": "External Traffic Inform",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "Enter the code from the application to change administrator credentials.",
         "twoFactorModalSetSuccess": "Two-factor authentication has been successfully established",
         "twoFactorModalDeleteSuccess": "Two-factor authentication has been successfully deleted",
-        "twoFactorModalError": "Wrong code"
+        "twoFactorModalError": "Wrong code",
+        "show": "Show",
+        "hide": "Hide",
+        "apiTokenNew": "New token",
+        "apiTokenName": "Name",
+        "apiTokenNamePlaceholder": "e.g. central-panel-a",
+        "apiTokenNameRequired": "Name is required",
+        "apiTokenEmpty": "No tokens yet — create one to authenticate bots or remote panels.",
+        "apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately."
       },
       "toasts": {
         "modifySettings": "The parameters have been changed.",

+ 12 - 1
web/translation/es-ES.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado",
         "resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado",
         "resetInboundClientTrafficSuccess": "El tráfico ha sido reiniciado",
+        "resetInboundTrafficSuccess": "El tráfico de entrada ha sido reiniciado",
         "trafficGetError": "Error al obtener los tráficos",
         "getNewX25519CertError": "Error al obtener el certificado X25519.",
         "getNewmldsa65Error": "Error al obtener el certificado mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "Encriptar las configuraciones devueltas en la suscripción.",
       "subShowInfo": "Mostrar información de uso",
       "subShowInfoDesc": "Mostrar tráfico restante y fecha después del nombre de configuración.",
+      "subEmailInRemark": "Incluir Email en el nombre",
+      "subEmailInRemarkDesc": "Incluir el correo del cliente en el nombre del perfil de suscripción.",
       "subURI": "URI de proxy inverso",
       "externalTrafficInformEnable": "Informe de tráfico externo",
       "externalTrafficInformEnableDesc": "Informar a la API externa sobre cada actualización de tráfico.",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "Ingrese el código de la aplicación para cambiar las credenciales del administrador.",
         "twoFactorModalSetSuccess": "La autenticación de dos factores se ha establecido con éxito",
         "twoFactorModalDeleteSuccess": "La autenticación de dos factores se ha eliminado con éxito",
-        "twoFactorModalError": "Código incorrecto"
+        "twoFactorModalError": "Código incorrecto",
+        "show": "Mostrar",
+        "hide": "Ocultar",
+        "apiTokenNew": "Nuevo token",
+        "apiTokenName": "Nombre",
+        "apiTokenNamePlaceholder": "por ejemplo central-panel-a",
+        "apiTokenNameRequired": "El nombre es obligatorio",
+        "apiTokenEmpty": "Aún no hay tokens — crea uno para autenticar bots o paneles remotos.",
+        "apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente."
       },
       "toasts": {
         "modifySettings": "Los parámetros han sido modificados.",

+ 12 - 1
web/translation/fa-IR.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد",
         "resetAllTrafficSuccess": "تمام ترافیک‌ها بازنشانی شدند",
         "resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد",
+        "resetInboundTrafficSuccess": "ترافیک ورودی بازنشانی شد",
         "trafficGetError": "خطا در دریافت ترافیک‌ها",
         "getNewX25519CertError": "خطا در دریافت گواهی X25519.",
         "getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "کدگذاری خواهدشد Base64 محتوای برگشتی سرویس سابسکریپشن برپایه",
       "subShowInfo": "نمایش اطلاعات مصرف",
       "subShowInfoDesc": "ترافیک و زمان باقی‌مانده را در برنامه‌های کاربری نمایش می‌دهد",
+      "subEmailInRemark": "گنجاندن ایمیل در نام",
+      "subEmailInRemarkDesc": "ایمیل کاربر در نام پروفایل اشتراک گنجانده می‌شود.",
       "subURI": "پروکسی معکوس URI مسیر",
       "subURIDesc": "سابسکریپشن را برای استفاده در پشت پراکسی‌ها تغییر می‌دهد URI مسیر",
       "externalTrafficInformEnable": "اطلاع رسانی خارجی مصرف ترافیک",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامه‌های مدیر، کد را از برنامه وارد کنید.",
         "twoFactorModalSetSuccess": "احراز هویت دو مرحله‌ای با موفقیت برقرار شد",
         "twoFactorModalDeleteSuccess": "احراز هویت دو مرحله‌ای با موفقیت حذف شد",
-        "twoFactorModalError": "کد نادرست"
+        "twoFactorModalError": "کد نادرست",
+        "show": "نمایش",
+        "hide": "پنهان",
+        "apiTokenNew": "توکن جدید",
+        "apiTokenName": "نام",
+        "apiTokenNamePlaceholder": "مثلاً central-panel-a",
+        "apiTokenNameRequired": "نام الزامی است",
+        "apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت ربات‌ها یا پنل‌های راه دور یکی بسازید.",
+        "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود."
       },
       "toasts": {
         "modifySettings": "پارامترها تغییر کرده‌اند.",

+ 12 - 1
web/translation/id-ID.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset",
         "resetAllTrafficSuccess": "Semua lalu lintas telah direset",
         "resetInboundClientTrafficSuccess": "Lalu lintas telah direset",
+        "resetInboundTrafficSuccess": "Lalu lintas masuk telah direset",
         "trafficGetError": "Gagal mendapatkan data lalu lintas",
         "getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.",
         "getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "Konten yang dikembalikan dari layanan langganan akan dienkripsi Base64.",
       "subShowInfo": "Tampilkan Info Penggunaan",
       "subShowInfoDesc": "Sisa traffic dan tanggal akan ditampilkan di aplikasi klien.",
+      "subEmailInRemark": "Sertakan Email dalam Nama",
+      "subEmailInRemarkDesc": "Sertakan email klien dalam nama profil langganan.",
       "subURI": "URI Proxy Terbalik",
       "subURIDesc": "Path URI dari URL langganan untuk digunakan di belakang proxy.",
       "externalTrafficInformEnable": "Informasikan API eksternal pada setiap pembaruan lalu lintas.",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "Masukkan kode dari aplikasi untuk mengubah kredensial administrator.",
         "twoFactorModalSetSuccess": "Autentikasi dua faktor telah berhasil dibuat",
         "twoFactorModalDeleteSuccess": "Autentikasi dua faktor telah berhasil dihapus",
-        "twoFactorModalError": "Kode salah"
+        "twoFactorModalError": "Kode salah",
+        "show": "Tampilkan",
+        "hide": "Sembunyikan",
+        "apiTokenNew": "Token baru",
+        "apiTokenName": "Nama",
+        "apiTokenNamePlaceholder": "misalnya central-panel-a",
+        "apiTokenNameRequired": "Nama wajib diisi",
+        "apiTokenEmpty": "Belum ada token — buat satu untuk mengautentikasi bot atau panel jarak jauh.",
+        "apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera."
       },
       "toasts": {
         "modifySettings": "Parameter telah diubah.",

+ 12 - 1
web/translation/ja-JP.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました",
         "resetAllTrafficSuccess": "すべてのトラフィックがリセットされました",
         "resetInboundClientTrafficSuccess": "トラフィックがリセットされました",
+        "resetInboundTrafficSuccess": "受信トラフィックがリセットされました",
         "trafficGetError": "トラフィックの取得中にエラーが発生しました",
         "getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。",
         "getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "サブスクリプションサービスが返す内容をBase64エンコードする",
       "subShowInfo": "利用情報を表示",
       "subShowInfoDesc": "クライアントアプリで残りのトラフィックと日付情報を表示する",
+      "subEmailInRemark": "名前にメールを含める",
+      "subEmailInRemarkDesc": "サブスクリプションプロファイル名にクライアントのメールアドレスを含めます。",
       "subURI": "リバースプロキシURI",
       "subURIDesc": "プロキシ後ろのサブスクリプションURLのURIパスに使用する",
       "externalTrafficInformEnable": "外部トラフィック情報",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。",
         "twoFactorModalSetSuccess": "二要素認証が正常に設定されました",
         "twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました",
-        "twoFactorModalError": "コードが間違っています"
+        "twoFactorModalError": "コードが間違っています",
+        "show": "表示",
+        "hide": "非表示",
+        "apiTokenNew": "新規トークン",
+        "apiTokenName": "名前",
+        "apiTokenNamePlaceholder": "例: central-panel-a",
+        "apiTokenNameRequired": "名前は必須です",
+        "apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
+        "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
       },
       "toasts": {
         "modifySettings": "パラメーターが変更されました。",

+ 12 - 1
web/translation/pt-BR.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado",
         "resetAllTrafficSuccess": "Todo o tráfego foi reiniciado",
         "resetInboundClientTrafficSuccess": "O tráfego foi reiniciado",
+        "resetInboundTrafficSuccess": "O tráfego de entrada foi reiniciado",
         "trafficGetError": "Erro ao obter tráfegos",
         "getNewX25519CertError": "Erro ao obter o certificado X25519.",
         "getNewmldsa65Error": "Erro ao obter o certificado mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "O conteúdo retornado pelo serviço de assinatura será codificado em Base64.",
       "subShowInfo": "Mostrar Informações de Uso",
       "subShowInfoDesc": "O tráfego restante e a data serão exibidos nos aplicativos de cliente.",
+      "subEmailInRemark": "Incluir Email no nome",
+      "subEmailInRemarkDesc": "Incluir o email do cliente no nome do perfil de assinatura.",
       "subURI": "URI de Proxy Reverso",
       "subURIDesc": "O caminho URI da URL de assinatura para uso por trás de proxies.",
       "externalTrafficInformEnable": "Informações de tráfego externo",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "Insira o código do aplicativo para alterar as credenciais do administrador.",
         "twoFactorModalSetSuccess": "A autenticação de dois fatores foi estabelecida com sucesso",
         "twoFactorModalDeleteSuccess": "A autenticação de dois fatores foi excluída com sucesso",
-        "twoFactorModalError": "Código incorreto"
+        "twoFactorModalError": "Código incorreto",
+        "show": "Mostrar",
+        "hide": "Ocultar",
+        "apiTokenNew": "Novo token",
+        "apiTokenName": "Nome",
+        "apiTokenNamePlaceholder": "ex.: central-panel-a",
+        "apiTokenNameRequired": "O nome é obrigatório",
+        "apiTokenEmpty": "Nenhum token ainda — crie um para autenticar bots ou painéis remotos.",
+        "apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente."
       },
       "toasts": {
         "modifySettings": "Os parâmetros foram alterados.",

+ 12 - 1
web/translation/ru-RU.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "Весь трафик клиента сброшен",
         "resetAllTrafficSuccess": "Весь трафик сброшен",
         "resetInboundClientTrafficSuccess": "Трафик сброшен",
+        "resetInboundTrafficSuccess": "Входящий трафик сброшен",
         "trafficGetError": "Ошибка получения данных о трафике",
         "getNewX25519CertError": "Ошибка при получении сертификата X25519.",
         "getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "Шифровать возвращенные конфиги в подписке",
       "subShowInfo": "Показать информацию об использовании",
       "subShowInfoDesc": "Отображать остаток трафика и дату окончания после имени конфигурации",
+      "subEmailInRemark": "Включать Email в название",
+      "subEmailInRemarkDesc": "Включать email клиента в название профиля подписки.",
       "subURI": "URI обратного прокси",
       "subURIDesc": "Изменить базовый URI URL-адреса подписки для использования за прокси-серверами",
       "externalTrafficInformEnable": "Информация о внешнем трафике",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.",
         "twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена",
         "twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена",
-        "twoFactorModalError": "Неверный код"
+        "twoFactorModalError": "Неверный код",
+        "show": "Показать",
+        "hide": "Скрыть",
+        "apiTokenNew": "Новый токен",
+        "apiTokenName": "Имя",
+        "apiTokenNamePlaceholder": "например, central-panel-a",
+        "apiTokenNameRequired": "Имя обязательно",
+        "apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
+        "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
       },
       "toasts": {
         "modifySettings": "Настройки изменены",

+ 12 - 1
web/translation/tr-TR.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "İstemcinin tüm trafiği sıfırlandı",
         "resetAllTrafficSuccess": "Tüm trafik sıfırlandı",
         "resetInboundClientTrafficSuccess": "Trafik sıfırlandı",
+        "resetInboundTrafficSuccess": "Gelen trafik sıfırlandı",
         "trafficGetError": "Trafik bilgisi alınırken hata oluştu",
         "getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.",
         "getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "Abonelik hizmetinin döndürülen içeriği Base64 ile şifrelenir.",
       "subShowInfo": "Kullanım Bilgisini Göster",
       "subShowInfoDesc": "Kalan trafik ve tarih müşteri uygulamalarında görüntülenir.",
+      "subEmailInRemark": "Ada Email Ekle",
+      "subEmailInRemarkDesc": "Abonelik profil adına istemcinin e-postasını dahil edin.",
       "subURI": "Ters Proxy URI",
       "subURIDesc": "Proxy arkasında kullanılacak abonelik URL'sinin URI yolu.",
       "externalTrafficInformEnable": "Harici Trafik Bilgisi",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "Yönetici kimlik bilgilerini değiştirmek için uygulamadaki kodu girin.",
         "twoFactorModalSetSuccess": "İki faktörlü kimlik doğrulama başarıyla kuruldu",
         "twoFactorModalDeleteSuccess": "İki faktörlü kimlik doğrulama başarıyla silindi",
-        "twoFactorModalError": "Yanlış kod"
+        "twoFactorModalError": "Yanlış kod",
+        "show": "Göster",
+        "hide": "Gizle",
+        "apiTokenNew": "Yeni token",
+        "apiTokenName": "Ad",
+        "apiTokenNamePlaceholder": "örn. central-panel-a",
+        "apiTokenNameRequired": "Ad zorunludur",
+        "apiTokenEmpty": "Henüz token yok — bot veya uzak panelleri doğrulamak için bir tane oluşturun.",
+        "apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder."
       },
       "toasts": {
         "modifySettings": "Parametreler değiştirildi.",

+ 12 - 1
web/translation/uk-UA.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто",
         "resetAllTrafficSuccess": "Весь трафік скинуто",
         "resetInboundClientTrafficSuccess": "Трафік скинуто",
+        "resetInboundTrafficSuccess": "Трафік вхідного потоку скинуто",
         "trafficGetError": "Помилка отримання даних про трафік",
         "getNewX25519CertError": "Помилка при отриманні сертифіката X25519.",
         "getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "Повернений вміст послуги підписки матиме кодування Base64.",
       "subShowInfo": "Показати інформацію про використання",
       "subShowInfoDesc": "Залишок трафіку та дата відображатимуться в клієнтських програмах.",
+      "subEmailInRemark": "Включати Email до назви",
+      "subEmailInRemarkDesc": "Включати email клієнта до назви профілю підписки.",
       "subURI": "URI зворотного проксі",
       "subURIDesc": "URI до URL-адреси підписки для використання за проксі.",
       "externalTrafficInformEnable": "Інформація про зовнішній трафік",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.",
         "twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена",
         "twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена",
-        "twoFactorModalError": "Невірний код"
+        "twoFactorModalError": "Невірний код",
+        "show": "Показати",
+        "hide": "Сховати",
+        "apiTokenNew": "Новий токен",
+        "apiTokenName": "Назва",
+        "apiTokenNamePlaceholder": "наприклад, central-panel-a",
+        "apiTokenNameRequired": "Назва обов'язкова",
+        "apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
+        "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
       },
       "toasts": {
         "modifySettings": "Параметри було змінено.",

+ 12 - 1
web/translation/vi-VN.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng client",
         "resetAllTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng",
         "resetInboundClientTrafficSuccess": "Đã đặt lại lưu lượng",
+        "resetInboundTrafficSuccess": "Đã đặt lại lưu lượng Inbound",
         "trafficGetError": "Lỗi khi lấy thông tin lưu lượng",
         "getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.",
         "getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "Mã hóa các cấu hình được trả về trong gói đăng ký",
       "subShowInfo": "Hiển thị thông tin sử dụng",
       "subShowInfoDesc": "Hiển thị lưu lượng truy cập còn lại và ngày sau tên cấu hình",
+      "subEmailInRemark": "Thêm Email vào tên",
+      "subEmailInRemarkDesc": "Thêm email của client vào tên hồ sơ đăng ký.",
       "subURI": "URI proxy trung gian",
       "subURIDesc": "Thay đổi URI cơ sở của URL gói đăng ký để sử dụng cho proxy trung gian",
       "externalTrafficInformEnable": "Thông báo giao thông bên ngoài",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "Nhập mã từ ứng dụng để thay đổi thông tin xác thực quản trị viên.",
         "twoFactorModalSetSuccess": "Xác thực hai yếu tố đã được thiết lập thành công",
         "twoFactorModalDeleteSuccess": "Xác thực hai yếu tố đã được xóa thành công",
-        "twoFactorModalError": "Mã sai"
+        "twoFactorModalError": "Mã sai",
+        "show": "Hiển thị",
+        "hide": "Ẩn",
+        "apiTokenNew": "Token mới",
+        "apiTokenName": "Tên",
+        "apiTokenNamePlaceholder": "ví dụ: central-panel-a",
+        "apiTokenNameRequired": "Tên là bắt buộc",
+        "apiTokenEmpty": "Chưa có token nào — tạo một token để xác thực bot hoặc panel từ xa.",
+        "apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức."
       },
       "toasts": {
         "modifySettings": "Các tham số đã được thay đổi.",

+ 12 - 1
web/translation/zh-CN.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "客户端所有流量已重置",
         "resetAllTrafficSuccess": "所有流量已重置",
         "resetInboundClientTrafficSuccess": "流量已重置",
+        "resetInboundTrafficSuccess": "入站流量已重置",
         "trafficGetError": "获取流量数据时出错",
         "getNewX25519CertError": "获取X25519证书时出错。",
         "getNewmldsa65Error": "获取mldsa65证书时出错。",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "订阅服务返回的内容将采用 Base64 编码",
       "subShowInfo": "显示使用信息",
       "subShowInfoDesc": "客户端应用中将显示剩余流量和日期信息",
+      "subEmailInRemark": "在名称中包含邮箱",
+      "subEmailInRemarkDesc": "在订阅配置名称中包含客户端邮箱。",
       "subURI": "反向代理 URI",
       "subURIDesc": "用于代理后面的订阅 URL 的 URI 路径",
       "externalTrafficInformEnable": "外部交通通知",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "输入应用程序中的代码以更改管理员凭据。",
         "twoFactorModalSetSuccess": "双因素认证已成功建立",
         "twoFactorModalDeleteSuccess": "双因素认证已成功删除",
-        "twoFactorModalError": "验证码错误"
+        "twoFactorModalError": "验证码错误",
+        "show": "显示",
+        "hide": "隐藏",
+        "apiTokenNew": "新建令牌",
+        "apiTokenName": "名称",
+        "apiTokenNamePlaceholder": "例如 central-panel-a",
+        "apiTokenNameRequired": "名称必填",
+        "apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
+        "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
       },
       "toasts": {
         "modifySettings": "参数已更改。",

+ 12 - 1
web/translation/zh-TW.json

@@ -341,6 +341,7 @@
         "resetAllClientTrafficSuccess": "客戶端所有流量已重置",
         "resetAllTrafficSuccess": "所有流量已重置",
         "resetInboundClientTrafficSuccess": "流量已重置",
+        "resetInboundTrafficSuccess": "入站流量已重置",
         "trafficGetError": "取得流量資料時發生錯誤",
         "getNewX25519CertError": "取得X25519憑證時發生錯誤。",
         "getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",
@@ -546,6 +547,8 @@
       "subEncryptDesc": "訂閱服務返回的內容將採用 Base64 編碼",
       "subShowInfo": "顯示使用資訊",
       "subShowInfoDesc": "客戶端應用中將顯示剩餘流量和日期資訊",
+      "subEmailInRemark": "在名稱中包含郵箱",
+      "subEmailInRemarkDesc": "在訂閱配置名稱中包含客戶端郵箱。",
       "subURI": "反向代理 URI",
       "subURIDesc": "用於代理後面的訂閱 URL 的 URI 路徑",
       "externalTrafficInformEnable": "外部交通通知",
@@ -588,7 +591,15 @@
         "twoFactorModalChangeCredentialsStep": "輸入應用程式中的代碼以更改管理員憑證。",
         "twoFactorModalSetSuccess": "雙重身份驗證已成功建立",
         "twoFactorModalDeleteSuccess": "雙重身份驗證已成功刪除",
-        "twoFactorModalError": "驗證碼錯誤"
+        "twoFactorModalError": "驗證碼錯誤",
+        "show": "顯示",
+        "hide": "隱藏",
+        "apiTokenNew": "新增令牌",
+        "apiTokenName": "名稱",
+        "apiTokenNamePlaceholder": "例如 central-panel-a",
+        "apiTokenNameRequired": "名稱必填",
+        "apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
+        "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。"
       },
       "toasts": {
         "modifySettings": "參數已更改。",