mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
feat: add user registration with role-based access
- Add Role field to User model (admin/user) with uniqueIndex on Username - Add POST /register endpoint with optional Cloudflare Turnstile verification - Add RegisterUser service with bcrypt password hashing and duplicate detection - Set default admin user role to "admin", new registrations get "user" - Add turnstileSecretKey setting and GetTurnstileSecretKey getter - Add i18n keys (userExists, errorRegister) to all 13 translation files
This commit is contained in:
parent
5729cebb8e
commit
5f83415e95
20 changed files with 365 additions and 7 deletions
|
|
@ -66,6 +66,7 @@ func initUser() error {
|
|||
user := &model.User{
|
||||
Username: defaultUsername,
|
||||
Password: hashedPassword,
|
||||
Role: "admin",
|
||||
}
|
||||
return db.Create(user).Error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ const (
|
|||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Username string `json:"username" gorm:"uniqueIndex"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role" gorm:"default:user"`
|
||||
}
|
||||
|
||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package controller
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
|
|
@ -21,6 +22,13 @@ type LoginForm struct {
|
|||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||
}
|
||||
|
||||
// RegisterForm represents the registration request structure.
|
||||
type RegisterForm struct {
|
||||
Username string `json:"username" form:"username"`
|
||||
Password string `json:"password" form:"password"`
|
||||
TurnstileToken string `json:"turnstileToken" form:"turnstileToken"`
|
||||
}
|
||||
|
||||
// IndexController handles the main index and login-related routes.
|
||||
type IndexController struct {
|
||||
BaseController
|
||||
|
|
@ -43,6 +51,7 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
|||
g.GET("/logout", a.logout)
|
||||
|
||||
g.POST("/login", a.login)
|
||||
g.POST("/register", a.register)
|
||||
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
|
||||
g.POST("/getTurnstileSiteKey", a.getTurnstileSiteKey)
|
||||
}
|
||||
|
|
@ -112,6 +121,52 @@ func (a *IndexController) login(c *gin.Context) {
|
|||
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
|
||||
}
|
||||
|
||||
// register handles new user registration.
|
||||
func (a *IndexController) register(c *gin.Context) {
|
||||
var form RegisterForm
|
||||
|
||||
if err := c.ShouldBind(&form); err != nil {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidFormData"))
|
||||
return
|
||||
}
|
||||
if form.Username == "" {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername"))
|
||||
return
|
||||
}
|
||||
if form.Password == "" {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword"))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify Turnstile token if site key is configured
|
||||
turnstileSecretKey, err := a.settingService.GetTurnstileSecretKey()
|
||||
if err == nil && turnstileSecretKey != "" {
|
||||
if form.TurnstileToken == "" {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.turnstileRequired"))
|
||||
return
|
||||
}
|
||||
if !service.VerifyTurnstile(turnstileSecretKey, form.TurnstileToken, getRemoteIp(c)) {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.turnstileRequired"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = a.userService.RegisterUser(form.Username, form.Password)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "already exists") {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.userExists"))
|
||||
return
|
||||
}
|
||||
logger.Warningf("register failed for user \"%s\": %s", template.HTMLEscapeString(form.Username), err)
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.errorRegister"))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("new user registered: %s", template.HTMLEscapeString(form.Username))
|
||||
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successRegister"), nil)
|
||||
}
|
||||
|
||||
// logout handles user logout by clearing the session and redirecting to the login page.
|
||||
func (a *IndexController) logout(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ type AllSetting struct {
|
|||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
|
||||
|
||||
// Registration settings
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"`
|
||||
TurnstileSecretKey string `json:"turnstileSecretKey" form:"turnstileSecretKey"`
|
||||
}
|
||||
|
||||
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
|
||||
|
|
|
|||
|
|
@ -106,7 +106,190 @@ var defaultValueMap = map[string]string{
|
|||
"ldapDefaultLimitIP": "0",
|
||||
|
||||
// Registration settings
|
||||
"turnstileSiteKey": "",
|
||||
"turnstileSiteKey": "",
|
||||
"turnstileSecretKey": "",
|
||||
}
|
||||
|
||||
// settingGroups defines the nested JSON structure for on-disk settings.
|
||||
// Each group maps nested keys to their flat equivalents in defaultValueMap.
|
||||
var settingGroups = map[string]map[string]string{
|
||||
"web": {
|
||||
"listen": "webListen",
|
||||
"domain": "webDomain",
|
||||
"port": "webPort",
|
||||
"certFile": "webCertFile",
|
||||
"keyFile": "webKeyFile",
|
||||
"basePath": "webBasePath",
|
||||
"sessionMaxAge": "sessionMaxAge",
|
||||
},
|
||||
"tgBot": {
|
||||
"enable": "tgBotEnable",
|
||||
"token": "tgBotToken",
|
||||
"proxy": "tgBotProxy",
|
||||
"apiServer": "tgBotAPIServer",
|
||||
"chatId": "tgBotChatId",
|
||||
"runTime": "tgRunTime",
|
||||
"backup": "tgBotBackup",
|
||||
"loginNotify": "tgBotLoginNotify",
|
||||
"cpu": "tgCpu",
|
||||
"lang": "tgLang",
|
||||
},
|
||||
"sub": {
|
||||
"enable": "subEnable",
|
||||
"jsonEnable": "subJsonEnable",
|
||||
"title": "subTitle",
|
||||
"supportUrl": "subSupportUrl",
|
||||
"profileUrl": "subProfileUrl",
|
||||
"announce": "subAnnounce",
|
||||
"enableRouting": "subEnableRouting",
|
||||
"routingRules": "subRoutingRules",
|
||||
"listen": "subListen",
|
||||
"port": "subPort",
|
||||
"path": "subPath",
|
||||
"domain": "subDomain",
|
||||
"certFile": "subCertFile",
|
||||
"keyFile": "subKeyFile",
|
||||
"updates": "subUpdates",
|
||||
"encrypt": "subEncrypt",
|
||||
"showInfo": "subShowInfo",
|
||||
"uri": "subURI",
|
||||
"jsonPath": "subJsonPath",
|
||||
"jsonURI": "subJsonURI",
|
||||
"jsonFragment": "subJsonFragment",
|
||||
"jsonNoises": "subJsonNoises",
|
||||
"jsonMux": "subJsonMux",
|
||||
"jsonRules": "subJsonRules",
|
||||
},
|
||||
"ldap": {
|
||||
"enable": "ldapEnable",
|
||||
"host": "ldapHost",
|
||||
"port": "ldapPort",
|
||||
"useTLS": "ldapUseTLS",
|
||||
"bindDN": "ldapBindDN",
|
||||
"password": "ldapPassword",
|
||||
"baseDN": "ldapBaseDN",
|
||||
"userFilter": "ldapUserFilter",
|
||||
"userAttr": "ldapUserAttr",
|
||||
"vlessField": "ldapVlessField",
|
||||
"syncCron": "ldapSyncCron",
|
||||
"flagField": "ldapFlagField",
|
||||
"truthyValues": "ldapTruthyValues",
|
||||
"invertFlag": "ldapInvertFlag",
|
||||
"inboundTags": "ldapInboundTags",
|
||||
"autoCreate": "ldapAutoCreate",
|
||||
"autoDelete": "ldapAutoDelete",
|
||||
"defaultTotalGB": "ldapDefaultTotalGB",
|
||||
"defaultExpiryDays": "ldapDefaultExpiryDays",
|
||||
"defaultLimitIP": "ldapDefaultLimitIP",
|
||||
},
|
||||
"other": {
|
||||
"timeLocation": "timeLocation",
|
||||
"twoFactorEnable": "twoFactorEnable",
|
||||
"twoFactorToken": "twoFactorToken",
|
||||
"externalTrafficInformEnable": "externalTrafficInformEnable",
|
||||
"externalTrafficInformURI": "externalTrafficInformURI",
|
||||
"turnstileSiteKey": "turnstileSiteKey",
|
||||
"turnstileSecretKey": "turnstileSecretKey",
|
||||
"datepicker": "datepicker",
|
||||
"pageSize": "pageSize",
|
||||
"expireDiff": "expireDiff",
|
||||
"trafficDiff": "trafficDiff",
|
||||
"remarkModel": "remarkModel",
|
||||
"secret": "secret",
|
||||
"warp": "warp",
|
||||
"xrayOutboundTestUrl": "xrayOutboundTestUrl",
|
||||
},
|
||||
}
|
||||
|
||||
// flatToNestedKey maps flat keys to their [group, nestedKey] pair.
|
||||
var flatToNestedKey map[string][2]string
|
||||
|
||||
func init() {
|
||||
flatToNestedKey = make(map[string][2]string)
|
||||
for group, keys := range settingGroups {
|
||||
for nestedKey, flatKey := range keys {
|
||||
flatToNestedKey[flatKey] = [2]string{group, nestedKey}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expandToNested converts a flat map[string]string to nested map[string]any
|
||||
// using the settingGroups mapping. Keys not in any group are placed at the top level.
|
||||
func expandToNested(flat map[string]string) map[string]any {
|
||||
result := make(map[string]any)
|
||||
|
||||
// Initialize all groups
|
||||
for group := range settingGroups {
|
||||
result[group] = make(map[string]string)
|
||||
}
|
||||
|
||||
// Place each flat key into its group
|
||||
for flatKey, value := range flat {
|
||||
if pair, ok := flatToNestedKey[flatKey]; ok {
|
||||
group, nestedKey := pair[0], pair[1]
|
||||
result[group].(map[string]string)[nestedKey] = value
|
||||
} else {
|
||||
// Ungrouped keys go to top level
|
||||
result[flatKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty groups
|
||||
for group := range result {
|
||||
if m, ok := result[group].(map[string]string); ok && len(m) == 0 {
|
||||
delete(result, group)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// flattenNested converts a nested map[string]any (from JSON) to a flat map[string]string.
|
||||
// It uses settingGroups to map nested keys back to flat keys.
|
||||
func flattenNested(nested map[string]any) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
for key, val := range nested {
|
||||
switch v := val.(type) {
|
||||
case map[string]any:
|
||||
// This is a group
|
||||
if groupKeys, ok := settingGroups[key]; ok {
|
||||
for nestedKey, flatKey := range groupKeys {
|
||||
if strVal, exists := v[nestedKey]; exists {
|
||||
result[flatKey] = fmt.Sprint(strVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Top-level value (ungrouped key)
|
||||
result[key] = fmt.Sprint(val)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// tryParseNested detects whether the JSON is nested or flat format and returns a flat map.
|
||||
func tryParseNested(data []byte) (map[string]string, error) {
|
||||
// First try to detect if it's nested by checking for object values
|
||||
var probe map[string]any
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if any value is a nested object (map[string]any) — indicates nested format
|
||||
for _, v := range probe {
|
||||
if _, isNested := v.(map[string]any); isNested {
|
||||
return flattenNested(probe), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Flat format — all values are strings
|
||||
result := make(map[string]string, len(probe))
|
||||
for k, v := range probe {
|
||||
result[k] = fmt.Sprint(v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadSettings reads the JSON settings file into a map.
|
||||
|
|
@ -128,8 +311,9 @@ func loadSettings() (map[string]string, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var settings map[string]string
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
// Detect format: try nested first, fall back to flat
|
||||
settings, err := tryParseNested(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse settings file %s: %w", path, err)
|
||||
}
|
||||
// Merge missing keys from defaults so new fields are picked up on upgrade
|
||||
|
|
@ -151,9 +335,10 @@ func loadSettings() (map[string]string, error) {
|
|||
return settings, nil
|
||||
}
|
||||
|
||||
// saveSettings writes the settings map to the JSON file.
|
||||
// saveSettings writes the settings map to the JSON file in nested format.
|
||||
func saveSettings(settings map[string]string) error {
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
nested := expandToNested(settings)
|
||||
data, err := json.MarshalIndent(nested, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -770,6 +955,10 @@ func (s *SettingService) GetTurnstileSiteKey() (string, error) {
|
|||
return s.getString("turnstileSiteKey")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTurnstileSecretKey() (string, error) {
|
||||
return s.getString("turnstileSecretKey")
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||
if err := allSetting.CheckValid(); err != nil {
|
||||
return err
|
||||
|
|
|
|||
44
web/service/turnstile.go
Normal file
44
web/service/turnstile.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
|
||||
type turnstileResponse struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// VerifyTurnstile verifies a Cloudflare Turnstile token with the given secret key.
|
||||
func VerifyTurnstile(secretKey, token, remoteIP string) bool {
|
||||
form := url.Values{
|
||||
"secret": {secretKey},
|
||||
"response": {token},
|
||||
}
|
||||
if remoteIP != "" {
|
||||
form.Set("remoteip", remoteIP)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.PostForm(turnstileVerifyURL, form)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var result turnstileResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
return result.Success
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package service
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database"
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
|
|
@ -126,6 +127,35 @@ func (s *UserService) UpdateUser(id int, username string, password string) error
|
|||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) RegisterUser(username string, password string) error {
|
||||
if username == "" {
|
||||
return errors.New("username can not be empty")
|
||||
}
|
||||
if password == "" {
|
||||
return errors.New("password can not be empty")
|
||||
}
|
||||
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
user := &model.User{
|
||||
Username: username,
|
||||
Password: hashedPassword,
|
||||
Role: "user",
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
// Check for unique constraint violation
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") || strings.Contains(err.Error(), "Duplicate") {
|
||||
return errors.New("username already exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateFirstUser(username string, password string) error {
|
||||
if username == "" {
|
||||
return errors.New("username can not be empty")
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "الباسورد مطلوب"
|
||||
"wrongUsernameOrPassword" = "اسم المستخدم أو كلمة المرور أو كود المصادقة الثنائية غير صحيح."
|
||||
"successLogin" = "لقد تم تسجيل الدخول إلى حسابك بنجاح."
|
||||
"successRegister" = "تم التسجيل بنجاح، يرجى تسجيل الدخول."
|
||||
"userExists" = "اسم المستخدم موجود بالفعل"
|
||||
"errorRegister" = "فشل التسجيل"
|
||||
|
||||
[pages.index]
|
||||
"title" = "نظرة عامة"
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@
|
|||
"wrongUsernameOrPassword" = "Invalid username or password or two-factor code."
|
||||
"successLogin" = " You have successfully logged into your account."
|
||||
"successRegister" = "Registration successful, please log in."
|
||||
"userExists" = "Username already exists"
|
||||
"errorRegister" = "Registration failed"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Overview"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "Por favor ingresa la contraseña."
|
||||
"wrongUsernameOrPassword" = "Nombre de usuario, contraseña o código de dos factores incorrecto."
|
||||
"successLogin" = "Has iniciado sesión en tu cuenta correctamente."
|
||||
"successRegister" = "Registro exitoso, por favor inicia sesión."
|
||||
"userExists" = "El nombre de usuario ya existe"
|
||||
"errorRegister" = "Error en el registro"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Estado del Sistema"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "لطفا یک رمزعبور وارد کنید"
|
||||
"wrongUsernameOrPassword" = "نام کاربری، رمز عبور یا کد دو مرحلهای نامعتبر است."
|
||||
"successLogin" = "شما با موفقیت به حساب کاربری خود وارد شدید."
|
||||
"successRegister" = "ثبتنام با موفقیت انجام شد، لطفاً وارد شوید."
|
||||
"userExists" = "نام کاربری از قبل وجود دارد"
|
||||
"errorRegister" = "ثبت نام ناموفق بود"
|
||||
|
||||
[pages.index]
|
||||
"title" = "نمای کلی"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "Kata Sandi diperlukan"
|
||||
"wrongUsernameOrPassword" = "Username, kata sandi, atau kode dua faktor tidak valid."
|
||||
"successLogin" = "Anda telah berhasil masuk ke akun Anda."
|
||||
"successRegister" = "Pendaftaran berhasil, silakan masuk."
|
||||
"userExists" = "Nama pengguna sudah ada"
|
||||
"errorRegister" = "Pendaftaran gagal"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Ikhtisar"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "パスワードを入力してください"
|
||||
"wrongUsernameOrPassword" = "ユーザー名、パスワード、または二段階認証コードが無効です。"
|
||||
"successLogin" = "アカウントに正常にログインしました。"
|
||||
"successRegister" = "登録が完了しました。ログインしてください。"
|
||||
"userExists" = "ユーザー名は既に存在します"
|
||||
"errorRegister" = "登録に失敗しました"
|
||||
|
||||
[pages.index]
|
||||
"title" = "システムステータス"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "Senha é obrigatória"
|
||||
"wrongUsernameOrPassword" = "Nome de usuário, senha ou código de dois fatores inválido."
|
||||
"successLogin" = "Você entrou na sua conta com sucesso."
|
||||
"successRegister" = "Registro bem-sucedido, por favor faça login."
|
||||
"userExists" = "Nome de usuário já existe"
|
||||
"errorRegister" = "Falha no registro"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Visão Geral"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "Введите пароль"
|
||||
"wrongUsernameOrPassword" = "Неверные данные учетной записи."
|
||||
"successLogin" = "Вход выполнен успешно"
|
||||
"successRegister" = "Регистрация прошла успешно, пожалуйста, войдите."
|
||||
"userExists" = "Имя пользователя уже существует"
|
||||
"errorRegister" = "Ошибка регистрации"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Дашборд"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "Şifre gerekli"
|
||||
"wrongUsernameOrPassword" = "Geçersiz kullanıcı adı, şifre veya iki adımlı doğrulama kodu."
|
||||
"successLogin" = "Hesabınıza başarıyla giriş yaptınız."
|
||||
"successRegister" = "Kayıt başarılı, lütfen giriş yapın."
|
||||
"userExists" = "Kullanıcı adı zaten mevcut"
|
||||
"errorRegister" = "Kayıt başarısız"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Genel Bakış"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "Потрібен пароль"
|
||||
"wrongUsernameOrPassword" = "Невірне ім’я користувача, пароль або код двофакторної аутентифікації."
|
||||
"successLogin" = "Ви успішно увійшли до свого облікового запису."
|
||||
"successRegister" = "Реєстрація пройшла успішно, будь ласка, увійдіть."
|
||||
"userExists" = "Ім'я користувача вже існує"
|
||||
"errorRegister" = "Помилка реєстрації"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Огляд"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "Vui lòng nhập mật khẩu."
|
||||
"wrongUsernameOrPassword" = "Tên người dùng, mật khẩu hoặc mã xác thực hai yếu tố không hợp lệ."
|
||||
"successLogin" = "Bạn đã đăng nhập vào tài khoản thành công."
|
||||
"successRegister" = "Đăng ký thành công, vui lòng đăng nhập."
|
||||
"userExists" = "Tên người dùng đã tồn tại"
|
||||
"errorRegister" = "Đăng ký thất bại"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Trạng thái hệ thống"
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@
|
|||
"wrongUsernameOrPassword" = "用户名、密码或双重验证码无效。"
|
||||
"successLogin" = "您已成功登录您的账户。"
|
||||
"successRegister" = "注册成功,请登录。"
|
||||
"userExists" = "用户名已存在"
|
||||
"errorRegister" = "注册失败"
|
||||
|
||||
[pages.index]
|
||||
"title" = "系统状态"
|
||||
|
|
|
|||
|
|
@ -108,6 +108,9 @@
|
|||
"emptyPassword" = "請輸入密碼"
|
||||
"wrongUsernameOrPassword" = "用戶名、密碼或雙重驗證碼無效。"
|
||||
"successLogin" = "您已成功登入您的帳戶。"
|
||||
"successRegister" = "註冊成功,請登入。"
|
||||
"userExists" = "使用者名稱已存在"
|
||||
"errorRegister" = "註冊失敗"
|
||||
|
||||
[pages.index]
|
||||
"title" = "系統狀態"
|
||||
|
|
|
|||
Loading…
Reference in a new issue