[功能] 禁用新用户注册功能,更新相关逻辑和测试

This commit is contained in:
ryan
2026-03-30 16:00:14 +08:00
parent 4b1e75f86b
commit 7d9190a8d8
8 changed files with 559 additions and 374 deletions
+2
View File
@@ -57,6 +57,8 @@ go run . --port 3000 --log-dir ./logs
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` | | `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` | | `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` | | `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
| `RegisterEnabled` | 是否允许新用户注册 | `false` |
| `PasswordRegisterEnabled` | 是否允许通过密码方式注册 | `true` |
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` | | `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` | | `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` | | `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
+1 -1
View File
@@ -31,7 +31,7 @@ var EmailVerificationEnabled = false
var GitHubOAuthEnabled = false var GitHubOAuthEnabled = false
var WeChatAuthEnabled = false var WeChatAuthEnabled = false
var TurnstileCheckEnabled = false var TurnstileCheckEnabled = false
var RegisterEnabled = true var RegisterEnabled = false
var SMTPServer = "" var SMTPServer = ""
var SMTPPort = 587 var SMTPPort = 587
+15 -13
View File
@@ -23,19 +23,21 @@ func GetStatus(c *gin.Context) {
"success": true, "success": true,
"message": "", "message": "",
"data": gin.H{ "data": gin.H{
"version": common.Version, "version": common.Version,
"start_time": common.StartTime, "start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled, "email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled, "github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId, "github_client_id": common.GitHubClientId,
"system_name": common.SystemName, "system_name": common.SystemName,
"home_page_link": common.HomePageLink, "home_page_link": common.HomePageLink,
"footer_html": common.Footer, "footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL, "wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled, "wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress, "server_address": common.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled, "turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey, "turnstile_site_key": common.TurnstileSiteKey,
"register_enabled": common.RegisterEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
}, },
}) })
return return
+30
View File
@@ -0,0 +1,30 @@
package model
import (
"openflare/common"
"testing"
)
func TestInitOptionMapDefaultsRegisterDisabled(t *testing.T) {
previousRegisterEnabled := common.RegisterEnabled
previousOptionMap := common.OptionMap
previousDB := DB
t.Cleanup(func() {
common.RegisterEnabled = previousRegisterEnabled
common.OptionMap = previousOptionMap
DB = previousDB
})
DB = openTestSQLiteDB(t, "options-defaults.db")
common.RegisterEnabled = false
common.OptionMap = nil
InitOptionMap()
if got := common.OptionMap["RegisterEnabled"]; got != "false" {
t.Fatalf("expected RegisterEnabled default to be false, got %q", got)
}
if common.RegisterEnabled {
t.Fatal("expected RegisterEnabled to remain false after InitOptionMap")
}
}
@@ -1,128 +1,170 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
import { useAuth } from '@/components/providers/auth-provider'; import { useAuth } from '@/components/providers/auth-provider';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { login } from '@/features/auth/api/auth'; import { login } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public'; import { getPublicStatus } from '@/features/auth/api/public';
import { import {
AuthButton, AuthButton,
AuthFormField, AuthFormField,
AuthInput, AuthInput,
SecondaryButton, SecondaryButton,
} from '@/features/auth/components/auth-form-primitives'; } from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard'; import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const loginSchema = z.object({ const TEXT = {
username: z.string().min(1, '请输入用户名'), usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
password: z.string().min(1, '请输入密码'), passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
}); loginFailed: '\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
githubUnavailable: 'GitHub \u767b\u5f55\u5f53\u524d\u4e0d\u53ef\u7528\u3002',
type LoginFormValues = z.infer<typeof loginSchema>; title: '\u7528\u6237\u767b\u5f55',
username: '\u7528\u6237\u540d',
export function LoginForm() { password: '\u5bc6\u7801',
const router = useRouter(); loginPending: '\u767b\u5f55\u4e2d...',
const searchParams = useSearchParams(); login: '\u767b\u5f55',
const { setUser } = useAuth(); githubLogin: 'GitHub \u767b\u5f55',
const [errorMessage, setErrorMessage] = useState(''); forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
const redirect = searchParams?.get('redirect') || '/'; register: '\u6ce8\u518c',
};
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema), const loginSchema = z.object({
defaultValues: { username: z.string().min(1, TEXT.usernameRequired),
username: '', password: z.string().min(1, TEXT.passwordRequired),
password: '', });
},
}); type LoginFormValues = z.infer<typeof loginSchema>;
const statusQuery = useQuery({ export function LoginForm() {
queryKey: ['public-status'], const router = useRouter();
queryFn: getPublicStatus, const searchParams = useSearchParams();
}); const { setUser } = useAuth();
const [errorMessage, setErrorMessage] = useState('');
const loginMutation = useMutation({ const redirect = searchParams?.get('redirect') || '/';
mutationFn: login,
onSuccess: (user) => { const form = useForm<LoginFormValues>({
setUser(user); resolver: zodResolver(loginSchema),
router.replace(redirect); defaultValues: {
}, username: '',
onError: (error: Error) => { password: '',
setErrorMessage(error.message || '登录失败,请稍后重试。'); },
}, });
});
const statusQuery = useQuery({
const handleSubmit = form.handleSubmit((values) => { queryKey: ['public-status'],
setErrorMessage(''); queryFn: getPublicStatus,
loginMutation.mutate(values); });
});
const canUsePasswordRegister =
const handleGitHubLogin = () => { (statusQuery.data?.register_enabled ?? false) &&
const clientId = statusQuery.data?.github_client_id; (statusQuery.data?.password_register_enabled ?? false);
if (!clientId) {
setErrorMessage('GitHub 登录当前不可用。'); const loginMutation = useMutation({
return; mutationFn: login,
} onSuccess: (user) => {
setUser(user);
const authorizeUrl = new URL('https://github.com/login/oauth/authorize'); router.replace(redirect);
authorizeUrl.searchParams.set('client_id', clientId); },
authorizeUrl.searchParams.set('scope', 'user:email'); onError: (error: Error) => {
window.location.href = authorizeUrl.toString(); setErrorMessage(error.message || TEXT.loginFailed);
}; },
});
return (
<PublicAuthGuard> const handleSubmit = form.handleSubmit((values) => {
<AppCard title='用户登录'> setErrorMessage('');
<form className='space-y-4' onSubmit={handleSubmit}> loginMutation.mutate(values);
<AuthFormField label='用户名'> });
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
{form.formState.errors.username ? ( const handleGitHubLogin = () => {
<span className='text-xs text-[var(--status-danger-foreground)]'> const clientId = statusQuery.data?.github_client_id;
{form.formState.errors.username.message} if (!clientId) {
</span> setErrorMessage(TEXT.githubUnavailable);
) : null} return;
</AuthFormField> }
<AuthFormField label='密码'> const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
<AuthInput type='password' placeholder='请输入密码' {...form.register('password')} /> authorizeUrl.searchParams.set('client_id', clientId);
{form.formState.errors.password ? ( authorizeUrl.searchParams.set('scope', 'user:email');
<span className='text-xs text-[var(--status-danger-foreground)]'> window.location.href = authorizeUrl.toString();
{form.formState.errors.password.message} };
</span>
) : null} return (
</AuthFormField> <PublicAuthGuard>
<AppCard title={TEXT.title}>
{errorMessage ? <InlineMessage tone='danger' message={errorMessage} /> : null} <form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label={TEXT.username}>
<div className='flex flex-col gap-3 sm:flex-row'> <AuthInput
<AuthButton type='submit' disabled={loginMutation.isPending}> placeholder={TEXT.username}
{loginMutation.isPending ? '登录中...' : '登录'} {...form.register('username')}
</AuthButton> />
{statusQuery.data?.github_oauth ? ( {form.formState.errors.username ? (
<SecondaryButton type='button' onClick={handleGitHubLogin} className='w-full sm:w-auto'> <span className='text-xs text-[var(--status-danger-foreground)]'>
GitHub 登录 {form.formState.errors.username.message}
</SecondaryButton> </span>
) : null} ) : null}
</div> </AuthFormField>
</form>
<AuthFormField label={TEXT.password}>
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'> <AuthInput
<Link href='/reset' className='text-[var(--brand-primary)] transition hover:opacity-80'> type='password'
忘记密码? placeholder={TEXT.password}
</Link> {...form.register('password')}
<span>·</span> />
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'> {form.formState.errors.password ? (
注册 <span className='text-xs text-[var(--status-danger-foreground)]'>
</Link> {form.formState.errors.password.message}
</div> </span>
</AppCard> ) : null}
</PublicAuthGuard> </AuthFormField>
);
} {errorMessage ? (
<InlineMessage tone='danger' message={errorMessage} />
) : null}
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthButton type='submit' disabled={loginMutation.isPending}>
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
</AuthButton>
{statusQuery.data?.github_oauth ? (
<SecondaryButton
type='button'
onClick={handleGitHubLogin}
className='w-full sm:w-auto'
>
{TEXT.githubLogin}
</SecondaryButton>
) : null}
</div>
</form>
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
<Link
href='/reset'
className='text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.forgotPassword}
</Link>
{canUsePasswordRegister ? (
<>
<span>|</span>
<Link
href='/register'
className='text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.register}
</Link>
</>
) : null}
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -1,225 +1,332 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget'; import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { import {
register as registerUser, register as registerUser,
sendEmailVerification, sendEmailVerification,
} from '@/features/auth/api/auth'; } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public'; import { getPublicStatus } from '@/features/auth/api/public';
import { import {
AuthButton, AuthButton,
AuthFormField, AuthFormField,
AuthInput, AuthInput,
SecondaryButton, SecondaryButton,
} from '@/features/auth/components/auth-form-primitives'; } from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard'; import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const baseSchemaObject = z.object({ const TEXT = {
username: z.string().min(1, '请输入用户名').max(12, '用户名最长 12 位'), title: '\u65b0\u7528\u6237\u6ce8\u518c',
password: z.string().min(8, '密码至少 8 位').max(20, '密码最长 20 位'), description:
password2: z.string().min(8, '请再次输入密码'), '\u517c\u5bb9\u73b0\u6709\u5bc6\u7801\u6ce8\u518c\u94fe\u8def\uff0c\u540e\u7eed\u53ef\u7ee7\u7eed\u6269\u5c55\u7b2c\u4e09\u65b9\u6ce8\u518c\u3002',
email: z.string().optional(), usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
verification_code: z.string().optional(), usernameTooLong: '\u7528\u6237\u540d\u6700\u957f 12 \u4f4d',
}); passwordTooShort: '\u5bc6\u7801\u81f3\u5c11 8 \u4f4d',
passwordTooLong: '\u5bc6\u7801\u6700\u957f 20 \u4f4d',
const baseSchema = baseSchemaObject.refine((data) => data.password === data.password2, { passwordRepeatRequired: '\u8bf7\u518d\u6b21\u8f93\u5165\u5bc6\u7801',
message: '两次输入的密码不一致', passwordMismatch: '\u4e24\u6b21\u8f93\u5165\u7684\u5bc6\u7801\u4e0d\u4e00\u81f4',
path: ['password2'], emailInvalid: '\u8bf7\u8f93\u5165\u6709\u6548\u90ae\u7bb1\u5730\u5740',
}); codeRequired: '\u8bf7\u8f93\u5165\u9a8c\u8bc1\u7801',
registerFailed: '\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
type RegisterFormValues = z.infer<typeof baseSchema>; emailRequired: '\u8bf7\u8f93\u5165\u90ae\u7bb1\u5730\u5740',
verificationSent:
export function RegisterForm() { '\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f\uff0c\u8bf7\u68c0\u67e5\u90ae\u7bb1\u3002',
const router = useRouter(); verificationFailed:
const [turnstileToken, setTurnstileToken] = useState(''); '\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null); turnstileRequired: '\u8bf7\u5148\u5b8c\u6210\u4eba\u673a\u9a8c\u8bc1\u3002',
registerClosed:
const statusQuery = useQuery({ '\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u65b0\u7528\u6237\u6ce8\u518c\u3002',
queryKey: ['public-status'], passwordRegisterClosed:
queryFn: getPublicStatus, '\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u5bc6\u7801\u6ce8\u518c\uff0c\u8bf7\u4f7f\u7528\u7b2c\u4e09\u65b9\u767b\u5f55\u5165\u53e3\u5b8c\u6210\u6ce8\u518c\u3002',
}); hasAccount: '\u5df2\u6709\u8d26\u53f7\uff1f',
backToLogin:
const needsEmailVerification = statusQuery.data?.email_verification ?? false; '\u8fd4\u56de\u767b\u5f55\u9875\u67e5\u770b\u53ef\u7528\u5165\u53e3\uff1a',
const needsTurnstile = statusQuery.data?.turnstile_check ?? false; clickLogin: '\u70b9\u51fb\u767b\u5f55',
username: '\u7528\u6237\u540d',
const schema = useMemo(() => { usernameHint: '\u6700\u957f 12 \u4f4d',
if (!needsEmailVerification) { password: '\u5bc6\u7801',
return baseSchema; passwordHint: '\u6700\u77ed 8 \u4f4d\uff0c\u6700\u957f 20 \u4f4d',
} passwordConfirm: '\u786e\u8ba4\u5bc6\u7801',
email: '\u90ae\u7bb1\u5730\u5740',
return baseSchemaObject emailCode: '\u90ae\u7bb1\u9a8c\u8bc1\u7801',
.extend({ getCode: '\u83b7\u53d6\u9a8c\u8bc1\u7801',
email: z.string().email('请输入有效邮箱地址'), gettingCode: '\u53d1\u9001\u4e2d...',
verification_code: z.string().min(1, '请输入验证码'), register: '\u6ce8\u518c',
}) registering: '\u6ce8\u518c\u4e2d...',
.refine((data) => data.password === data.password2, { };
message: '两次输入的密码不一致',
path: ['password2'], const baseSchemaObject = z.object({
}); username: z.string().min(1, TEXT.usernameRequired).max(12, TEXT.usernameTooLong),
}, [needsEmailVerification]); password: z.string().min(8, TEXT.passwordTooShort).max(20, TEXT.passwordTooLong),
password2: z.string().min(8, TEXT.passwordRepeatRequired),
const form = useForm<RegisterFormValues>({ email: z.string().optional(),
resolver: zodResolver(schema), verification_code: z.string().optional(),
defaultValues: { });
username: '',
password: '', const baseSchema = baseSchemaObject.refine(
password2: '', (data) => data.password === data.password2,
email: '', {
verification_code: '', message: TEXT.passwordMismatch,
}, path: ['password2'],
}); },
);
const registerMutation = useMutation({
mutationFn: (values: RegisterFormValues) => type RegisterFormValues = z.infer<typeof baseSchema>;
registerUser(
{ export function RegisterForm() {
username: values.username, const router = useRouter();
password: values.password, const [turnstileToken, setTurnstileToken] = useState('');
email: values.email, const [message, setMessage] = useState<{
verification_code: values.verification_code, tone: 'success' | 'danger' | 'info';
}, text: string;
turnstileToken || undefined, } | null>(null);
),
onSuccess: () => { const statusQuery = useQuery({
router.replace('/login'); queryKey: ['public-status'],
}, queryFn: getPublicStatus,
onError: (error: Error) => { });
setMessage({ tone: 'danger', text: error.message || '注册失败,请稍后重试。' });
}, const needsEmailVerification = statusQuery.data?.email_verification ?? false;
}); const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
const registerEnabled = statusQuery.data?.register_enabled ?? false;
const verificationMutation = useMutation({ const passwordRegisterEnabled =
mutationFn: async () => { statusQuery.data?.password_register_enabled ?? false;
const email = form.getValues('email');
if (!email) { const schema = useMemo(() => {
form.setError('email', { message: '请输入邮箱地址' }); if (!needsEmailVerification) {
return; return baseSchema;
} }
await sendEmailVerification(email, turnstileToken || undefined);
}, return baseSchemaObject
onSuccess: () => { .extend({
setMessage({ tone: 'success', text: '验证码发送成功,请检查邮箱。' }); email: z.string().email(TEXT.emailInvalid),
}, verification_code: z.string().min(1, TEXT.codeRequired),
onError: (error: Error) => { })
setMessage({ tone: 'danger', text: error.message || '验证码发送失败,请稍后重试。' }); .refine((data) => data.password === data.password2, {
}, message: TEXT.passwordMismatch,
}); path: ['password2'],
});
const handleSubmit = form.handleSubmit((values) => { }, [needsEmailVerification]);
setMessage(null);
if (needsTurnstile && !turnstileToken) { const form = useForm<RegisterFormValues>({
setMessage({ tone: 'info', text: '请先完成人机验证。' }); resolver: zodResolver(schema),
return; defaultValues: {
} username: '',
registerMutation.mutate(values); password: '',
}); password2: '',
email: '',
return ( verification_code: '',
<PublicAuthGuard> },
<AppCard title='新用户注册' description='兼容现有密码注册链路,后续可继续扩展第三方注册。'> });
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='用户名' hint='最长 12 位'> const registerMutation = useMutation({
<AuthInput placeholder='请输入用户名' {...form.register('username')} /> mutationFn: (values: RegisterFormValues) =>
{form.formState.errors.username ? ( registerUser(
<span className='text-xs text-[var(--status-danger-foreground)]'> {
{form.formState.errors.username.message} username: values.username,
</span> password: values.password,
) : null} email: values.email,
</AuthFormField> verification_code: values.verification_code,
},
<AuthFormField label='密码' hint='最短 8 位,最长 20 位'> turnstileToken || undefined,
<AuthInput type='password' placeholder='请输入密码' {...form.register('password')} /> ),
{form.formState.errors.password ? ( onSuccess: () => {
<span className='text-xs text-[var(--status-danger-foreground)]'> router.replace('/login');
{form.formState.errors.password.message} },
</span> onError: (error: Error) => {
) : null} setMessage({ tone: 'danger', text: error.message || TEXT.registerFailed });
</AuthFormField> },
});
<AuthFormField label='确认密码'>
<AuthInput type='password' placeholder='请再次输入密码' {...form.register('password2')} /> const verificationMutation = useMutation({
{form.formState.errors.password2 ? ( mutationFn: async () => {
<span className='text-xs text-[var(--status-danger-foreground)]'> const email = form.getValues('email');
{form.formState.errors.password2.message} if (!email) {
</span> form.setError('email', { message: TEXT.emailRequired });
) : null} return;
</AuthFormField> }
await sendEmailVerification(email, turnstileToken || undefined);
{needsEmailVerification ? ( },
<> onSuccess: () => {
<AuthFormField label='邮箱地址'> setMessage({ tone: 'success', text: TEXT.verificationSent });
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} /> },
{form.formState.errors.email ? ( onError: (error: Error) => {
<span className='text-xs text-[var(--status-danger-foreground)]'> setMessage({
{form.formState.errors.email.message} tone: 'danger',
</span> text: error.message || TEXT.verificationFailed,
) : null} });
</AuthFormField> },
});
<AuthFormField label='邮箱验证码'>
<div className='flex flex-col gap-3 sm:flex-row'> const handleSubmit = form.handleSubmit((values) => {
<AuthInput setMessage(null);
placeholder='请输入验证码' if (needsTurnstile && !turnstileToken) {
className='flex-1' setMessage({ tone: 'info', text: TEXT.turnstileRequired });
{...form.register('verification_code')} return;
/> }
<SecondaryButton registerMutation.mutate(values);
type='button' });
onClick={() => {
if (needsTurnstile && !turnstileToken) { return (
setMessage({ tone: 'info', text: '请先完成人机验证。' }); <PublicAuthGuard>
return; <AppCard title={TEXT.title} description={TEXT.description}>
} {!registerEnabled ? (
setMessage(null); <div className='space-y-4'>
verificationMutation.mutate(); <InlineMessage tone='info' message={TEXT.registerClosed} />
}} <div className='text-sm text-[var(--foreground-secondary)]'>
disabled={verificationMutation.isPending} {TEXT.hasAccount}
> <Link
{verificationMutation.isPending ? '发送中...' : '获取验证码'} href='/login'
</SecondaryButton> className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
</div> >
{form.formState.errors.verification_code ? ( {TEXT.clickLogin}
<span className='text-xs text-[var(--status-danger-foreground)]'> </Link>
{form.formState.errors.verification_code.message} </div>
</span> </div>
) : null} ) : !passwordRegisterEnabled ? (
</AuthFormField> <div className='space-y-4'>
</> <InlineMessage tone='info' message={TEXT.passwordRegisterClosed} />
) : null} <div className='text-sm text-[var(--foreground-secondary)]'>
{TEXT.backToLogin}
{needsTurnstile && statusQuery.data?.turnstile_site_key ? ( <Link
<TurnstileWidget href='/login'
siteKey={statusQuery.data.turnstile_site_key} className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
onVerify={(token) => setTurnstileToken(token)} >
onExpire={() => setTurnstileToken('')} {TEXT.clickLogin}
onError={() => setTurnstileToken('')} </Link>
/> </div>
) : null} </div>
) : (
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null} <>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthButton type='submit' disabled={registerMutation.isPending}> <AuthFormField label={TEXT.username} hint={TEXT.usernameHint}>
{registerMutation.isPending ? '注册中...' : '注册'} <AuthInput
</AuthButton> placeholder={TEXT.username}
</form> {...form.register('username')}
/>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'> {form.formState.errors.username ? (
已有账户? <span className='text-xs text-[var(--status-danger-foreground)]'>
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'> {form.formState.errors.username.message}
点击登录 </span>
</Link> ) : null}
</div> </AuthFormField>
</AppCard>
</PublicAuthGuard> <AuthFormField label={TEXT.password} hint={TEXT.passwordHint}>
); <AuthInput
} type='password'
placeholder={TEXT.password}
{...form.register('password')}
/>
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.passwordConfirm}>
<AuthInput
type='password'
placeholder={TEXT.passwordConfirm}
{...form.register('password2')}
/>
{form.formState.errors.password2 ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password2.message}
</span>
) : null}
</AuthFormField>
{needsEmailVerification ? (
<>
<AuthFormField label={TEXT.email}>
<AuthInput
type='email'
placeholder={TEXT.email}
{...form.register('email')}
/>
{form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.emailCode}>
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthInput
placeholder={TEXT.emailCode}
className='flex-1'
{...form.register('verification_code')}
/>
<SecondaryButton
type='button'
onClick={() => {
if (needsTurnstile && !turnstileToken) {
setMessage({
tone: 'info',
text: TEXT.turnstileRequired,
});
return;
}
setMessage(null);
verificationMutation.mutate();
}}
disabled={verificationMutation.isPending}
>
{verificationMutation.isPending
? TEXT.gettingCode
: TEXT.getCode}
</SecondaryButton>
</div>
{form.formState.errors.verification_code ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.verification_code.message}
</span>
) : null}
</AuthFormField>
</>
) : null}
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
<TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)}
onExpire={() => setTurnstileToken('')}
onError={() => setTurnstileToken('')}
/>
) : null}
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
<AuthButton type='submit' disabled={registerMutation.isPending}>
{registerMutation.isPending ? TEXT.registering : TEXT.register}
</AuthButton>
</form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
{TEXT.hasAccount}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</>
)}
</AppCard>
</PublicAuthGuard>
);
}
@@ -61,7 +61,7 @@ const defaultSystemFields = {
GitHubOAuthEnabled: false, GitHubOAuthEnabled: false,
WeChatAuthEnabled: false, WeChatAuthEnabled: false,
TurnstileCheckEnabled: false, TurnstileCheckEnabled: false,
RegisterEnabled: true, RegisterEnabled: false,
SMTPServer: '', SMTPServer: '',
SMTPPort: '587', SMTPPort: '587',
SMTPAccount: '', SMTPAccount: '',
@@ -348,7 +348,7 @@ export function SettingsPage() {
GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false), GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false),
WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false), WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false),
TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false), TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false),
RegisterEnabled: toBoolean(optionMap.RegisterEnabled, true), RegisterEnabled: toBoolean(optionMap.RegisterEnabled, false),
SMTPServer: optionMap.SMTPServer ?? '', SMTPServer: optionMap.SMTPServer ?? '',
SMTPPort: optionMap.SMTPPort ?? '587', SMTPPort: optionMap.SMTPPort ?? '587',
SMTPAccount: optionMap.SMTPAccount ?? '', SMTPAccount: optionMap.SMTPAccount ?? '',
+7 -5
View File
@@ -8,8 +8,10 @@ export interface PublicStatus {
home_page_link: string; home_page_link: string;
footer_html: string; footer_html: string;
wechat_qrcode: string; wechat_qrcode: string;
wechat_login: boolean; wechat_login: boolean;
server_address: string; server_address: string;
turnstile_check: boolean; turnstile_check: boolean;
turnstile_site_key: string; turnstile_site_key: string;
} register_enabled: boolean;
password_register_enabled: boolean;
}