fix(css): fix version
This commit is contained in:
50
src/components/auth/captcha-field.tsx
Normal file
50
src/components/auth/captcha-field.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function CaptchaField(props: {
|
||||
captcha: string;
|
||||
onCaptchaChange: (next: string) => void;
|
||||
captchaKey: string;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="captcha">验证码</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="captcha"
|
||||
inputMode="numeric"
|
||||
value={props.captcha}
|
||||
onChange={(e) => props.onCaptchaChange(e.target.value)}
|
||||
disabled={props.disabled}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md border border-input bg-background px-2"
|
||||
onClick={props.onRefresh}
|
||||
aria-label="刷新验证码"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.captchaKey ? (
|
||||
<Image
|
||||
alt="captcha"
|
||||
src={`/api/captcha?key=${encodeURIComponent(props.captchaKey)}`}
|
||||
width={120}
|
||||
height={40}
|
||||
className="w-[120px] h-[40px] object-contain"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[120px] h-[40px] bg-muted/50 animate-pulse rounded-md" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/components/auth/login-form.tsx
Normal file
150
src/components/auth/login-form.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { CaptchaField } from "@/components/auth/captcha-field"
|
||||
|
||||
export function LoginForm(props: {
|
||||
clientId: string
|
||||
tenantId: string
|
||||
callback: string
|
||||
initialEmail: string
|
||||
prefillPassword?: string
|
||||
disabled?: boolean
|
||||
externalError?: string | null
|
||||
onClearExternalError?: () => void
|
||||
captcha: string
|
||||
onCaptchaChange: (next: string) => void
|
||||
captchaKey: string
|
||||
onRefreshCaptcha: () => void
|
||||
}) {
|
||||
const [email, setEmail] = React.useState(props.initialEmail)
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [rememberMe, setRememberMe] = React.useState(Boolean(props.initialEmail))
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setEmail(props.initialEmail)
|
||||
setRememberMe(Boolean(props.initialEmail))
|
||||
}, [props.initialEmail])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.prefillPassword) {
|
||||
setPassword(props.prefillPassword)
|
||||
}
|
||||
}, [props.prefillPassword])
|
||||
|
||||
const missingParams = !props.clientId || !props.tenantId || !props.callback
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
props.onClearExternalError?.()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: props.clientId,
|
||||
tenantId: props.tenantId,
|
||||
callback: props.callback,
|
||||
email,
|
||||
password,
|
||||
captcha: props.captcha,
|
||||
rememberMe,
|
||||
}),
|
||||
})
|
||||
const json = (await res.json()) as { redirectTo?: string; message?: string }
|
||||
if (!res.ok || !json.redirectTo) {
|
||||
throw new Error(json.message || "登录失败")
|
||||
}
|
||||
window.location.href = json.redirectTo
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败")
|
||||
props.onCaptchaChange("")
|
||||
props.onRefreshCaptcha()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = Boolean(props.disabled) || submitting
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{missingParams ? (
|
||||
<div className="text-sm text-destructive">
|
||||
缺少 clientId、tenantId 或 callback 参数,无法继续登录
|
||||
</div>
|
||||
) : null}
|
||||
{props.externalError ? (
|
||||
<div className="text-sm text-destructive">{props.externalError}</div>
|
||||
) : null}
|
||||
{error ? <div className="text-sm text-destructive">{error}</div> : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">用户名</Label>
|
||||
<Input
|
||||
id="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CaptchaField
|
||||
captcha={props.captcha}
|
||||
onCaptchaChange={props.onCaptchaChange}
|
||||
captchaKey={props.captchaKey}
|
||||
onRefresh={props.onRefreshCaptcha}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(v) => setRememberMe(Boolean(v))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
记住我
|
||||
</label>
|
||||
<Link
|
||||
className="text-sm underline underline-offset-4"
|
||||
href={`/forgot-password?tenantId=${encodeURIComponent(props.tenantId)}`}
|
||||
>
|
||||
忘记密码
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={disabled || missingParams}
|
||||
>
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
155
src/components/auth/register-form.tsx
Normal file
155
src/components/auth/register-form.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { CaptchaField } from "@/components/auth/captcha-field"
|
||||
|
||||
export function RegisterForm(props: {
|
||||
tenantId: string
|
||||
disabled?: boolean
|
||||
captcha: string
|
||||
onCaptchaChange: (next: string) => void
|
||||
captchaKey: string
|
||||
onRefreshCaptcha: () => void
|
||||
onRegisterSuccessPrefillLogin: (payload: { email: string; password: string }) => void
|
||||
onLoginAfterRegister: (payload: { email: string; password: string }) => Promise<void>
|
||||
}) {
|
||||
const [email, setEmail] = React.useState("")
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
const passwordMismatch =
|
||||
confirmPassword.length > 0 && password !== confirmPassword
|
||||
|
||||
const missingTenant = !props.tenantId
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const nextEmail = email.trim()
|
||||
if (!props.tenantId) {
|
||||
setError("缺少 tenantId 参数,无法注册")
|
||||
return
|
||||
}
|
||||
if (!nextEmail || !password || !confirmPassword) {
|
||||
setError("请填写邮箱与密码")
|
||||
return
|
||||
}
|
||||
if (passwordMismatch) {
|
||||
setError("两次输入的密码不一致")
|
||||
return
|
||||
}
|
||||
if (!props.captcha.trim()) {
|
||||
setError("请填写验证码")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tenantId: props.tenantId,
|
||||
email: nextEmail,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
const json = (await res.json()) as { message?: string; id?: string }
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
throw new Error("该邮箱已注册,请直接登录")
|
||||
}
|
||||
throw new Error(json.message || "注册失败")
|
||||
}
|
||||
if (!json.id) {
|
||||
throw new Error("注册失败")
|
||||
}
|
||||
|
||||
props.onRegisterSuccessPrefillLogin({ email: nextEmail, password })
|
||||
await props.onLoginAfterRegister({ email: nextEmail, password })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "注册失败")
|
||||
props.onCaptchaChange("")
|
||||
props.onRefreshCaptcha()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = Boolean(props.disabled) || submitting
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{missingTenant ? (
|
||||
<div className="text-sm text-destructive">
|
||||
缺少 tenantId 参数,无法继续注册
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div className="text-sm text-destructive">{error}</div> : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="registerEmail">邮箱</Label>
|
||||
<Input
|
||||
id="registerEmail"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="registerPassword">密码</Label>
|
||||
<Input
|
||||
id="registerPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="registerConfirmPassword">确认密码</Label>
|
||||
<Input
|
||||
id="registerConfirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
{passwordMismatch ? (
|
||||
<div className="text-sm text-destructive">两次输入的密码不一致</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CaptchaField
|
||||
captcha={props.captcha}
|
||||
onCaptchaChange={props.onCaptchaChange}
|
||||
captchaKey={props.captchaKey}
|
||||
onRefresh={props.onRefreshCaptcha}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={disabled || passwordMismatch || missingTenant}
|
||||
>
|
||||
{submitting ? "注册中..." : "注册并登录"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const LoginFormCard = (props: {
|
||||
}) => {
|
||||
const [tab, setTab] = React.useState<"login" | "register">("login");
|
||||
const [captcha, setCaptcha] = React.useState("");
|
||||
const [captchaKey, setCaptchaKey] = React.useState(() => String(Date.now()));
|
||||
const [captchaKey, setCaptchaKey] = React.useState("");
|
||||
const [loginExternalError, setLoginExternalError] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -30,6 +30,10 @@ const LoginFormCard = (props: {
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCaptchaKey(String(Date.now()));
|
||||
}, []);
|
||||
|
||||
function refreshCaptcha() {
|
||||
setCaptchaKey(String(Date.now()));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-base md:text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
50
src/components/ui/tabs.tsx
Normal file
50
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn("mt-4 ring-offset-background focus-visible:outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
Reference in New Issue
Block a user