refactor: restructure authentication components and routes
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:
2025-09-23 02:21:03 +08:00
parent 0f7cbb385a
commit 349cf5a5b7
23 changed files with 380 additions and 112 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}