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