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.
This commit is contained in:
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -589,7 +589,15 @@
|
||||
"twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.",
|
||||
"twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح",
|
||||
"twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح",
|
||||
"twoFactorModalError": "رمز خاطئ"
|
||||
"twoFactorModalError": "رمز خاطئ",
|
||||
"show": "إظهار",
|
||||
"hide": "إخفاء",
|
||||
"apiTokenNew": "رمز جديد",
|
||||
"apiTokenName": "الاسم",
|
||||
"apiTokenNamePlaceholder": "مثل central-panel-a",
|
||||
"apiTokenNameRequired": "الاسم مطلوب",
|
||||
"apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
|
||||
"apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "تم تغيير المعلمات.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -589,7 +589,15 @@
|
||||
"twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامههای مدیر، کد را از برنامه وارد کنید.",
|
||||
"twoFactorModalSetSuccess": "احراز هویت دو مرحلهای با موفقیت برقرار شد",
|
||||
"twoFactorModalDeleteSuccess": "احراز هویت دو مرحلهای با موفقیت حذف شد",
|
||||
"twoFactorModalError": "کد نادرست"
|
||||
"twoFactorModalError": "کد نادرست",
|
||||
"show": "نمایش",
|
||||
"hide": "پنهان",
|
||||
"apiTokenNew": "توکن جدید",
|
||||
"apiTokenName": "نام",
|
||||
"apiTokenNamePlaceholder": "مثلاً central-panel-a",
|
||||
"apiTokenNameRequired": "نام الزامی است",
|
||||
"apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت رباتها یا پنلهای راه دور یکی بسازید.",
|
||||
"apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده میکند بلافاصله احراز هویتش قطع میشود."
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "پارامترها تغییر کردهاند.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -589,7 +589,15 @@
|
||||
"twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。",
|
||||
"twoFactorModalSetSuccess": "二要素認証が正常に設定されました",
|
||||
"twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました",
|
||||
"twoFactorModalError": "コードが間違っています"
|
||||
"twoFactorModalError": "コードが間違っています",
|
||||
"show": "表示",
|
||||
"hide": "非表示",
|
||||
"apiTokenNew": "新規トークン",
|
||||
"apiTokenName": "名前",
|
||||
"apiTokenNamePlaceholder": "例: central-panel-a",
|
||||
"apiTokenNameRequired": "名前は必須です",
|
||||
"apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
|
||||
"apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "パラメーターが変更されました。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -589,7 +589,15 @@
|
||||
"twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.",
|
||||
"twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена",
|
||||
"twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена",
|
||||
"twoFactorModalError": "Неверный код"
|
||||
"twoFactorModalError": "Неверный код",
|
||||
"show": "Показать",
|
||||
"hide": "Скрыть",
|
||||
"apiTokenNew": "Новый токен",
|
||||
"apiTokenName": "Имя",
|
||||
"apiTokenNamePlaceholder": "например, central-panel-a",
|
||||
"apiTokenNameRequired": "Имя обязательно",
|
||||
"apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
|
||||
"apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "Настройки изменены",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -589,7 +589,15 @@
|
||||
"twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.",
|
||||
"twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена",
|
||||
"twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена",
|
||||
"twoFactorModalError": "Невірний код"
|
||||
"twoFactorModalError": "Невірний код",
|
||||
"show": "Показати",
|
||||
"hide": "Сховати",
|
||||
"apiTokenNew": "Новий токен",
|
||||
"apiTokenName": "Назва",
|
||||
"apiTokenNamePlaceholder": "наприклад, central-panel-a",
|
||||
"apiTokenNameRequired": "Назва обов'язкова",
|
||||
"apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
|
||||
"apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "Параметри було змінено.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -589,7 +589,15 @@
|
||||
"twoFactorModalChangeCredentialsStep": "输入应用程序中的代码以更改管理员凭据。",
|
||||
"twoFactorModalSetSuccess": "双因素认证已成功建立",
|
||||
"twoFactorModalDeleteSuccess": "双因素认证已成功删除",
|
||||
"twoFactorModalError": "验证码错误"
|
||||
"twoFactorModalError": "验证码错误",
|
||||
"show": "显示",
|
||||
"hide": "隐藏",
|
||||
"apiTokenNew": "新建令牌",
|
||||
"apiTokenName": "名称",
|
||||
"apiTokenNamePlaceholder": "例如 central-panel-a",
|
||||
"apiTokenNameRequired": "名称必填",
|
||||
"apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
|
||||
"apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "参数已更改。",
|
||||
|
||||
@@ -589,7 +589,15 @@
|
||||
"twoFactorModalChangeCredentialsStep": "輸入應用程式中的代碼以更改管理員憑證。",
|
||||
"twoFactorModalSetSuccess": "雙重身份驗證已成功建立",
|
||||
"twoFactorModalDeleteSuccess": "雙重身份驗證已成功刪除",
|
||||
"twoFactorModalError": "驗證碼錯誤"
|
||||
"twoFactorModalError": "驗證碼錯誤",
|
||||
"show": "顯示",
|
||||
"hide": "隱藏",
|
||||
"apiTokenNew": "新增令牌",
|
||||
"apiTokenName": "名稱",
|
||||
"apiTokenNamePlaceholder": "例如 central-panel-a",
|
||||
"apiTokenNameRequired": "名稱必填",
|
||||
"apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
|
||||
"apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。"
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "參數已更改。",
|
||||
|
||||
Reference in New Issue
Block a user