浏览代码

fix(api-token): hash tokens at rest and show plaintext only once

Store API tokens as SHA-256 hashes instead of plaintext and return the token value only in the create response. List no longer exposes the token, and the UI drops the Show/Copy buttons in favor of a one-time reveal modal at creation.

Match hashes the presented bearer token before the constant-time compare, and a migration hashes any pre-existing plaintext rows in place so existing tokens keep authenticating. Docs and translations updated.
MHSanaei 20 小时之前
父节点
当前提交
4813a2fe00

+ 29 - 1
database/db.go

@@ -181,7 +181,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
@@ -232,6 +232,12 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 	}
 
+	if !slices.Contains(seedersHistory, "ApiTokensHash") {
+		if err := hashExistingApiTokens(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "ClientsTable") {
 		if err := seedClientsFromInboundJSON(); err != nil {
 			return err
@@ -646,6 +652,28 @@ func seedApiTokens() error {
 	return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
 }
 
+// hashExistingApiTokens replaces any plaintext token stored before tokens were
+// hashed at rest with its SHA-256 digest. Callers keep their plaintext copy
+// (used on remote nodes), so existing tokens keep authenticating; the panel
+// just can no longer reveal them. Idempotent — already-hashed rows are skipped.
+func hashExistingApiTokens() error {
+	var rows []*model.ApiToken
+	if err := db.Find(&rows).Error; err != nil {
+		return err
+	}
+	for _, r := range rows {
+		if crypto.IsSHA256Hex(r.Token) {
+			continue
+		}
+		hashed := crypto.HashTokenSHA256(r.Token)
+		if err := db.Model(model.ApiToken{}).Where("id = ?", r.Id).Update("token", hashed).Error; err != nil {
+			log.Printf("Error hashing api token %d: %v", r.Id, err)
+			return err
+		}
+	}
+	return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensHash"}).Error
+}
+
 // isTableEmpty returns true if the named table contains zero rows.
 func isTableEmpty(tableName string) (bool, error) {
 	var count int64

+ 1 - 1
database/model/model.go

@@ -138,7 +138,7 @@ type HistoryOfSeeders struct {
 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"`
+	Token     string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation
 	Enabled   bool   `json:"enabled" gorm:"default:true"`
 	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 }

+ 3 - 4
frontend/public/openapi.json

@@ -69,7 +69,7 @@
     },
     {
       "name": "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."
+      "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 as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request."
     },
     {
       "name": "Xray Settings",
@@ -5105,7 +5105,7 @@
         "tags": [
           "API Tokens"
         ],
-        "summary": "List every API token, enabled or not.",
+        "summary": "List every API token, enabled or not. The token value is never returned — only metadata.",
         "operationId": "get_panel_setting_apiTokens",
         "responses": {
           "200": {
@@ -5130,7 +5130,6 @@
                     {
                       "id": 1,
                       "name": "default",
-                      "token": "abcdef-12345-...",
                       "enabled": true,
                       "createdAt": 1736000000
                     }
@@ -5147,7 +5146,7 @@
         "tags": [
           "API Tokens"
         ],
-        "summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.",
+        "summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.",
         "operationId": "post_panel_setting_apiTokens_create",
         "requestBody": {
           "required": true,

+ 4 - 4
frontend/src/pages/api-docs/endpoints.ts

@@ -951,18 +951,18 @@ export const sections: readonly Section[] = [
     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.',
+      '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 as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
     endpoints: [
       {
         method: 'GET',
         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}',
+        summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "default",\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.',
+        summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
         params: [
           { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
         ],

+ 5 - 0
frontend/src/pages/settings/SecurityTab.css

@@ -83,6 +83,11 @@
   word-break: break-all;
 }
 
+.api-token-created-notice {
+  margin: 0 0 12px;
+  font-size: 13px;
+}
+
 .security-actions {
   padding: 12px 0;
   display: flex;

+ 24 - 34
frontend/src/pages/settings/SecurityTab.tsx

@@ -30,7 +30,6 @@ interface ApiMsg<T = unknown> {
 interface ApiTokenRow {
   id: number;
   name: string;
-  token: string;
   enabled: boolean;
   createdAt: number;
 }
@@ -77,10 +76,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
 
   const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
   const [apiTokensLoading, setApiTokensLoading] = useState(false);
-  const [visibleTokenIds, setVisibleTokenIds] = useState<Set<number>>(() => new Set());
   const [createOpen, setCreateOpen] = useState(false);
   const [createName, setCreateName] = useState('');
   const [creating, setCreating] = useState(false);
+  const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
 
   const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
     setTfa({ ...opts, open: true });
@@ -137,14 +136,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     loadApiTokens();
   }, [loadApiTokens]);
 
-  function toggleTokenVisibility(id: number) {
-    setVisibleTokenIds((prev) => {
-      const next = new Set(prev);
-      if (next.has(id)) next.delete(id); else next.add(id);
-      return next;
-    });
-  }
-
   async function copyToken(token: string) {
     if (!token) return;
     const ok = await ClipboardManager.copyText(token);
@@ -165,17 +156,12 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     }
     setCreating(true);
     try {
-      const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ id?: number }>;
+      const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
       if (msg?.success) {
         setCreateOpen(false);
         await loadApiTokens();
-        if (msg.obj?.id != null) {
-          const id = msg.obj.id;
-          setVisibleTokenIds((prev) => {
-            const next = new Set(prev);
-            next.add(id);
-            return next;
-          });
+        if (msg.obj?.token) {
+          setCreatedToken({ name, token: msg.obj.token });
         }
       }
     } finally {
@@ -206,11 +192,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     }
   }
 
-  function maskToken(token: string): string {
-    if (!token) return '';
-    return '•'.repeat(Math.min(token.length, 24));
-  }
-
   function formatTokenDate(ts: number): string {
     if (!ts) return '';
     return new Date(ts * 1000).toLocaleString();
@@ -326,17 +307,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
                         </Button>
                       </div>
                     </div>
-                    <div className="api-token-value-wrap">
-                      <code className="api-token-value">
-                        {visibleTokenIds.has(row.id) ? row.token : maskToken(row.token)}
-                      </code>
-                      <Button size="small" onClick={() => toggleTokenVisibility(row.id)}>
-                        {visibleTokenIds.has(row.id)
-                          ? (t('pages.settings.security.hide') || 'Hide')
-                          : (t('pages.settings.security.show') || 'Show')}
-                      </Button>
-                      <Button size="small" onClick={() => copyToken(row.token)}>{t('copy')}</Button>
-                    </div>
                   </div>
                 ))}
               </Spin>
@@ -367,6 +337,26 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
         </Form>
       </Modal>
 
+      <Modal
+        open={!!createdToken}
+        title={t('pages.settings.security.apiTokenCreatedTitle') || 'Token created'}
+        okText={t('done')}
+        onOk={() => setCreatedToken(null)}
+        onCancel={() => setCreatedToken(null)}
+        cancelButtonProps={{ style: { display: 'none' } }}
+      >
+        <p className="api-token-created-notice">
+          {t('pages.settings.security.apiTokenCreatedNotice')
+            || 'Copy this token now. For security it is not stored in readable form and will not be shown again.'}
+        </p>
+        <div className="api-token-value-wrap">
+          <code className="api-token-value">{createdToken?.token}</code>
+          <Button size="small" type="primary" onClick={() => createdToken && copyToken(createdToken.token)}>
+            {t('copy')}
+          </Button>
+        </div>
+      </Modal>
+
       <TwoFactorModal
         open={tfa.open}
         title={tfa.title}

+ 25 - 0
util/crypto/crypto.go

@@ -2,6 +2,9 @@
 package crypto
 
 import (
+	"crypto/sha256"
+	"encoding/hex"
+
 	"golang.org/x/crypto/bcrypt"
 )
 
@@ -20,3 +23,25 @@ func IsHashed(s string) bool {
 	_, err := bcrypt.Cost([]byte(s))
 	return err == nil
 }
+
+// HashTokenSHA256 returns the hex-encoded SHA-256 digest of token. API tokens
+// are high-entropy random strings, so a fast unsalted digest is sufficient to
+// keep them irrecoverable at rest while allowing constant-time verification.
+func HashTokenSHA256(token string) string {
+	sum := sha256.Sum256([]byte(token))
+	return hex.EncodeToString(sum[:])
+}
+
+// IsSHA256Hex reports whether s looks like a hex-encoded SHA-256 digest
+// (64 lowercase hex characters), used to skip already-hashed token rows.
+func IsSHA256Hex(s string) bool {
+	if len(s) != 64 {
+		return false
+	}
+	for _, c := range s {
+		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
+			return false
+		}
+	}
+	return true
+}

+ 15 - 8
web/service/api_token.go

@@ -8,6 +8,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/crypto"
 	"github.com/mhsanaei/3x-ui/v3/util/random"
 )
 
@@ -18,16 +19,18 @@ const apiTokenLength = 48
 type ApiTokenView struct {
 	Id        int    `json:"id"`
 	Name      string `json:"name"`
-	Token     string `json:"token"`
+	Token     string `json:"token,omitempty"`
 	Enabled   bool   `json:"enabled"`
 	CreatedAt int64  `json:"createdAt"`
 }
 
+// toView builds the metadata view returned by List. It never carries the
+// token value: only a SHA-256 hash is stored, and the plaintext is shown
+// exactly once at creation time.
 func toView(t *model.ApiToken) *ApiTokenView {
 	return &ApiTokenView{
 		Id:        t.Id,
 		Name:      t.Name,
-		Token:     t.Token,
 		Enabled:   t.Enabled,
 		CreatedAt: t.CreatedAt,
 	}
@@ -62,15 +65,18 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
 	if count > 0 {
 		return nil, common.NewError("a token with that name already exists")
 	}
+	plaintext := random.Seq(apiTokenLength)
 	row := &model.ApiToken{
 		Name:    name,
-		Token:   random.Seq(apiTokenLength),
+		Token:   crypto.HashTokenSHA256(plaintext),
 		Enabled: true,
 	}
 	if err := db.Create(row).Error; err != nil {
 		return nil, err
 	}
-	return toView(row), nil
+	view := toView(row)
+	view.Token = plaintext
+	return view, nil
 }
 
 func (s *ApiTokenService) Delete(id int) error {
@@ -97,8 +103,9 @@ func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
 }
 
 // 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.
+// row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented
+// value is hashed before a constant-time compare per row keeps a remote
+// attacker from timing the comparison byte-by-byte.
 func (s *ApiTokenService) Match(presented string) bool {
 	if presented == "" {
 		return false
@@ -108,10 +115,10 @@ func (s *ApiTokenService) Match(presented string) bool {
 	if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
 		return false
 	}
-	presentedBytes := []byte(presented)
+	presentedHash := []byte(crypto.HashTokenSHA256(presented))
 	matched := false
 	for _, r := range rows {
-		if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
+		if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
 			matched = true
 		}
 	}

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

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "مثل central-panel-a",
         "apiTokenNameRequired": "الاسم مطلوب",
         "apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
-        "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
+        "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا.",
+        "apiTokenCreatedTitle": "تم إنشاء الرمز",
+        "apiTokenCreatedNotice": "انسخ هذا الرمز الآن. لأسباب أمنية لا يتم تخزينه بصيغة قابلة للقراءة ولن يتم عرضه مرة أخرى."
       },
       "toasts": {
         "modifySettings": "تم تغيير المعلمات.",

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

@@ -1119,7 +1119,9 @@
         "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."
+        "apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately.",
+        "apiTokenCreatedTitle": "Token created",
+        "apiTokenCreatedNotice": "Copy this token now. For security it is not stored in readable form and will not be shown again."
       },
       "toasts": {
         "modifySettings": "The parameters have been changed.",

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

@@ -1119,7 +1119,9 @@
         "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."
+        "apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente.",
+        "apiTokenCreatedTitle": "Token creado",
+        "apiTokenCreatedNotice": "Copia este token ahora. Por seguridad, no se almacena de forma legible y no se volverá a mostrar."
       },
       "toasts": {
         "modifySettings": "Los parámetros han sido modificados.",

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

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "مثلاً central-panel-a",
         "apiTokenNameRequired": "نام الزامی است",
         "apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت ربات‌ها یا پنل‌های راه دور یکی بسازید.",
-        "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود."
+        "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود.",
+        "apiTokenCreatedTitle": "توکن ساخته شد",
+        "apiTokenCreatedNotice": "اکنون این توکن را کپی کنید. به‌دلیل امنیتی به‌صورت قابل‌خواندن ذخیره نمی‌شود و دوباره نمایش داده نخواهد شد."
       },
       "toasts": {
         "modifySettings": "پارامترها تغییر کرده‌اند.",

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

@@ -1119,7 +1119,9 @@
         "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."
+        "apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera.",
+        "apiTokenCreatedTitle": "Token dibuat",
+        "apiTokenCreatedNotice": "Salin token ini sekarang. Demi keamanan, token tidak disimpan dalam bentuk yang dapat dibaca dan tidak akan ditampilkan lagi."
       },
       "toasts": {
         "modifySettings": "Parameter telah diubah.",

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

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "例: central-panel-a",
         "apiTokenNameRequired": "名前は必須です",
         "apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
-        "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
+        "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。",
+        "apiTokenCreatedTitle": "トークンを作成しました",
+        "apiTokenCreatedNotice": "このトークンを今すぐコピーしてください。セキュリティ上、読み取り可能な形式では保存されず、再表示されません。"
       },
       "toasts": {
         "modifySettings": "パラメーターが変更されました。",

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

@@ -1119,7 +1119,9 @@
         "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."
+        "apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente.",
+        "apiTokenCreatedTitle": "Token criado",
+        "apiTokenCreatedNotice": "Copie este token agora. Por segurança, ele não é armazenado de forma legível e não será exibido novamente."
       },
       "toasts": {
         "modifySettings": "Os parâmetros foram alterados.",

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

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "например, central-panel-a",
         "apiTokenNameRequired": "Имя обязательно",
         "apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
-        "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
+        "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию.",
+        "apiTokenCreatedTitle": "Токен создан",
+        "apiTokenCreatedNotice": "Скопируйте этот токен сейчас. В целях безопасности он не хранится в читаемом виде и больше не будет показан."
       },
       "toasts": {
         "modifySettings": "Настройки изменены",

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

@@ -1119,7 +1119,9 @@
         "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."
+        "apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder.",
+        "apiTokenCreatedTitle": "Belirteç oluşturuldu",
+        "apiTokenCreatedNotice": "Bu belirteci şimdi kopyalayın. Güvenlik nedeniyle okunabilir biçimde saklanmaz ve tekrar gösterilmez."
       },
       "toasts": {
         "modifySettings": "Parametreler değiştirildi.",

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

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "наприклад, central-panel-a",
         "apiTokenNameRequired": "Назва обов'язкова",
         "apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
-        "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
+        "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію.",
+        "apiTokenCreatedTitle": "Токен створено",
+        "apiTokenCreatedNotice": "Скопіюйте цей токен зараз. З міркувань безпеки він не зберігається у читабельному вигляді й більше не відображатиметься."
       },
       "toasts": {
         "modifySettings": "Параметри було змінено.",

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

@@ -1119,7 +1119,9 @@
         "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."
+        "apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức.",
+        "apiTokenCreatedTitle": "Đã tạo token",
+        "apiTokenCreatedNotice": "Hãy sao chép token này ngay bây giờ. Vì lý do bảo mật, token không được lưu ở dạng đọc được và sẽ không hiển thị lại."
       },
       "toasts": {
         "modifySettings": "Các tham số đã được thay đổi.",

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

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "例如 central-panel-a",
         "apiTokenNameRequired": "名称必填",
         "apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
-        "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
+        "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。",
+        "apiTokenCreatedTitle": "令牌已创建",
+        "apiTokenCreatedNotice": "请立即复制此令牌。出于安全考虑,它不会以可读形式存储,也不会再次显示。"
       },
       "toasts": {
         "modifySettings": "参数已更改。",

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

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "例如 central-panel-a",
         "apiTokenNameRequired": "名稱必填",
         "apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
-        "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。"
+        "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。",
+        "apiTokenCreatedTitle": "權杖已建立",
+        "apiTokenCreatedNotice": "請立即複製此權杖。基於安全考量,它不會以可讀形式儲存,也不會再次顯示。"
       },
       "toasts": {
         "modifySettings": "參數已更改。",