feat: add registration tab with Cloudflare Turnstile support

Add a register tab on the login page with username, password, confirm
password fields and Cloudflare Turnstile widget. The site key is
configurable via x-ui.json and exposed through a public endpoint.
This commit is contained in:
Sora39831 2026-04-02 20:18:48 +08:00
parent 82d93da36e
commit 5103d57879
6 changed files with 168 additions and 37 deletions

View file

@ -44,6 +44,7 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.POST("/login", a.login)
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
g.POST("/getTurnstileSiteKey", a.getTurnstileSiteKey)
}
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
@ -131,3 +132,11 @@ func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
jsonObj(c, status, nil)
}
}
// getTurnstileSiteKey returns the Cloudflare Turnstile site key for the registration form.
func (a *IndexController) getTurnstileSiteKey(c *gin.Context) {
siteKey, err := a.settingService.GetTurnstileSiteKey()
if err == nil {
jsonObj(c, siteKey, nil)
}
}

View file

@ -103,7 +103,9 @@ type AllSetting struct {
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
// JSON subscription routing rules
// Registration settings
TurnstileSiteKey string `json:"turnstileSiteKey" form:"turnstileSiteKey"`
}
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.

View file

@ -59,39 +59,83 @@
</a-row>
<a-row type="flex" justify="center">
<a-col span="24">
<a-form @submit.prevent="login">
<a-space direction="vertical" size="middle">
<a-form-item>
<a-input autocomplete="username" name="username" v-model.trim="user.username"
placeholder='{{ i18n "username" }}' autofocus required>
<a-icon slot="prefix" type="user" class="fs-1rem"></a-icon>
</a-input>
</a-form-item>
<a-form-item>
<a-input-password autocomplete="current-password" name="password" v-model.trim="user.password"
placeholder='{{ i18n "password" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password>
</a-form-item>
<a-form-item v-if="twoFactorEnable">
<a-input autocomplete="one-time-code" name="twoFactorCode" v-model.trim="user.twoFactorCode"
placeholder='{{ i18n "twoFactorCode" }}' required>
<a-icon slot="prefix" type="key" class="fs-1rem"></a-icon>
</a-input>
</a-form-item>
<a-form-item>
<a-row justify="center" class="centered">
<div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem"
:style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning"
:icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit">
[[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]]
</a-button>
</div>
</a-row>
</a-form-item>
</a-space>
</a-form>
<a-tabs :active-key="activeTab" @change="switchTab" centered>
<a-tab-pane key="login" tab='{{ i18n "pages.login.loginTab" }}'>
<a-form @submit.prevent="doLogin">
<a-space direction="vertical" size="middle">
<a-form-item>
<a-input autocomplete="username" name="username" v-model.trim="user.username"
placeholder='{{ i18n "username" }}' autofocus required>
<a-icon slot="prefix" type="user" class="fs-1rem"></a-icon>
</a-input>
</a-form-item>
<a-form-item>
<a-input-password autocomplete="current-password" name="password" v-model.trim="user.password"
placeholder='{{ i18n "password" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password>
</a-form-item>
<a-form-item v-if="twoFactorEnable">
<a-input autocomplete="one-time-code" name="twoFactorCode" v-model.trim="user.twoFactorCode"
placeholder='{{ i18n "twoFactorCode" }}' required>
<a-icon slot="prefix" type="key" class="fs-1rem"></a-icon>
</a-input>
</a-form-item>
<a-form-item>
<a-row justify="center" class="centered">
<div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem"
:style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning"
:icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit">
[[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]]
</a-button>
</div>
</a-row>
</a-form-item>
</a-space>
</a-form>
</a-tab-pane>
<a-tab-pane key="register" tab='{{ i18n "pages.login.registerTab" }}'>
<a-form @submit.prevent="doRegister">
<a-space direction="vertical" size="middle">
<a-form-item>
<a-input autocomplete="username" name="reg-username" v-model.trim="regUser.username"
placeholder='{{ i18n "username" }}' required>
<a-icon slot="prefix" type="user" class="fs-1rem"></a-icon>
</a-input>
</a-form-item>
<a-form-item>
<a-input-password autocomplete="new-password" name="reg-password" v-model.trim="regUser.password"
placeholder='{{ i18n "password" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password>
</a-form-item>
<a-form-item>
<a-input-password autocomplete="new-password" name="reg-confirm-password" v-model.trim="regUser.confirmPassword"
placeholder='{{ i18n "pages.login.confirmPassword" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password>
</a-form-item>
<a-form-item>
<div class="cf-turnstile-wrapper">
<div class="cf-turnstile" :data-sitekey="turnstileSiteKey" data-callback="onTurnstileCallback"></div>
</div>
</a-form-item>
<a-form-item>
<a-row justify="center" class="centered">
<div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem"
:style="loadingStates.registerSpinning ? 'width: 52px' : 'display: inline-block'">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.registerSpinning"
:icon="loadingStates.registerSpinning ? 'poweroff' : undefined" html-type="submit">
[[ loadingStates.registerSpinning ? '' : '{{ i18n "pages.login.registerTab" }}' ]]
</a-button>
</div>
</a-row>
</a-form-item>
</a-space>
</a-form>
</a-tab-pane>
</a-tabs>
</a-col>
</a-row>
</template>
@ -103,23 +147,39 @@
{{template "page/body_scripts" .}}
{{template "component/aThemeSwitch" .}}
<script>
var turnstileToken = '';
function onTurnstileCallback(token) {
turnstileToken = token;
}
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
data: {
themeSwitcher,
loadingStates: { fetched: false, spinning: false },
loadingStates: { fetched: false, spinning: false, registerSpinning: false },
activeTab: 'login',
user: { username: "", password: "", twoFactorCode: "" },
regUser: { username: "", password: "", confirmPassword: "" },
twoFactorEnable: false,
lang: "",
animationStarted: false
animationStarted: false,
turnstileSiteKey: '',
},
async mounted() {
this.lang = LanguageManager.getLanguage();
this.twoFactorEnable = await this.getTwoFactorEnable();
this.turnstileSiteKey = await this.getTurnstileSiteKey();
if (this.turnstileSiteKey) {
this.loadTurnstileScript();
}
},
methods: {
async login() {
switchTab(key) {
this.activeTab = key;
},
async doLogin() {
this.loadingStates.spinning = true;
const msg = await HttpUtil.post('/login', this.user);
if (msg.success) {
@ -127,6 +187,47 @@
}
this.loadingStates.spinning = false;
},
async doRegister() {
if (this.regUser.password !== this.regUser.confirmPassword) {
this.$message.error('{{ i18n "pages.login.passwordMismatch" }}');
return;
}
if (!turnstileToken) {
this.$message.error('{{ i18n "pages.login.turnstileRequired" }}');
return;
}
this.loadingStates.registerSpinning = true;
const msg = await HttpUtil.post('/register', {
username: this.regUser.username,
password: this.regUser.password,
turnstileToken: turnstileToken,
});
if (msg.success) {
this.$message.success('{{ i18n "pages.login.toasts.successRegister" }}');
this.activeTab = 'login';
this.user.username = this.regUser.username;
this.regUser = { username: "", password: "", confirmPassword: "" };
turnstileToken = '';
if (window.turnstile) {
turnstile.reset('.cf-turnstile');
}
}
this.loadingStates.registerSpinning = false;
},
loadTurnstileScript() {
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
script.defer = true;
document.head.appendChild(script);
},
async getTurnstileSiteKey() {
const msg = await HttpUtil.post('/getTurnstileSiteKey');
if (msg.success) {
return msg.obj;
}
return '';
},
async getTwoFactorEnable() {
const msg = await HttpUtil.post('/getTwoFactorEnable');
if (msg.success) {

View file

@ -104,6 +104,9 @@ var defaultValueMap = map[string]string{
"ldapDefaultTotalGB": "0",
"ldapDefaultExpiryDays": "0",
"ldapDefaultLimitIP": "0",
// Registration settings
"turnstileSiteKey": "",
}
// loadSettings reads the JSON settings file into a map.
@ -746,6 +749,10 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
return s.getInt("ldapDefaultLimitIP")
}
func (s *SettingService) GetTurnstileSiteKey() (string, error) {
return s.getString("turnstileSiteKey")
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil {
return err

View file

@ -101,6 +101,11 @@
"hello" = "Hello"
"title" = "Welcome"
"loginAgain" = "Your session has expired, please log in again"
"loginTab" = "Login"
"registerTab" = "Register"
"confirmPassword" = "Confirm Password"
"passwordMismatch" = "Passwords do not match"
"turnstileRequired" = "Please complete the verification"
[pages.login.toasts]
"invalidFormData" = "The Input data format is invalid."
@ -108,6 +113,7 @@
"emptyPassword" = "Password is required"
"wrongUsernameOrPassword" = "Invalid username or password or two-factor code."
"successLogin" = " You have successfully logged into your account."
"successRegister" = "Registration successful, please log in."
[pages.index]
"title" = "Overview"

View file

@ -101,6 +101,11 @@
"hello" = "你好"
"title" = "欢迎"
"loginAgain" = "登录时效已过,请重新登录"
"loginTab" = "登录"
"registerTab" = "注册"
"confirmPassword" = "确认密码"
"passwordMismatch" = "两次输入的密码不一致"
"turnstileRequired" = "请完成验证"
[pages.login.toasts]
"invalidFormData" = "数据格式错误"
@ -108,6 +113,7 @@
"emptyPassword" = "请输入密码"
"wrongUsernameOrPassword" = "用户名、密码或双重验证码无效。"
"successLogin" = "您已成功登录您的账户。"
"successRegister" = "注册成功,请登录。"
[pages.index]
"title" = "系统状态"