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.
This commit is contained in:
MHSanaei 2026-06-03 22:57:50 +02:00
parent 7a72aeda7a
commit 4813a2fe00
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
21 changed files with 145 additions and 65 deletions

View file

@ -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

View file

@ -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"`
}

View file

@ -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,

View file

@ -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".' },
],

View file

@ -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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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