Merge pull request #22 from snowykami/fix/21

feat: 更新用户界面,添加注销功能和绑定第三方账号选项,优化表单布局
This commit is contained in:
2025-09-23 17:39:43 +08:00
committed by GitHub
6 changed files with 105 additions and 92 deletions

View File

@ -1,100 +1,100 @@
package dto package dto
type UserDto struct { type UserDto struct {
ID uint `json:"id"` // 用户ID ID uint `json:"id"` // 用户ID
Username string `json:"username"` // 用户名 Username string `json:"username"` // 用户名
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"` // 头像URL AvatarUrl string `json:"avatar_url"` // 头像URL
Email string `json:"email"` // 邮箱 Email string `json:"email"` // 邮箱
Gender string `json:"gender"` Gender string `json:"gender"`
Role string `json:"role"` Role string `json:"role"`
Language string `json:"language"` // 语言 Language string `json:"language"` // 语言
} }
type UserOidcConfigDto struct { type UserOidcConfigDto struct {
Name string `json:"name"` // OIDC配置名称 Name string `json:"name"` // OIDC配置名称
DisplayName string `json:"display_name"` // OIDC配置显示名称 DisplayName string `json:"display_name"` // OIDC配置显示名称
Icon string `json:"icon"` // OIDC配置图标URL Icon string `json:"icon"` // OIDC配置图标URL
LoginUrl string `json:"login_url"` // OIDC登录URL LoginUrl string `json:"login_url"` // OIDC登录URL
} }
type UserLoginReq struct { type UserLoginReq struct {
Username string `json:"username"` // username or email Username string `json:"username"` // username or email
Password string `json:"password"` Password string `json:"password"`
} }
type UserLoginResp struct { type UserLoginResp struct {
Token string `json:"token"` Token string `json:"token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
User UserDto `json:"user"` User UserDto `json:"user"`
} }
type UserRegisterReq struct { type UserRegisterReq struct {
Username string `json:"username"` // 用户名 Username string `json:"username"` // 用户名
Nickname string `json:"nickname"` // 昵称 Nickname string `json:"nickname"` // 昵称
Password string `json:"password"` // 密码 Password string `json:"password"` // 密码
Email string `json:"-" binding:"-"` Email string `json:"-" binding:"-"`
} }
type UserRegisterResp struct { type UserRegisterResp struct {
Token string `json:"token"` // 访问令牌 Token string `json:"token"` // 访问令牌
RefreshToken string `json:"refresh_token"` // 刷新令牌 RefreshToken string `json:"refresh_token"` // 刷新令牌
User UserDto `json:"user"` // 用户信息 User UserDto `json:"user"` // 用户信息
} }
type VerifyEmailReq struct { type VerifyEmailReq struct {
Email string `json:"email"` // 邮箱地址 Email string `json:"email"` // 邮箱地址
} }
type VerifyEmailResp struct { type VerifyEmailResp struct {
Success bool `json:"success"` // 验证码发送成功与否 Success bool `json:"success"` // 验证码发送成功与否
} }
type OidcLoginReq struct { type OidcLoginReq struct {
Name string `json:"name"` // OIDC配置名称 Name string `json:"name"` // OIDC配置名称
Code string `json:"code"` // OIDC授权码 Code string `json:"code"` // OIDC授权码
State string `json:"state"` State string `json:"state"`
} }
type OidcLoginResp struct { type OidcLoginResp struct {
Token string `json:"token"` Token string `json:"token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
User UserDto `json:"user"` User UserDto `json:"user"`
} }
type ListOidcConfigResp struct { type ListOidcConfigResp struct {
OidcConfigs []UserOidcConfigDto `json:"oidc_configs"` // OIDC配置列表 OidcConfigs []UserOidcConfigDto `json:"oidc_configs"` // OIDC配置列表
} }
type GetUserReq struct { type GetUserReq struct {
UserID uint `json:"user_id"` UserID uint `json:"user_id"`
} }
type GetUserByUsernameReq struct { type GetUserByUsernameReq struct {
Username string `json:"username"` Username string `json:"username"`
} }
type GetUserResp struct { type GetUserResp struct {
User UserDto `json:"user"` // 用户信息 User UserDto `json:"user"` // 用户信息
} }
type UpdateUserReq struct { type UpdateUserReq struct {
ID uint `json:"id"` ID uint `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"` AvatarUrl string `json:"avatar_url"`
Gender string `json:"gender"` Gender string `json:"gender"`
} }
type UpdateUserResp struct { type UpdateUserResp struct {
User *UserDto `json:"user"` // 更新后的用户信息 User *UserDto `json:"user"` // 更新后的用户信息
} }
type UpdatePasswordReq struct { type UpdatePasswordReq struct {
OldPassword string `json:"old_password"` OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"` NewPassword string `json:"new_password"`
} }
type ResetPasswordReq struct { type ResetPasswordReq struct {
Email string `json:"-" binding:"-"` Email string `json:"-" binding:"-"`
NewPassword string `json:"new_password"` NewPassword string `json:"new_password"`
} }

View File

@ -8,33 +8,49 @@ import { useTranslations } from "next-intl";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import React from "react"; import React from "react";
import { SectionDivider } from '@/components/common/section-divider'; import { SectionDivider } from '@/components/common/section-divider';
import { LogOut } from "lucide-react";
import { userLogout } from "@/api/user";
import { toast } from "sonner";
export function CurrentLogged() { export function CurrentLogged() {
const t = useTranslations("Login"); const t = useTranslations("Login");
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const redirectBack = searchParams.get("redirect_back") || "/" const redirectBack = searchParams.get("redirect_back") || "/"
const { user } = useAuth(); const { user, logout } = useAuth();
const handleLoggedContinue = () => { const handleLoggedContinue = () => {
router.push(redirectBack); router.push(redirectBack);
} }
const handleLogOut = () => {
userLogout().then(() => {
logout();
toast.success(t("logout_success"));
})
}
if (!user) return null; if (!user) return null;
return ( return (
<div> <div className="mb-4">
<SectionDivider className="mb-4">{t("currently_logged_in")}</SectionDivider> <SectionDivider className="mb-4">{t("currently_logged_in")}</SectionDivider>
<div onClick={handleLoggedContinue} className="cursor-pointer"> <div className="flex justify-evenly items-center">
<div className="flex gap-2 justify-center items-center"> <div className="flex gap-4 items-center cursor-pointer">
<Avatar className="h-10 w-10 rounded-full"> <div onClick={handleLoggedContinue} className="flex gap-2 justify-center items-center ">
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} /> <Avatar className="h-10 w-10 rounded-full">
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback> <AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
</Avatar> <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>
<div className="grid place-items-center text-sm leading-tight text-center"> <div>
<span className="text-primary font-medium">{formatDisplayName(user)}</span> <LogOut onClick={handleLogOut} className="text-muted-foreground cursor-pointer" />
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -25,6 +25,7 @@ import { useAuth } from "@/contexts/auth-context"
import { registerPath, resetPasswordPath } from "@/hooks/use-route" import { registerPath, resetPasswordPath } from "@/hooks/use-route"
import { CurrentLogged } from "@/components/auth/common/current-logged" import { CurrentLogged } from "@/components/auth/common/current-logged"
import { SectionDivider } from "@/components/common/section-divider" import { SectionDivider } from "@/components/common/section-divider"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export function LoginForm({ export function LoginForm({
className, className,
@ -103,9 +104,9 @@ export function LoginForm({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CurrentLogged /> <CurrentLogged />
<SectionDivider className="my-4">{t("with_oidc")}</SectionDivider> <SectionDivider className="mb-6">{user ? t("bind_oidc") : t("with_oidc")}</SectionDivider>
<form> <form>
<div className="grid gap-6"> <div className="grid gap-4">
{/* OIDC 登录选项 */} {/* OIDC 登录选项 */}
{oidcConfigs.length > 0 && ( {oidcConfigs.length > 0 && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@ -130,10 +131,10 @@ export function LoginForm({
)} )}
{/* 分隔线 */} {/* 分隔线 */}
{oidcConfigs.length > 0 && ( {oidcConfigs.length > 0 && (
<SectionDivider className="my-0"> {t("or_continue_with_local_account")}</SectionDivider> <SectionDivider className="my-2"> {t("or_continue_with_local_account")}</SectionDivider>
)} )}
{/* 邮箱密码登录 */} {/* 邮箱密码登录 */}
<div className="grid gap-6"> <div className="grid gap-4">
<div className="grid gap-3"> <div className="grid gap-3">
<Label htmlFor="email">{t("email_or_username")}</Label> <Label htmlFor="email">{t("email_or_username")}</Label>
<Input <Input
@ -181,7 +182,7 @@ export function LoginForm({
{/* 注册链接 */} {/* 注册链接 */}
<div className="text-center text-sm"> <div className="text-center text-sm">
{t("no_account")}{" "} {t("no_account")}{" "}
<Link href={registerPath+"?redirect_back="+encodeURIComponent(redirectBack)} className="underline underline-offset-4"> <Link href={registerPath + "?redirect_back=" + encodeURIComponent(redirectBack)} className="underline underline-offset-4">
{t("register")} {t("register")}
</Link> </Link>
</div> </div>
@ -211,20 +212,10 @@ function LoginWithOidc({
asChild asChild
> >
<Link href={loginUrl}> <Link href={loginUrl}>
<Image <Avatar className="h-6 w-6 rounded-full">
src={icon} <AvatarImage src={icon} alt={displayName} />
alt={`${displayName} icon`} <AvatarFallback className="rounded-full"></AvatarFallback>
width={16} </Avatar>
height={16}
style={{
width: '16px',
height: '16px',
marginRight: '8px'
}}
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
{displayName} {displayName}
</Link> </Link>
</Button> </Button>

View File

@ -109,10 +109,10 @@ export function RegisterForm({
<CardContent> <CardContent>
<CurrentLogged /> <CurrentLogged />
<form> <form>
<div className="grid gap-6"> <div className="grid gap-4">
<SectionDivider className="mt-4">{t("register_a_new_account")}</SectionDivider> <SectionDivider className="mt-0">{t("register_a_new_account")}</SectionDivider>
<div className="grid gap-6"> <div className="grid gap-4">
{/* 用户名 */} {/* 用户名 */}
<div className="grid gap-3"> <div className="grid gap-3">
@ -152,17 +152,21 @@ export function RegisterForm({
value={email} value={email}
onChange={e => setEmail(e.target.value)} 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> </div>
{/* 邮箱验证码 */} {/* 邮箱验证码 */}
<div className="grid gap-3"> <div className="grid gap-3">
<Label htmlFor="email">{commonT("verify_code")}</Label> <Label htmlFor="email">{commonT("verify_code")}</Label>
<InputOTPControlled <div className="flex justify-between">
onChange={value => setVerifyCode(value)} <InputOTPControlled
/> onChange={value => setVerifyCode(value)}
/>
<Button onClick={handleSendVerifyCode} disabled={!email} variant="outline" className="border-2" type="button">
{commonT("send_verify_code")}
</Button>
</div>
</div> </div>
{captchaProps && {captchaProps &&
<div className="flex justify-center items-center w-full"> <div className="flex justify-center items-center w-full">

View File

@ -83,13 +83,14 @@ export function UserSecurityPage() {
<Label htmlFor="email">{commonT("email")}</Label> <Label htmlFor="email">{commonT("email")}</Label>
<div className="flex gap-3"> <div className="flex gap-3">
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> <Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Button disabled={!email || email == user.email} variant="outline" className="border-2" onClick={handleSendVerifyCode}>{t("send_verify_code")}</Button>
</div> </div>
<Label htmlFor="verify-code">{t("verify_code")}</Label> <Label htmlFor="verify-code">{t("verify_code")}</Label>
<div className="flex gap-3"> <div className="flex justify-between">
<InputOTPControlled onChange={(value) => setVerifyCode(value)} /> <InputOTPControlled onChange={(value) => setVerifyCode(value)} />
<Button disabled={verifyCode.length < 6} className="border-2" onClick={handleSubmitEmail}>{t("update_email")}</Button> <Button disabled={!email || email == user.email} variant="outline" className="border-2" onClick={handleSendVerifyCode}>{t("send_verify_code")}</Button>
</div> </div>
<Button disabled={verifyCode.length < 6} className="border-2" onClick={handleSubmitEmail}>{t("update_email")}</Button>
</div> </div>
</div> </div>
) )

View File

@ -128,6 +128,7 @@
} }
}, },
"Login": { "Login": {
"bind_oidc": "绑定第三方账号",
"captcha_error": "验证错误,请重试。", "captcha_error": "验证错误,请重试。",
"continue": "继续", "continue": "继续",
"currently_logged_in": "当前已登录为", "currently_logged_in": "当前已登录为",
@ -137,7 +138,7 @@
"login_success": "登录成功!", "login_success": "登录成功!",
"login_failed": "登录失败", "login_failed": "登录失败",
"welcome": "欢迎回来", "welcome": "欢迎回来",
"with_oidc": "使用第三方身份提供者", "with_oidc": "使用第三方账号",
"or_continue_with_local_account": "或使用用户名和密码", "or_continue_with_local_account": "或使用用户名和密码",
"email_or_username": "邮箱或用户名", "email_or_username": "邮箱或用户名",
"password": "密码", "password": "密码",