Browse Source

feat(api-tokens): manage multiple named tokens; add tab/section anchor URLs

Replace the single regenerable API token with a named-token list:
- New ApiToken model + service with constant-time auth matching
- Seeder migrates the legacy `apiToken` setting into a "default" row
- Security tab gets create/enable/delete UI; api-docs page links to it
- Dedicated "API Tokens" section in the in-panel docs

URL anchors now reflect the active tab/section on Settings, Xray, and
API Docs pages, so deep links like `/panel/settings#security` work.

Translations for the 8 new SecurityTab strings added across all locales.
MHSanaei 1 day ago
parent
commit
b97ff40ad6

+ 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

+ 29 - 105
frontend/src/pages/api-docs/ApiDocsPage.vue

@@ -1,13 +1,7 @@
 <script setup>
 import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
-import { useI18n } from 'vue-i18n';
-import { Modal, message } from 'ant-design-vue';
 import {
   KeyOutlined,
-  ReloadOutlined,
-  CopyOutlined,
-  EyeOutlined,
-  EyeInvisibleOutlined,
   SearchOutlined,
   ExpandOutlined,
   CompressOutlined,
@@ -25,34 +19,28 @@ import {
 
 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 = {
-  auth: SafetyCertificateOutlined,
+  authentication: SafetyCertificateOutlined,
   inbounds: NodeIndexOutlined,
   server: CloudServerOutlined,
   nodes: ClusterOutlined,
-  customGeo: GlobalOutlined,
+  'custom-geo': GlobalOutlined,
   backup: SaveOutlined,
   settings: SettingOutlined,
-  xraySettings: WifiOutlined,
+  'api-tokens': KeyOutlined,
+  'xray-settings': WifiOutlined,
   subscription: LinkOutlined,
   websocket: ApiOutlined,
 };
@@ -103,46 +91,20 @@ 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;
@@ -162,16 +124,20 @@ function onScroll() {
 }
 
 onMounted(() => {
-  loadApiToken();
   scrollObserver = onScroll;
   window.addEventListener('scroll', scrollObserver, { passive: true });
-  onScroll();
+  window.addEventListener('hashchange', scrollToHash);
+  requestAnimationFrame(() => {
+    scrollToHash();
+    onScroll();
+  });
 });
 
 onBeforeUnmount(() => {
   if (scrollObserver) {
     window.removeEventListener('scroll', scrollObserver);
   }
+  window.removeEventListener('hashchange', scrollToHash);
 });
 </script>
 
@@ -197,38 +163,17 @@ onBeforeUnmount(() => {
               <div class="token-card-head">
                 <div class="token-card-title">
                   <KeyOutlined />
-                  <span>API Token</span>
-                </div>
-                <div class="token-actions">
-                  <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>
+                  <span>API Tokens</span>
                 </div>
+                <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>
 
@@ -387,25 +332,6 @@ onBeforeUnmount(() => {
   font-size: 14px;
 }
 
-.token-actions {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  flex-wrap: wrap;
-}
-
-.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);
@@ -573,14 +499,12 @@ html[data-theme='ultra-dark'] .token-hint code {
   background: rgba(255, 255, 255, 0.12);
 }
 
-body.dark .token-value,
 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'] .token-value,
 html[data-theme='ultra-dark'] .code-block {
   background: rgba(255, 255, 255, 0.02);
   border-color: rgba(255, 255, 255, 0.08);

+ 44 - 10
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/*.',
@@ -576,7 +576,7 @@ export const sections = [
   },
 
   {
-    id: 'customGeo',
+    id: 'custom-geo',
     title: 'Custom Geo',
     description:
       'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
@@ -647,7 +647,7 @@ export const sections = [
     id: 'settings',
     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',
@@ -688,23 +688,57 @@ 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/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/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/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',
+    id: 'xray-settings',
     title: 'Xray Settings',
     description:
       'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',

+ 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 />

+ 36 - 2
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 {
@@ -208,6 +208,40 @@ 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 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>
@@ -259,7 +293,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>

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

+ 51 - 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.
@@ -130,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))
 }

+ 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
+}

+ 5 - 45
web/service/setting.go

@@ -1,7 +1,6 @@
 package service
 
 import (
-	"crypto/subtle"
 	_ "embed"
 	"encoding/json"
 	"errors"
@@ -211,7 +210,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 +469,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
@@ -877,7 +837,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)
 	}
 

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

@@ -589,7 +589,15 @@
         "twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.",
         "twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح",
         "twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح",
-        "twoFactorModalError": "رمز خاطئ"
+        "twoFactorModalError": "رمز خاطئ",
+        "show": "إظهار",
+        "hide": "إخفاء",
+        "apiTokenNew": "رمز جديد",
+        "apiTokenName": "الاسم",
+        "apiTokenNamePlaceholder": "مثل central-panel-a",
+        "apiTokenNameRequired": "الاسم مطلوب",
+        "apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
+        "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
       },
       "toasts": {
         "modifySettings": "تم تغيير المعلمات.",

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

@@ -589,7 +589,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.",

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

@@ -589,7 +589,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.",

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

@@ -589,7 +589,15 @@
         "twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامه‌های مدیر، کد را از برنامه وارد کنید.",
         "twoFactorModalSetSuccess": "احراز هویت دو مرحله‌ای با موفقیت برقرار شد",
         "twoFactorModalDeleteSuccess": "احراز هویت دو مرحله‌ای با موفقیت حذف شد",
-        "twoFactorModalError": "کد نادرست"
+        "twoFactorModalError": "کد نادرست",
+        "show": "نمایش",
+        "hide": "پنهان",
+        "apiTokenNew": "توکن جدید",
+        "apiTokenName": "نام",
+        "apiTokenNamePlaceholder": "مثلاً central-panel-a",
+        "apiTokenNameRequired": "نام الزامی است",
+        "apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت ربات‌ها یا پنل‌های راه دور یکی بسازید.",
+        "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود."
       },
       "toasts": {
         "modifySettings": "پارامترها تغییر کرده‌اند.",

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

@@ -589,7 +589,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.",

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

@@ -589,7 +589,15 @@
         "twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。",
         "twoFactorModalSetSuccess": "二要素認証が正常に設定されました",
         "twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました",
-        "twoFactorModalError": "コードが間違っています"
+        "twoFactorModalError": "コードが間違っています",
+        "show": "表示",
+        "hide": "非表示",
+        "apiTokenNew": "新規トークン",
+        "apiTokenName": "名前",
+        "apiTokenNamePlaceholder": "例: central-panel-a",
+        "apiTokenNameRequired": "名前は必須です",
+        "apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
+        "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
       },
       "toasts": {
         "modifySettings": "パラメーターが変更されました。",

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

@@ -589,7 +589,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.",

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

@@ -589,7 +589,15 @@
         "twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.",
         "twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена",
         "twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена",
-        "twoFactorModalError": "Неверный код"
+        "twoFactorModalError": "Неверный код",
+        "show": "Показать",
+        "hide": "Скрыть",
+        "apiTokenNew": "Новый токен",
+        "apiTokenName": "Имя",
+        "apiTokenNamePlaceholder": "например, central-panel-a",
+        "apiTokenNameRequired": "Имя обязательно",
+        "apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
+        "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
       },
       "toasts": {
         "modifySettings": "Настройки изменены",

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

@@ -589,7 +589,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.",

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

@@ -589,7 +589,15 @@
         "twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.",
         "twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена",
         "twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена",
-        "twoFactorModalError": "Невірний код"
+        "twoFactorModalError": "Невірний код",
+        "show": "Показати",
+        "hide": "Сховати",
+        "apiTokenNew": "Новий токен",
+        "apiTokenName": "Назва",
+        "apiTokenNamePlaceholder": "наприклад, central-panel-a",
+        "apiTokenNameRequired": "Назва обов'язкова",
+        "apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
+        "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
       },
       "toasts": {
         "modifySettings": "Параметри було змінено.",

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

@@ -589,7 +589,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.",

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

@@ -589,7 +589,15 @@
         "twoFactorModalChangeCredentialsStep": "输入应用程序中的代码以更改管理员凭据。",
         "twoFactorModalSetSuccess": "双因素认证已成功建立",
         "twoFactorModalDeleteSuccess": "双因素认证已成功删除",
-        "twoFactorModalError": "验证码错误"
+        "twoFactorModalError": "验证码错误",
+        "show": "显示",
+        "hide": "隐藏",
+        "apiTokenNew": "新建令牌",
+        "apiTokenName": "名称",
+        "apiTokenNamePlaceholder": "例如 central-panel-a",
+        "apiTokenNameRequired": "名称必填",
+        "apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
+        "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
       },
       "toasts": {
         "modifySettings": "参数已更改。",

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

@@ -589,7 +589,15 @@
         "twoFactorModalChangeCredentialsStep": "輸入應用程式中的代碼以更改管理員憑證。",
         "twoFactorModalSetSuccess": "雙重身份驗證已成功建立",
         "twoFactorModalDeleteSuccess": "雙重身份驗證已成功刪除",
-        "twoFactorModalError": "驗證碼錯誤"
+        "twoFactorModalError": "驗證碼錯誤",
+        "show": "顯示",
+        "hide": "隱藏",
+        "apiTokenNew": "新增令牌",
+        "apiTokenName": "名稱",
+        "apiTokenNamePlaceholder": "例如 central-panel-a",
+        "apiTokenNameRequired": "名稱必填",
+        "apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
+        "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。"
       },
       "toasts": {
         "modifySettings": "參數已更改。",