feat(auth): 支持开放注册
- 新增注册接口与注册页面,开放注册时可创建普通用户并自动登录。 - 公共配置增加开放注册状态,登录页可跳转注册入口。 - 补充注册流程测试,验证关闭注册、账号创建与登录行为。
This commit is contained in:
@@ -44,9 +44,10 @@ export type SystemSettings = {
|
||||
mailRefreshSeconds: number
|
||||
}
|
||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
|
||||
export type PublicSettings = { turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number }
|
||||
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number }
|
||||
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
|
||||
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string }
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
@@ -78,6 +79,7 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
|
||||
export const api = {
|
||||
publicSettings: () => request<PublicSettings>("/api/public/settings"),
|
||||
register: (payload: RegisterPayload) => request<{ user: User }>("/api/auth/register", { method: "POST", body: JSON.stringify(payload) }),
|
||||
login: (payload: LoginPayload) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
|
||||
logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }),
|
||||
me: () => request<{ user: User }>("/api/me"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { ProtectedLayout } from "@/components/protected-layout"
|
||||
import { LoginPage } from "@/pages/login"
|
||||
import { RegisterPage } from "@/pages/register"
|
||||
import { MailPage } from "@/pages/mail"
|
||||
import { AdminPage } from "@/pages/admin"
|
||||
import { ProfilePage } from "@/pages/profile"
|
||||
@@ -14,6 +15,7 @@ import "./index.css"
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/register", element: <RegisterPage /> },
|
||||
{ path: "/", element: <ProtectedLayout />, children: [
|
||||
{ index: true, element: <MailPage /> },
|
||||
{ path: "mail", element: <Navigate to="/" replace /> },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { Navigate } from "react-router-dom"
|
||||
import { Link, Navigate } from "react-router-dom"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { api } from "@/lib/api"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
@@ -42,11 +42,11 @@ export function LoginPage() {
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" />
|
||||
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" />
|
||||
<Input id="password" name="password" type="password" autoComplete="current-password" required className="h-11 text-base" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -62,6 +62,11 @@ export function LoginPage() {
|
||||
{login.isPending ? "登录中..." : challengeToken ? "验证登录" : "登录"}
|
||||
</Button>
|
||||
{challengeToken && <Button type="button" variant="ghost" className="w-full" onClick={() => setChallengeToken("")}>返回登录</Button>}
|
||||
{!challengeToken && publicSettings.data?.openRegistration && (
|
||||
<Button type="button" variant="ghost" className="w-full" asChild>
|
||||
<Link to="/register">注册账号</Link>
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +82,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
|
||||
export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
|
||||
const ref = React.useRef<HTMLDivElement | null>(null)
|
||||
React.useEffect(() => {
|
||||
if (!siteKey || !ref.current) return
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from "react"
|
||||
import { Link, Navigate, useNavigate } from "react-router-dom"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { api } from "@/lib/api"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { TurnstileBox } from "@/pages/login"
|
||||
|
||||
export function RegisterPage() {
|
||||
const me = useMe()
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const [turnstileToken, setTurnstileToken] = React.useState("")
|
||||
const register = useMutation({
|
||||
mutationFn: (form: FormData) => {
|
||||
const password = String(form.get("password") || "")
|
||||
const confirmPassword = String(form.get("confirmPassword") || "")
|
||||
if (password !== confirmPassword) throw new Error("两次输入的密码不一致")
|
||||
return api.register({
|
||||
email: String(form.get("email") || ""),
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
password,
|
||||
turnstileToken,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["me"] })
|
||||
toast({ title: "注册成功" })
|
||||
navigate("/profile", { replace: true })
|
||||
},
|
||||
onError: (e) => toast({ title: "注册失败", description: e.message }),
|
||||
})
|
||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||
if (me.data?.user) return <Navigate to="/" replace />
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<div className="w-full max-w-[360px]">
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">注册账号</h1>
|
||||
</div>
|
||||
{publicSettings.isSuccess && !publicSettings.data.openRegistration ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border p-4 text-center text-sm text-muted-foreground">当前未开放注册</div>
|
||||
<Button type="button" variant="outline" className="w-full" asChild>
|
||||
<Link to="/login">返回登录</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; register.mutate(new FormData(e.currentTarget)) }}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">显示名称</Label>
|
||||
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
</div>
|
||||
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
||||
{register.isPending ? "注册中..." : "注册"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className="w-full" asChild>
|
||||
<Link to="/login">返回登录</Link>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user