feat(auth): 优化登录注册页面与人机验证展示
- 调整登录、注册页布局为更一致的卡片式结构,提升表单可读性与视觉层级。 - 为登录/注册按钮与状态区补充图标和文案,优化交互反馈。 - 让 Turnstile 验证组件支持自适应宽度并使用更合适的展示尺寸。
This commit is contained in:
@@ -3,7 +3,7 @@ import * as React from "react"
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
turnstile?: {
|
turnstile?: {
|
||||||
render: (container: HTMLElement, options: { sitekey: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void }) => string
|
render: (container: HTMLElement, options: { sitekey: string; size?: "normal" | "compact" | "flexible"; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void }) => string
|
||||||
remove: (widgetId: string) => void
|
remove: (widgetId: string) => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (
|
|||||||
ref.current.innerHTML = ""
|
ref.current.innerHTML = ""
|
||||||
widgetId = window.turnstile.render(ref.current, {
|
widgetId = window.turnstile.render(ref.current, {
|
||||||
sitekey: siteKey,
|
sitekey: siteKey,
|
||||||
|
size: "flexible",
|
||||||
callback: onToken,
|
callback: onToken,
|
||||||
"expired-callback": () => onToken(""),
|
"expired-callback": () => onToken(""),
|
||||||
"error-callback": () => onToken(""),
|
"error-callback": () => onToken(""),
|
||||||
@@ -46,5 +47,5 @@ export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (
|
|||||||
if (widgetId && window.turnstile) window.turnstile.remove(widgetId)
|
if (widgetId && window.turnstile) window.turnstile.remove(widgetId)
|
||||||
}
|
}
|
||||||
}, [siteKey, onToken])
|
}, [siteKey, onToken])
|
||||||
return <div className="flex justify-center"><div ref={ref} /></div>
|
return <div className="w-full"><div ref={ref} className="w-full" /></div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Link, Navigate } from "react-router-dom"
|
import { Link, Navigate } from "react-router-dom"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { ArrowRight, KeyRound, LockKeyhole } from "lucide-react"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { TurnstileBox } from "@/components/turnstile-box"
|
import { TurnstileBox } from "@/components/turnstile-box"
|
||||||
@@ -34,42 +35,52 @@ export function LoginPage() {
|
|||||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||||
if (me.data?.user) return <Navigate to="/" replace />
|
if (me.data?.user) return <Navigate to="/" replace />
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||||
<div className="w-full max-w-[360px]">
|
<div className="w-full max-w-[420px]">
|
||||||
<div className="mb-10 text-center">
|
<div className="mb-7 text-center">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">LanQin Email</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">LanQin Email</h1>
|
||||||
</div>
|
</div>
|
||||||
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (!challengeToken && turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; login.mutate(new FormData(e.currentTarget)) }}>
|
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||||
{!challengeToken ? (
|
<div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
<>
|
{challengeToken ? <KeyRound className="h-4 w-4" /> : <LockKeyhole className="h-4 w-4" />}
|
||||||
|
{challengeToken ? "双因素验证" : "账号登录"}
|
||||||
|
</div>
|
||||||
|
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (!challengeToken && turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; login.mutate(new FormData(e.currentTarget)) }}>
|
||||||
|
{!challengeToken ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-sm font-medium">邮箱</Label>
|
||||||
|
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||||
|
<PasswordInput id="password" name="password" autoComplete="current-password" required className="h-11 text-base" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">邮箱</Label>
|
<Label htmlFor="twoFactorCode" className="text-sm font-medium">双因素验证码</Label>
|
||||||
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
|
<Input id="twoFactorCode" name="twoFactorCode" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required className="h-11 text-center text-lg tracking-[0.35em]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
)}
|
||||||
<Label htmlFor="password">密码</Label>
|
{!challengeToken && turnstileRequired && (
|
||||||
<PasswordInput id="password" name="password" autoComplete="current-password" required className="h-11 text-base" />
|
<TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />
|
||||||
</div>
|
)}
|
||||||
</>
|
<Button className="h-11 w-full text-base" disabled={login.isPending}>
|
||||||
) : (
|
{login.isPending ? "登录中..." : challengeToken ? "验证登录" : "登录"}
|
||||||
<div className="space-y-2">
|
{!login.isPending && <ArrowRight className="h-4 w-4" />}
|
||||||
<Label htmlFor="twoFactorCode">双因素验证码</Label>
|
</Button>
|
||||||
<Input id="twoFactorCode" name="twoFactorCode" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required className="h-11 text-base" />
|
{challengeToken && <Button type="button" variant="ghost" className="w-full" onClick={() => setChallengeToken("")}>返回登录</Button>}
|
||||||
</div>
|
</form>
|
||||||
)}
|
</div>
|
||||||
{!challengeToken && turnstileRequired && (
|
{!challengeToken && (
|
||||||
<TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />
|
<div className="mt-5 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
)}
|
<span>没有账号?</span>
|
||||||
<Button className="h-11 w-full text-base" disabled={login.isPending}>
|
<Button type="button" variant="link" className="h-auto px-0 text-sm" asChild>
|
||||||
{login.isPending ? "登录中..." : challengeToken ? "验证登录" : "登录"}
|
|
||||||
</Button>
|
|
||||||
{challengeToken && <Button type="button" variant="ghost" className="w-full" onClick={() => setChallengeToken("")}>返回登录</Button>}
|
|
||||||
{!challengeToken && (
|
|
||||||
<Button type="button" variant="ghost" className="w-full" asChild>
|
|
||||||
<Link to="/register">注册账号</Link>
|
<Link to="/register">注册账号</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</form>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Link, Navigate, useNavigate } from "react-router-dom"
|
import { Link, Navigate, useNavigate } from "react-router-dom"
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { ArrowLeft, ArrowRight, UserPlus } from "lucide-react"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
import type { PublicDomain } from "@/lib/api"
|
import type { PublicDomain } from "@/lib/api"
|
||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
@@ -61,64 +62,78 @@ export function RegisterPage() {
|
|||||||
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
|
||||||
if (me.data?.user) return <Navigate to="/" replace />
|
if (me.data?.user) return <Navigate to="/" replace />
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen place-items-center bg-background px-4">
|
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||||
<div className="w-full max-w-[360px]">
|
<div className="w-full max-w-[420px]">
|
||||||
<div className="mb-10 text-center">
|
<div className="mb-7 text-center">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">注册账号</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">LanQin Email</h1>
|
||||||
</div>
|
</div>
|
||||||
{publicSettings.isSuccess && !publicSettings.data.openRegistration ? (
|
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||||
<div className="space-y-4">
|
<div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
<div className="rounded-md border p-4 text-center text-sm text-muted-foreground">当前未开放注册</div>
|
<UserPlus className="h-4 w-4" />
|
||||||
<Button type="button" variant="outline" className="w-full" asChild>
|
注册账号
|
||||||
|
</div>
|
||||||
|
{publicSettings.isSuccess && !publicSettings.data.openRegistration ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-md bg-muted/40 px-4 py-3 text-center text-sm text-muted-foreground">当前未开放注册</div>
|
||||||
|
<Button type="button" variant="outline" className="h-11 w-full text-base" asChild>
|
||||||
|
<Link to="/login">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; register.mutate(new FormData(e.currentTarget)) }}>
|
||||||
|
{domains.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="localPart" className="text-sm font-medium">邮箱地址</Label>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_150px]">
|
||||||
|
<Input id="localPart" name="localPart" className="h-11 text-base" placeholder="邮箱前缀" required />
|
||||||
|
<Select value={domainId} onValueChange={setDomainId} required>
|
||||||
|
<SelectTrigger className="h-11">
|
||||||
|
<SelectValue placeholder="选择域名" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map((d) => (
|
||||||
|
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-sm font-medium">邮箱</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" className="text-sm font-medium">显示名称</Label>
|
||||||
|
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||||
|
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword" className="text-sm font-medium">确认密码</Label>
|
||||||
|
<PasswordInput id="confirmPassword" name="confirmPassword" 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 ? "注册中..." : "注册"}
|
||||||
|
{!register.isPending && <ArrowRight className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!(publicSettings.isSuccess && !publicSettings.data.openRegistration) && (
|
||||||
|
<div className="mt-5 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>已有账号?</span>
|
||||||
|
<Button type="button" variant="link" className="h-auto px-0 text-sm" asChild>
|
||||||
<Link to="/login">返回登录</Link>
|
<Link to="/login">返回登录</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; register.mutate(new FormData(e.currentTarget)) }}>
|
|
||||||
{domains.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="localPart">邮箱地址</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input id="localPart" name="localPart" className="h-11 flex-1 text-base" placeholder="your-name" required />
|
|
||||||
<span className="text-sm text-muted-foreground">@</span>
|
|
||||||
<Select value={domainId} onValueChange={setDomainId} required>
|
|
||||||
<SelectTrigger className="h-11 w-[140px]">
|
|
||||||
<SelectValue placeholder="选择域名" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{domains.map((d) => (
|
|
||||||
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
|
||||||
<PasswordInput id="confirmPassword" name="confirmPassword" 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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user