From 5f83415e95b273b09351a0714f2bbeb4fbaac287 Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Thu, 2 Apr 2026 23:49:30 +0800 Subject: [PATCH] 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 --- database/db.go | 1 + database/model/model.go | 3 +- web/controller/index.go | 55 ++++++++ web/entity/entity.go | 3 +- web/service/setting.go | 199 ++++++++++++++++++++++++++- web/service/turnstile.go | 44 ++++++ web/service/user.go | 30 ++++ web/translation/translate.ar_EG.toml | 3 + web/translation/translate.en_US.toml | 2 + web/translation/translate.es_ES.toml | 3 + web/translation/translate.fa_IR.toml | 3 + web/translation/translate.id_ID.toml | 3 + web/translation/translate.ja_JP.toml | 3 + web/translation/translate.pt_BR.toml | 3 + web/translation/translate.ru_RU.toml | 3 + web/translation/translate.tr_TR.toml | 3 + web/translation/translate.uk_UA.toml | 3 + web/translation/translate.vi_VN.toml | 3 + web/translation/translate.zh_CN.toml | 2 + web/translation/translate.zh_TW.toml | 3 + 20 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 web/service/turnstile.go diff --git a/database/db.go b/database/db.go index 6b579dd9..1354d8a4 100644 --- a/database/db.go +++ b/database/db.go @@ -66,6 +66,7 @@ func initUser() error { user := &model.User{ Username: defaultUsername, Password: hashedPassword, + Role: "admin", } return db.Create(user).Error } diff --git a/database/model/model.go b/database/model/model.go index 6225df52..1865d1a9 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -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. diff --git a/web/controller/index.go b/web/controller/index.go index be289a68..fd607ab6 100644 --- a/web/controller/index.go +++ b/web/controller/index.go @@ -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) diff --git a/web/entity/entity.go b/web/entity/entity.go index 90710f88..dce37800 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -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. diff --git a/web/service/setting.go b/web/service/setting.go index de3a1b9f..a932bc52 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -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 diff --git a/web/service/turnstile.go b/web/service/turnstile.go new file mode 100644 index 00000000..34d0c455 --- /dev/null +++ b/web/service/turnstile.go @@ -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 +} diff --git a/web/service/user.go b/web/service/user.go index 6fcf17e7..8f0e47c0 100644 --- a/web/service/user.go +++ b/web/service/user.go @@ -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") diff --git a/web/translation/translate.ar_EG.toml b/web/translation/translate.ar_EG.toml index 3fbcee6e..c69fa7a0 100644 --- a/web/translation/translate.ar_EG.toml +++ b/web/translation/translate.ar_EG.toml @@ -108,6 +108,9 @@ "emptyPassword" = "الباسورد مطلوب" "wrongUsernameOrPassword" = "اسم المستخدم أو كلمة المرور أو كود المصادقة الثنائية غير صحيح." "successLogin" = "لقد تم تسجيل الدخول إلى حسابك بنجاح." +"successRegister" = "تم التسجيل بنجاح، يرجى تسجيل الدخول." +"userExists" = "اسم المستخدم موجود بالفعل" +"errorRegister" = "فشل التسجيل" [pages.index] "title" = "نظرة عامة" diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 51301bae..a60766c9 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -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" diff --git a/web/translation/translate.es_ES.toml b/web/translation/translate.es_ES.toml index 14429228..e407a0bb 100644 --- a/web/translation/translate.es_ES.toml +++ b/web/translation/translate.es_ES.toml @@ -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" diff --git a/web/translation/translate.fa_IR.toml b/web/translation/translate.fa_IR.toml index cc2220fd..b21b2c32 100644 --- a/web/translation/translate.fa_IR.toml +++ b/web/translation/translate.fa_IR.toml @@ -108,6 +108,9 @@ "emptyPassword" = "لطفا یک رمزعبور وارد کنید" "wrongUsernameOrPassword" = "نام کاربری، رمز عبور یا کد دو مرحله‌ای نامعتبر است." "successLogin" = "شما با موفقیت به حساب کاربری خود وارد شدید." +"successRegister" = "ثبت‌نام با موفقیت انجام شد، لطفاً وارد شوید." +"userExists" = "نام کاربری از قبل وجود دارد" +"errorRegister" = "ثبت نام ناموفق بود" [pages.index] "title" = "نمای کلی" diff --git a/web/translation/translate.id_ID.toml b/web/translation/translate.id_ID.toml index 65fc04af..edb3fb95 100644 --- a/web/translation/translate.id_ID.toml +++ b/web/translation/translate.id_ID.toml @@ -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" diff --git a/web/translation/translate.ja_JP.toml b/web/translation/translate.ja_JP.toml index d7ff3451..3bddb7c1 100644 --- a/web/translation/translate.ja_JP.toml +++ b/web/translation/translate.ja_JP.toml @@ -108,6 +108,9 @@ "emptyPassword" = "パスワードを入力してください" "wrongUsernameOrPassword" = "ユーザー名、パスワード、または二段階認証コードが無効です。" "successLogin" = "アカウントに正常にログインしました。" +"successRegister" = "登録が完了しました。ログインしてください。" +"userExists" = "ユーザー名は既に存在します" +"errorRegister" = "登録に失敗しました" [pages.index] "title" = "システムステータス" diff --git a/web/translation/translate.pt_BR.toml b/web/translation/translate.pt_BR.toml index dc04c98f..6f876359 100644 --- a/web/translation/translate.pt_BR.toml +++ b/web/translation/translate.pt_BR.toml @@ -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" diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index 0425db96..a973a0d2 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -108,6 +108,9 @@ "emptyPassword" = "Введите пароль" "wrongUsernameOrPassword" = "Неверные данные учетной записи." "successLogin" = "Вход выполнен успешно" +"successRegister" = "Регистрация прошла успешно, пожалуйста, войдите." +"userExists" = "Имя пользователя уже существует" +"errorRegister" = "Ошибка регистрации" [pages.index] "title" = "Дашборд" diff --git a/web/translation/translate.tr_TR.toml b/web/translation/translate.tr_TR.toml index 57b84e07..b510a686 100644 --- a/web/translation/translate.tr_TR.toml +++ b/web/translation/translate.tr_TR.toml @@ -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ış" diff --git a/web/translation/translate.uk_UA.toml b/web/translation/translate.uk_UA.toml index b08ddbec..6e42d2aa 100644 --- a/web/translation/translate.uk_UA.toml +++ b/web/translation/translate.uk_UA.toml @@ -108,6 +108,9 @@ "emptyPassword" = "Потрібен пароль" "wrongUsernameOrPassword" = "Невірне ім’я користувача, пароль або код двофакторної аутентифікації." "successLogin" = "Ви успішно увійшли до свого облікового запису." +"successRegister" = "Реєстрація пройшла успішно, будь ласка, увійдіть." +"userExists" = "Ім'я користувача вже існує" +"errorRegister" = "Помилка реєстрації" [pages.index] "title" = "Огляд" diff --git a/web/translation/translate.vi_VN.toml b/web/translation/translate.vi_VN.toml index a4d667d0..b2a54800 100644 --- a/web/translation/translate.vi_VN.toml +++ b/web/translation/translate.vi_VN.toml @@ -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" diff --git a/web/translation/translate.zh_CN.toml b/web/translation/translate.zh_CN.toml index b13e4845..e8e02d5c 100644 --- a/web/translation/translate.zh_CN.toml +++ b/web/translation/translate.zh_CN.toml @@ -114,6 +114,8 @@ "wrongUsernameOrPassword" = "用户名、密码或双重验证码无效。" "successLogin" = "您已成功登录您的账户。" "successRegister" = "注册成功,请登录。" +"userExists" = "用户名已存在" +"errorRegister" = "注册失败" [pages.index] "title" = "系统状态" diff --git a/web/translation/translate.zh_TW.toml b/web/translation/translate.zh_TW.toml index ab083f2c..f2fb4539 100644 --- a/web/translation/translate.zh_TW.toml +++ b/web/translation/translate.zh_TW.toml @@ -108,6 +108,9 @@ "emptyPassword" = "請輸入密碼" "wrongUsernameOrPassword" = "用戶名、密碼或雙重驗證碼無效。" "successLogin" = "您已成功登入您的帳戶。" +"successRegister" = "註冊成功,請登入。" +"userExists" = "使用者名稱已存在" +"errorRegister" = "註冊失敗" [pages.index] "title" = "系統狀態"