From 4813a2fe00e95c00afa965a93e0d015ff99a18cc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 3 Jun 2026 22:57:50 +0200 Subject: [PATCH] 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. --- database/db.go | 30 ++++++++++- database/model/model.go | 2 +- frontend/public/openapi.json | 7 ++- frontend/src/pages/api-docs/endpoints.ts | 8 +-- frontend/src/pages/settings/SecurityTab.css | 5 ++ frontend/src/pages/settings/SecurityTab.tsx | 58 +++++++++------------ util/crypto/crypto.go | 25 +++++++++ web/service/api_token.go | 23 +++++--- web/translation/ar-EG.json | 4 +- web/translation/en-US.json | 4 +- web/translation/es-ES.json | 4 +- web/translation/fa-IR.json | 4 +- web/translation/id-ID.json | 4 +- web/translation/ja-JP.json | 4 +- web/translation/pt-BR.json | 4 +- web/translation/ru-RU.json | 4 +- web/translation/tr-TR.json | 4 +- web/translation/uk-UA.json | 4 +- web/translation/vi-VN.json | 4 +- web/translation/zh-CN.json | 4 +- web/translation/zh-TW.json | 4 +- 21 files changed, 145 insertions(+), 65 deletions(-) diff --git a/database/db.go b/database/db.go index c2d79742..b3b914e7 100644 --- a/database/db.go +++ b/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 diff --git a/database/model/model.go b/database/model/model.go index 4d70a44d..2db08a29 100644 --- a/database/model/model.go +++ b/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"` } diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d51b0b8d..dab418d5 100644 --- a/frontend/public/openapi.json +++ b/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 Authorization: Bearer <token> 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 Authorization: Bearer <token> 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, diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 6b3a5c8e..6e9c7e8e 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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 Authorization: Bearer <token> 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 Authorization: Bearer <token> 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".' }, ], diff --git a/frontend/src/pages/settings/SecurityTab.css b/frontend/src/pages/settings/SecurityTab.css index e078c081..87a6d5f4 100644 --- a/frontend/src/pages/settings/SecurityTab.css +++ b/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; diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx index 8c4dd2ab..f564a528 100644 --- a/frontend/src/pages/settings/SecurityTab.tsx +++ b/frontend/src/pages/settings/SecurityTab.tsx @@ -30,7 +30,6 @@ interface ApiMsg { 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([]); const [apiTokensLoading, setApiTokensLoading] = useState(false); - const [visibleTokenIds, setVisibleTokenIds] = useState>(() => 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) => { 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 -
- - {visibleTokenIds.has(row.id) ? row.token : maskToken(row.token)} - - - -
))} @@ -367,6 +337,26 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr + setCreatedToken(null)} + onCancel={() => setCreatedToken(null)} + cancelButtonProps={{ style: { display: 'none' } }} + > +

+ {t('pages.settings.security.apiTokenCreatedNotice') + || 'Copy this token now. For security it is not stored in readable form and will not be shown again.'} +

+
+ {createdToken?.token} + +
+
+ '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} diff --git a/web/service/api_token.go b/web/service/api_token.go index fbde1a47..adeeae18 100644 --- a/web/service/api_token.go +++ b/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 } } diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index fb9c71dc..91290fc5 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -1119,7 +1119,9 @@ "apiTokenNamePlaceholder": "مثل central-panel-a", "apiTokenNameRequired": "الاسم مطلوب", "apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.", - "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا." + "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا.", + "apiTokenCreatedTitle": "تم إنشاء الرمز", + "apiTokenCreatedNotice": "انسخ هذا الرمز الآن. لأسباب أمنية لا يتم تخزينه بصيغة قابلة للقراءة ولن يتم عرضه مرة أخرى." }, "toasts": { "modifySettings": "تم تغيير المعلمات.", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index 7e0f91be..a28c0894 100644 --- a/web/translation/en-US.json +++ b/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.", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index 2a31dd99..0b9ba12c 100644 --- a/web/translation/es-ES.json +++ b/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.", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index bef48625..359a6de8 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -1119,7 +1119,9 @@ "apiTokenNamePlaceholder": "مثلاً central-panel-a", "apiTokenNameRequired": "نام الزامی است", "apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت ربات‌ها یا پنل‌های راه دور یکی بسازید.", - "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود." + "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود.", + "apiTokenCreatedTitle": "توکن ساخته شد", + "apiTokenCreatedNotice": "اکنون این توکن را کپی کنید. به‌دلیل امنیتی به‌صورت قابل‌خواندن ذخیره نمی‌شود و دوباره نمایش داده نخواهد شد." }, "toasts": { "modifySettings": "پارامترها تغییر کرده‌اند.", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index 1b3ccf3f..6742f21f 100644 --- a/web/translation/id-ID.json +++ b/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.", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index d3dc3a35..7397e12a 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -1119,7 +1119,9 @@ "apiTokenNamePlaceholder": "例: central-panel-a", "apiTokenNameRequired": "名前は必須です", "apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。", - "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。" + "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。", + "apiTokenCreatedTitle": "トークンを作成しました", + "apiTokenCreatedNotice": "このトークンを今すぐコピーしてください。セキュリティ上、読み取り可能な形式では保存されず、再表示されません。" }, "toasts": { "modifySettings": "パラメーターが変更されました。", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 99da5271..955df690 100644 --- a/web/translation/pt-BR.json +++ b/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.", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index f0ed1d89..32b0e436 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -1119,7 +1119,9 @@ "apiTokenNamePlaceholder": "например, central-panel-a", "apiTokenNameRequired": "Имя обязательно", "apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.", - "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию." + "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию.", + "apiTokenCreatedTitle": "Токен создан", + "apiTokenCreatedNotice": "Скопируйте этот токен сейчас. В целях безопасности он не хранится в читаемом виде и больше не будет показан." }, "toasts": { "modifySettings": "Настройки изменены", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index 9b20a188..e78f55ab 100644 --- a/web/translation/tr-TR.json +++ b/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.", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index 8b3a1ce8..4d9974d8 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -1119,7 +1119,9 @@ "apiTokenNamePlaceholder": "наприклад, central-panel-a", "apiTokenNameRequired": "Назва обов'язкова", "apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.", - "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію." + "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію.", + "apiTokenCreatedTitle": "Токен створено", + "apiTokenCreatedNotice": "Скопіюйте цей токен зараз. З міркувань безпеки він не зберігається у читабельному вигляді й більше не відображатиметься." }, "toasts": { "modifySettings": "Параметри було змінено.", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index 47c6affc..66b2693e 100644 --- a/web/translation/vi-VN.json +++ b/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.", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 1841e980..cbd9aea6 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -1119,7 +1119,9 @@ "apiTokenNamePlaceholder": "例如 central-panel-a", "apiTokenNameRequired": "名称必填", "apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。", - "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。" + "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。", + "apiTokenCreatedTitle": "令牌已创建", + "apiTokenCreatedNotice": "请立即复制此令牌。出于安全考虑,它不会以可读形式存储,也不会再次显示。" }, "toasts": { "modifySettings": "参数已更改。", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index a84b9981..c3422480 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -1119,7 +1119,9 @@ "apiTokenNamePlaceholder": "例如 central-panel-a", "apiTokenNameRequired": "名稱必填", "apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。", - "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。" + "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。", + "apiTokenCreatedTitle": "權杖已建立", + "apiTokenCreatedNotice": "請立即複製此權杖。基於安全考量,它不會以可讀形式儲存,也不會再次顯示。" }, "toasts": { "modifySettings": "參數已更改。",