mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 19:16:24 +00:00
refactor: restructure authentication components and routes
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 13s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 13s
- Removed the old reset password form component and replaced it with a new implementation. - Updated routing paths for login, registration, and reset password to be under a common auth path. - Added new login and registration pages with corresponding forms. - Introduced a common auth header component for consistent branding across auth pages. - Implemented a current logged-in user display component. - Enhanced the register form to include email verification and captcha. - Updated translations for new and modified components. - Refactored the navigation bar to include user avatar dropdown and improved menu structure.
This commit is contained in:
19
web/src/components/auth/common/auth-header.tsx
Normal file
19
web/src/components/auth/common/auth-header.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import config from "@/config";
|
||||
import Image from "next/image";
|
||||
|
||||
export function AuthHeader() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 self-center font-bold text-2xl">
|
||||
<div className="flex size-10 items-center justify-center rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
|
||||
<Image
|
||||
src={config.metadata.icon}
|
||||
alt="Logo"
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-bold text-2xl">{config.metadata.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
42
web/src/components/auth/common/current-logged.tsx
Normal file
42
web/src/components/auth/common/current-logged.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||
import { formatDisplayName, getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import { SectionDivider } from '@/components/common/section-divider';
|
||||
|
||||
export function CurrentLogged() {
|
||||
const t = useTranslations("Login");
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectBack = searchParams.get("redirect_back") || "/"
|
||||
const { user } = useAuth();
|
||||
|
||||
const handleLoggedContinue = () => {
|
||||
router.push(redirectBack);
|
||||
}
|
||||
if (!user) return null;
|
||||
return (
|
||||
<div>
|
||||
<SectionDivider className="mb-4">{t("currently_logged_in")}</SectionDivider>
|
||||
<div onClick={handleLoggedContinue} className="cursor-pointer">
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<Avatar className="h-10 w-10 rounded-full">
|
||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="grid place-items-center text-sm leading-tight text-center">
|
||||
<span className="text-primary font-medium">{formatDisplayName(user)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
232
web/src/components/auth/login/login-form.tsx
Normal file
232
web/src/components/auth/login/login-form.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState } from "react"
|
||||
import type { OidcConfig } from "@/models/oidc-config"
|
||||
import { getCaptchaConfig, listOidcConfigs, userLogin } from "@/api/user"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Captcha from "@/components/common/captcha"
|
||||
import { CaptchaProvider } from "@/models/captcha"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { registerPath, resetPasswordPath } from "@/hooks/use-route"
|
||||
import { CurrentLogged } from "@/components/auth/common/current-logged"
|
||||
import { SectionDivider } from "@/components/common/section-divider"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const t = useTranslations('Login')
|
||||
const { user, setUser } = useAuth();
|
||||
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
||||
const [captchaProps, setCaptchaProps] = useState<{
|
||||
provider: CaptchaProvider
|
||||
siteKey: string
|
||||
url?: string
|
||||
} | null>(null)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [isLogging, setIsLogging] = useState(false)
|
||||
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
|
||||
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectBack = searchParams.get("redirect_back") || "/"
|
||||
|
||||
useEffect(() => {
|
||||
listOidcConfigs()
|
||||
.then((res) => {
|
||||
setOidcConfigs(res.data || [])
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||
setOidcConfigs([])
|
||||
})
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
getCaptchaConfig()
|
||||
.then((res) => {
|
||||
setCaptchaProps(res.data)
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("fetch_captcha_config_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||
setCaptchaProps(null)
|
||||
})
|
||||
}, [refreshCaptchaKey, t])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
setIsLogging(true)
|
||||
e.preventDefault()
|
||||
userLogin({ username, password, captcha: captchaToken || "" })
|
||||
.then(res => {
|
||||
toast.success(t("login_success") + ` ${res.data.user.nickname || res.data.user.username}`);
|
||||
setUser(res.data.user);
|
||||
router.push(redirectBack)
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error)
|
||||
toast.error(t("login_failed") + (error?.response?.data?.message ? `: ${error.response.data.message}` : ""))
|
||||
setRefreshCaptchaKey(k => k + 1)
|
||||
setCaptchaToken(null)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLogging(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCaptchaError = (error: string) => {
|
||||
toast.error(t("captcha_error") + (error ? `: ${error}` : ""));
|
||||
setTimeout(() => {
|
||||
setRefreshCaptchaKey(k => k + 1);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t("welcome")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CurrentLogged />
|
||||
<SectionDivider className="my-4">{t("with_oidc")}</SectionDivider>
|
||||
<form>
|
||||
<div className="grid gap-6">
|
||||
{/* OIDC 登录选项 */}
|
||||
{oidcConfigs.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{oidcConfigs.map((config, index) => {
|
||||
const uniqueKey = config.id ||
|
||||
config.loginUrl ||
|
||||
`${config.displayName}-${index}` ||
|
||||
`oidc-${index}`;
|
||||
return (
|
||||
<LoginWithOidc
|
||||
key={uniqueKey}
|
||||
// 这个REDIRECT_BACK需要前端自己拼接,传给后端服务器,后端服务器拿来响应给前端另一个页面获取,然后改变路由
|
||||
// 因为这个是我暑假那会写的,后面因为其他事情太忙了,好久没看了,忘了为什么当时要这么设计了,在弄清楚之前先保持这样
|
||||
// 貌似是因为oidc认证时是后端响应重定向的,所以前端只能把redirect_back传给后端,由后端再传回来;普通登录时,这个参数可以被前端直接拿到进行路由跳转
|
||||
loginUrl={config.loginUrl.replace("REDIRECT_BACK", encodeURIComponent(`?redirect_back=${redirectBack}`))}
|
||||
displayName={config.displayName}
|
||||
icon={config.icon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 分隔线 */}
|
||||
{oidcConfigs.length > 0 && (
|
||||
<SectionDivider className="my-0"> {t("or_continue_with_local_account")}</SectionDivider>
|
||||
)}
|
||||
{/* 邮箱密码登录 */}
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">{t("email_or_username")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
placeholder="example@liteyuki.org"
|
||||
required
|
||||
value={username}
|
||||
onChange={e => setCredentials(c => ({ ...c, username: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Link
|
||||
href={resetPasswordPath}
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("forgot_password")}
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={e => setCredentials(c => ({ ...c, password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{captchaProps &&
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<Captcha {...captchaProps} onSuccess={setCaptchaToken} onError={handleCaptchaError} key={refreshCaptchaKey} />
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={handleLogin}
|
||||
disabled={!captchaToken || isLogging}
|
||||
>
|
||||
{isLogging ? t("logging") : t("login")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 注册链接 */}
|
||||
<div className="text-center text-sm">
|
||||
{t("no_account")}{" "}
|
||||
<Link href={registerPath+"?redirect_back="+encodeURIComponent(redirectBack)} className="underline underline-offset-4">
|
||||
{t("register")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoginWithOidcProps {
|
||||
loginUrl: string;
|
||||
displayName?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
function LoginWithOidc({
|
||||
loginUrl,
|
||||
displayName = "Login with OIDC",
|
||||
icon = "/oidc-icon.svg",
|
||||
}: LoginWithOidcProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
asChild
|
||||
>
|
||||
<Link href={loginUrl}>
|
||||
<Image
|
||||
src={icon}
|
||||
alt={`${displayName} icon`}
|
||||
width={16}
|
||||
height={16}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
marginRight: '8px'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{displayName}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
187
web/src/components/auth/register/register-form.tsx
Normal file
187
web/src/components/auth/register/register-form.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useEffect, useState } from "react"
|
||||
import { getCaptchaConfig, requestEmailVerifyCode, userRegister } from "@/api/user"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Captcha from "@/components/common/captcha"
|
||||
import { CaptchaProvider } from "@/models/captcha"
|
||||
import { toast } from "sonner"
|
||||
import { CurrentLogged } from "@/components/auth/common/current-logged"
|
||||
import { SectionDivider } from "@/components/common/section-divider"
|
||||
import { InputOTPControlled } from "@/components/common/input-otp"
|
||||
import { BaseErrorResponse } from "@/models/resp"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export function RegisterForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { setUser } = useAuth();
|
||||
const t = useTranslations('Register')
|
||||
const commonT = useTranslations('Common')
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectBack = searchParams.get("redirect_back") || "/"
|
||||
const [captchaProps, setCaptchaProps] = useState<{
|
||||
provider: CaptchaProvider
|
||||
siteKey: string
|
||||
url?: string
|
||||
} | null>(null)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [isLogging, setIsLogging] = useState(false)
|
||||
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [verifyCode, setVerifyCode] = useState('')
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getCaptchaConfig()
|
||||
.then((res) => {
|
||||
setCaptchaProps(res.data)
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("fetch_captcha_config_failed") + (error?.message ? `: ${error.message}` : ""))
|
||||
setCaptchaProps(null)
|
||||
})
|
||||
}, [refreshCaptchaKey, t])
|
||||
|
||||
const handleCaptchaError = (error: string) => {
|
||||
toast.error(t("captcha_error") + (error ? `: ${error}` : ""));
|
||||
setTimeout(() => {
|
||||
setRefreshCaptchaKey(k => k + 1);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
const handleSendVerifyCode = () => {
|
||||
requestEmailVerifyCode(email)
|
||||
.then(() => {
|
||||
toast.success(t("send_verify_code_success"))
|
||||
})
|
||||
.catch((error: BaseErrorResponse) => {
|
||||
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
const handleRegister = () => {
|
||||
if (!username || !password || !email) {
|
||||
toast.error(t("please_fill_in_all_required_fields"));
|
||||
return;
|
||||
}
|
||||
if (!captchaToken) {
|
||||
toast.error(t("please_complete_captcha_verification"));
|
||||
return;
|
||||
}
|
||||
userRegister({ username, password, email, verifyCode, captchaToken })
|
||||
.then(res => {
|
||||
toast.success(t("register_success") + ` ${res.data.user.nickname || res.data.user.username}`);
|
||||
setUser(res.data.user);
|
||||
router.push(redirectBack)
|
||||
})
|
||||
.catch((error: BaseErrorResponse) => {
|
||||
toast.error(t("register_failed") + (error?.response?.data?.message ? `: ${error.response.data.message}` : ""))
|
||||
setRefreshCaptchaKey(k => k + 1)
|
||||
setCaptchaToken(null)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLogging(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CurrentLogged />
|
||||
<form>
|
||||
<div className="grid gap-6">
|
||||
<SectionDivider className="mt-4">{t("register_a_new_account")}</SectionDivider>
|
||||
|
||||
<div className="grid gap-6">
|
||||
|
||||
{/* 用户名 */}
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="username">{commonT("username")}</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* 密码 */}
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">{commonT("password")}</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* 邮箱 */}
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">{commonT("email")}</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
placeholder="example@liteyuki.org"
|
||||
required
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
<Button onClick={handleSendVerifyCode} disabled={!email} variant="outline" className="border-2" type="button">
|
||||
{commonT("send_verify_code")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 邮箱验证码 */}
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">{commonT("verify_code")}</Label>
|
||||
<InputOTPControlled
|
||||
onChange={value => setVerifyCode(value)}
|
||||
/>
|
||||
</div>
|
||||
{captchaProps &&
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<Captcha {...captchaProps} onSuccess={setCaptchaToken} onError={handleCaptchaError} key={refreshCaptchaKey} />
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={!captchaToken || isLogging || !username || !password || !email}
|
||||
>
|
||||
{isLogging ? t("registering") : t("register")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
101
web/src/components/auth/reset-password/reset-password-form.tsx
Normal file
101
web/src/components/auth/reset-password/reset-password-form.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useState } from "react"
|
||||
import { requestEmailVerifyCode, resetPassword } from "@/api/user"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { InputOTPControlled } from "@/components/common/input-otp"
|
||||
import { BaseErrorResponse } from "@/models/resp"
|
||||
import { loginPath } from "@/hooks/use-route"
|
||||
import router from "next/router"
|
||||
|
||||
export function ResetPasswordForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const t = useTranslations('ResetPassword')
|
||||
const commonT = useTranslations('Common')
|
||||
const [email, setEmail] = useState("")
|
||||
const [verifyCode, setVerifyCode] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
|
||||
const handleSendVerifyCode = () => {
|
||||
requestEmailVerifyCode(email)
|
||||
.then(() => {
|
||||
toast.success(t("send_verify_code_success"))
|
||||
})
|
||||
.catch((error: BaseErrorResponse) => {
|
||||
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetPassword = () => {
|
||||
resetPassword({ email, newPassword, verifyCode }).then(() => {
|
||||
toast.success(t("reset_password_success"))
|
||||
router.push(loginPath);
|
||||
}).catch((error: BaseErrorResponse) => {
|
||||
toast.error(`${t("reset_password_failed")}: ${error.response.data.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="password">{t("new_password")}</Label>
|
||||
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="email">{commonT("email")}</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Button
|
||||
disabled={!email}
|
||||
variant="outline"
|
||||
className="border-2"
|
||||
type="button"
|
||||
onClick={handleSendVerifyCode}>{t("send_verify_code")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="verify_code">{t("verify_code")}</Label>
|
||||
<InputOTPControlled onChange={value => setVerifyCode(value)} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={!email || !newPassword || !verifyCode}
|
||||
onClick={handleResetPassword}
|
||||
>
|
||||
{t("title")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* TODO 回归登录和注册链接 */}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 服务条款 */}
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user