mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
feat: add email verification and password reset functionality
- Introduced environment variables for database and email configurations. - Implemented email verification code generation and validation. - Added password reset feature with email verification. - Updated user registration and profile management APIs. - Refactored user security settings to include email and password updates. - Enhanced console layout with internationalization support. - Removed deprecated settings page and integrated global settings. - Added new reset password page and form components. - Updated localization files for new features and translations.
This commit is contained in:
@ -40,7 +40,7 @@ export async function userRegister(
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function ListOidcConfigs(): Promise<BaseResponse<OidcConfig[]>> {
|
||||
export async function listOidcConfigs(): Promise<BaseResponse<OidcConfig[]>> {
|
||||
const res = await axiosClient.get<BaseResponse<OidcConfig[]>>(
|
||||
'/user/oidc/list',
|
||||
)
|
||||
@ -88,4 +88,24 @@ export async function getCaptchaConfig(): Promise<BaseResponse<{
|
||||
export async function updateUser(data: Partial<User>): Promise<BaseResponse<User>> {
|
||||
const res = await axiosClient.put<BaseResponse<User>>(`/user/u/${data.id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function requestEmailVerifyCode(email: string): Promise<BaseResponse<{ coolDown: number }>> {
|
||||
const res = await axiosClient.post<BaseResponse<{ coolDown: number }>>('/user/email/verify', { email })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updatePassword({ oldPassword, newPassword }: { oldPassword: string, newPassword: string }): Promise<BaseResponse<null>> {
|
||||
const res = await axiosClient.put<BaseResponse<null>>('/user/password/edit', { oldPassword, newPassword })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function resetPassword({ email, newPassword, verifyCode }: { email: string, newPassword: string, verifyCode: string }): Promise<BaseResponse<null>> {
|
||||
const res = await axiosClient.put<BaseResponse<null>>('/user/password/reset', { newPassword }, { headers: { 'X-Email': email, 'X-VerifyCode': verifyCode } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateEmail({ newEmail, verifyCode }: { newEmail: string, verifyCode: string }): Promise<BaseResponse<null>> {
|
||||
const res = await axiosClient.put<BaseResponse<null>>('/user/email/edit', null, { headers: { 'X-Email': newEmail, 'X-VerifyCode': verifyCode } })
|
||||
return res.data
|
||||
}
|
5
web/src/app/console/global/page.tsx
Normal file
5
web/src/app/console/global/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import GlobalPage from "@/components/console/global";
|
||||
|
||||
export default function Page() {
|
||||
return <GlobalPage />;
|
||||
}
|
@ -11,12 +11,14 @@ import { useEffect, useState } from "react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { sidebarData, SidebarItem } from "@/components/console/data"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
export default function ConsoleLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const t = useTranslations("Console")
|
||||
const { user } = useAuth();
|
||||
const [title, setTitle] = useState("Title");
|
||||
const toLogin = useToLogin();
|
||||
@ -27,12 +29,12 @@ export default function ConsoleLayout({
|
||||
useEffect(() => {
|
||||
const currentItem = sideBarItems.find(item => item.url === pathname);
|
||||
if (currentItem) {
|
||||
setTitle(currentItem.title);
|
||||
document.title = `${currentItem.title} - 控制台`;
|
||||
setTitle(t(currentItem.title));
|
||||
document.title = `${t(currentItem.title)} - 控制台`;
|
||||
} else {
|
||||
setTitle("Title");
|
||||
}
|
||||
}, [pathname, sideBarItems]);
|
||||
}, [pathname, sideBarItems, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
|
@ -1,5 +0,0 @@
|
||||
import SettingPage from "@/components/console/setting";
|
||||
|
||||
export default function Page() {
|
||||
return <SettingPage />;
|
||||
}
|
25
web/src/app/reset-password/page.tsx
Normal file
25
web/src/app/reset-password/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { ResetPasswordForm } from "@/components/reset-password/reset-password-form";
|
||||
import config from "@/config";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<a href="#" 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>
|
||||
</a>
|
||||
<ResetPasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
27
web/src/components/common/input-otp.tsx
Normal file
27
web/src/components/common/input-otp.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
|
||||
|
||||
export function InputOTPControlled({ onChange }: { onChange: (value: string) => void }) {
|
||||
const [value, setValue] = useState("")
|
||||
useEffect(() => {
|
||||
onChange(value)
|
||||
}, [value, onChange])
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -14,57 +14,57 @@ export interface SidebarItem {
|
||||
export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[] } = {
|
||||
navMain: [
|
||||
{
|
||||
title: "大石坝",
|
||||
title: "dashboard.title",
|
||||
url: "/console",
|
||||
icon: Gauge,
|
||||
permission: isAdmin
|
||||
},
|
||||
{
|
||||
title: "文章管理",
|
||||
title: "post.title",
|
||||
url: "/console/post",
|
||||
icon: Newspaper,
|
||||
permission: isEditor
|
||||
},
|
||||
{
|
||||
title: "评论管理",
|
||||
title: "comment.title",
|
||||
url: "/console/comment",
|
||||
icon: MessageCircle,
|
||||
permission: isEditor
|
||||
},
|
||||
{
|
||||
title: "文件管理",
|
||||
title: "file.title",
|
||||
url: "/console/file",
|
||||
icon: Folder,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "用户管理",
|
||||
title: "user.title",
|
||||
url: "/console/user",
|
||||
icon: Users,
|
||||
permission: isAdmin
|
||||
},
|
||||
{
|
||||
title: "全局设置",
|
||||
url: "/console/setting",
|
||||
title: "global.title",
|
||||
url: "/console/global",
|
||||
icon: Settings,
|
||||
permission: isAdmin
|
||||
},
|
||||
],
|
||||
navUserCenter: [
|
||||
{
|
||||
title: "个人资料",
|
||||
title: "user_profile.title",
|
||||
url: "/console/user-profile",
|
||||
icon: UserPen,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "安全设置",
|
||||
title: "user_security.title",
|
||||
url: "/console/user-security",
|
||||
icon: ShieldCheck,
|
||||
permission: () => true
|
||||
},
|
||||
{
|
||||
title: "个性化",
|
||||
title: "user-preference.title",
|
||||
url: "/console/user-preference",
|
||||
icon: Palette,
|
||||
permission: () => true
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function SettingPage() {
|
||||
export default function GlobalPage() {
|
||||
return <div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
全局设置
|
@ -13,6 +13,7 @@ import { usePathname } from "next/navigation";
|
||||
import { User } from "@/models/user";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { IconType } from "@/types/icon";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
@ -24,6 +25,7 @@ export function NavMain({
|
||||
permission: ({ user }: { user: User }) => boolean
|
||||
}[]
|
||||
}) {
|
||||
const t = useTranslations("Console")
|
||||
const { user } = useAuth();
|
||||
const pathname = usePathname() ?? "/"
|
||||
|
||||
@ -39,7 +41,7 @@ export function NavMain({
|
||||
<Link href={item.url}>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<span>{t(item.title)}</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
|
@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
@ -13,6 +12,7 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { IconType } from "@/types/icon"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
export function NavUserCenter({
|
||||
items,
|
||||
@ -24,6 +24,7 @@ export function NavUserCenter({
|
||||
permission: ({ user }: { user: User }) => boolean
|
||||
}[]
|
||||
}) {
|
||||
const t = useTranslations("Console")
|
||||
const { user } = useAuth();
|
||||
const pathname = usePathname() ?? "/"
|
||||
|
||||
@ -38,7 +39,7 @@ export function NavUserCenter({
|
||||
<Link href={item.url}>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<span>{t(item.title)}</span>
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
|
@ -11,6 +11,7 @@ import { useAuth } from "@/contexts/auth-context";
|
||||
import { getFileUri } from "@/utils/client/file";
|
||||
import { getGravatarFromUser } from "@/utils/common/gravatar";
|
||||
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@ -25,9 +26,8 @@ interface PictureInputChangeEvent {
|
||||
}
|
||||
|
||||
export function UserProfilePage() {
|
||||
const t = useTranslations("Console.user_profile")
|
||||
const { user } = useAuth();
|
||||
|
||||
|
||||
const [nickname, setNickname] = useState(user?.nickname || '')
|
||||
const [username, setUsername] = useState(user?.username || '')
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||
@ -62,12 +62,12 @@ export function UserProfilePage() {
|
||||
};
|
||||
if (!file.type || !file.type.startsWith('image/') || !constraints.allowedTypes.includes(file.type)) {
|
||||
setAvatarFile(null);
|
||||
toast.error('只允许上传 PNG / JPEG / WEBP / GIF 格式的图片');
|
||||
toast.error(t("only_allow_picture"));
|
||||
return;
|
||||
}
|
||||
if (file.size > constraints.maxSize) {
|
||||
setAvatarFile(null);
|
||||
toast.error('图片大小不能超过 5MB');
|
||||
toast.error(t("picture_size_cannot_exceed", {"size": "5MiB"}));
|
||||
return;
|
||||
}
|
||||
setAvatarFile(file);
|
||||
@ -79,15 +79,15 @@ export function UserProfilePage() {
|
||||
nickname.trim() === '' ||
|
||||
username.trim() === ''
|
||||
) {
|
||||
toast.error('Nickname and Username cannot be empty')
|
||||
toast.error(t("nickname_and_username_cannot_be_empty"))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(username.length < 3 || username.length > 20) ||
|
||||
(username.length < 1 || username.length > 20) ||
|
||||
(nickname.length < 1 || nickname.length > 20)
|
||||
) {
|
||||
toast.error('Nickname and Username must be between 3 and 20 characters')
|
||||
toast.error(t("nickname_and_username_must_be_between", {"min": 1, "max": 20}))
|
||||
return
|
||||
}
|
||||
|
||||
@ -97,7 +97,7 @@ export function UserProfilePage() {
|
||||
gender === user.gender &&
|
||||
avatarFile === null
|
||||
) {
|
||||
toast.warning('No changes made')
|
||||
toast.warning(t("no_changes_made"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -108,19 +108,17 @@ export function UserProfilePage() {
|
||||
try {
|
||||
const resp = await uploadFile({ file: avatarFile });
|
||||
avatarUrl = getFileUri(resp.data.id);
|
||||
console.log('Uploaded avatar, got URL:', avatarUrl);
|
||||
} catch (error: unknown) {
|
||||
toast.error(`Failed to upload avatar ${error}`);
|
||||
toast.error(`${t("failed_to_upload_avatar")}: ${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUser({ nickname, username, avatarUrl, gender, id: user.id });
|
||||
toast.success('Profile updated successfully');
|
||||
window.location.reload();
|
||||
} catch (error: unknown) {
|
||||
toast.error(`Failed to update profile ${error}`);
|
||||
toast.error(`${t("failed_to_update_profile")}: ${error}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -138,11 +136,11 @@ export function UserProfilePage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
Public Profile
|
||||
{t("public_profile")}
|
||||
</h1>
|
||||
<Separator className="my-2" />
|
||||
<div className="grid w-full max-w-sm items-center gap-3">
|
||||
<Label htmlFor="picture">Picture</Label>
|
||||
<Label htmlFor="picture">{t("picture")}</Label>
|
||||
<Avatar className="h-40 w-40 rounded-xl border-2">
|
||||
{avatarFileUrl ?
|
||||
<AvatarImage src={avatarFileUrl} alt={nickname || username} /> :
|
||||
@ -157,13 +155,13 @@ export function UserProfilePage() {
|
||||
/>
|
||||
<ImageCropper image={avatarFile} onCropped={handleCropped} />
|
||||
</div>
|
||||
<Label htmlFor="nickname">Nickname</Label>
|
||||
<Label htmlFor="nickname">{t("nickname")}</Label>
|
||||
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
<Label htmlFor="gender">Gender</Label>
|
||||
<Label htmlFor="gender">{t("gender")}</Label>
|
||||
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)}/>
|
||||
<Button className="max-w-1/3" onClick={handleSubmit} disabled={submitting}>Submit{submitting && '...'}</Button>
|
||||
<Button className="max-w-1/3" onClick={handleSubmit} disabled={submitting}>{t("update_profile")}{submitting && '...'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -3,82 +3,95 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useState } from "react";
|
||||
import { requestEmailVerifyCode, updateEmail, updatePassword } from "@/api/user";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BaseErrorResponse } from "@/models/resp";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useToResetPassword } from "@/hooks/use-route";
|
||||
import { InputOTPControlled } from "@/components/common/input-otp";
|
||||
// const VERIFY_CODE_COOL_DOWN = 60; // seconds
|
||||
|
||||
export function UserSecurityPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const t = useTranslations("Console.user_security")
|
||||
const { user, setUser } = useAuth();
|
||||
const toResetPassword = useToResetPassword();
|
||||
const [email, setEmail] = useState(user?.email || "")
|
||||
const [verifyCode, setVerifyCode] = useState("")
|
||||
const [oldPassword, setOldPassword] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const handleSubmitPassword = () => {
|
||||
|
||||
const handleSubmitPassword = () => {
|
||||
updatePassword({ oldPassword, newPassword }).then(() => {
|
||||
toast.success(t("update_password_success"))
|
||||
setOldPassword("")
|
||||
setNewPassword("")
|
||||
}).catch((error: BaseErrorResponse) => {
|
||||
toast.error(`${t("update_password_failed")}: ${error.response.data.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSendVerifyCode = () => {
|
||||
console.log("send verify code to ", email)
|
||||
requestEmailVerifyCode(email)
|
||||
.then(() => {
|
||||
toast.success(t("send_verify_code_success"))
|
||||
})
|
||||
.catch((error: BaseErrorResponse) => {
|
||||
console.log("error", error)
|
||||
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmitEmail = () => {
|
||||
console.log("submit email ", email, verifyCode)
|
||||
updateEmail({ newEmail: email, verifyCode }).then(() => {
|
||||
toast.success(t("update_email_success"))
|
||||
if (user) {
|
||||
setUser({
|
||||
...user,
|
||||
email,
|
||||
})
|
||||
}
|
||||
setVerifyCode("")
|
||||
}).catch((error: BaseErrorResponse) => {
|
||||
toast.error(`${t("update_email_failed")}: ${error.response.data.message}`)
|
||||
})
|
||||
}
|
||||
if (!user) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="grid w-full max-w-sm items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">
|
||||
密码设置
|
||||
{t("password_setting")}
|
||||
</h1>
|
||||
<Label htmlFor="password">Old Password</Label>
|
||||
<Label htmlFor="password">{t("old_password")}</Label>
|
||||
<Input id="password" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||
<Label htmlFor="password">New Password</Label>
|
||||
<Label htmlFor="password">{t("new_password")}</Label>
|
||||
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
<Button className="max-w-1/3 border-2" onClick={handleSubmitPassword}>Submit</Button>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button disabled={!oldPassword || !newPassword} className="max-w-1/3 border-2" onClick={handleSubmitPassword}>{t("update_password")}</Button>
|
||||
<Button onClick={toResetPassword} variant="ghost">{t("forgot_password_or_no_password")}</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid w-full max-w-sm items-center gap-3 py-4">
|
||||
<h1 className="text-2xl font-bold">
|
||||
邮箱设置
|
||||
{t("email_setting")}
|
||||
</h1>
|
||||
<Label htmlFor="email">email</Label>
|
||||
<Label htmlFor="email">{t("email")}</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Button variant="outline" className="border-2" onClick={handleSendVerifyCode}>发送验证码</Button>
|
||||
<Button disabled={!email || email == user.email} variant="outline" className="border-2" onClick={handleSendVerifyCode}>{t("send_verify_code")}</Button>
|
||||
</div>
|
||||
<Label htmlFor="verify-code">verify code</Label>
|
||||
<Label htmlFor="verify-code">{t("verify_code")}</Label>
|
||||
<div className="flex gap-3">
|
||||
<InputOTPControlled onChange={(value) => setVerifyCode(value)} />
|
||||
<Button className="border-2" onClick={handleSubmitEmail}>Submit</Button>
|
||||
<Button disabled={verifyCode.length < 6} className="border-2" onClick={handleSubmitEmail}>{t("update_email")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPControlled({ onChange }: { onChange: (value: string) => void }) {
|
||||
const [value, setValue] = useState("")
|
||||
useEffect(() => {
|
||||
onChange(value)
|
||||
}, [value, onChange])
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,7 @@ 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 { getCaptchaConfig, listOidcConfigs, userLogin } from "@/api/user"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
@ -22,12 +22,14 @@ import Captcha from "../common/captcha"
|
||||
import { CaptchaProvider } from "@/models/captcha"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { resetPasswordPath, useToResetPassword } from "@/hooks/use-route"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const t = useTranslations('Login')
|
||||
const toResetPassword = useToResetPassword();
|
||||
const {user, setUser} = useAuth();
|
||||
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
||||
const [captchaProps, setCaptchaProps] = useState<{
|
||||
@ -50,7 +52,7 @@ export function LoginForm({
|
||||
}, [user, router, redirectBack]);
|
||||
|
||||
useEffect(() => {
|
||||
ListOidcConfigs()
|
||||
listOidcConfigs()
|
||||
.then((res) => {
|
||||
setOidcConfigs(res.data || [])
|
||||
})
|
||||
@ -158,12 +160,12 @@ export function LoginForm({
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<a
|
||||
href="#"
|
||||
<Link
|
||||
href={resetPasswordPath}
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("forgot_password")}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
@ -179,7 +181,7 @@ export function LoginForm({
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
type="submit"
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={handleLogin}
|
||||
disabled={!captchaToken || isLogging}
|
||||
@ -191,9 +193,9 @@ export function LoginForm({
|
||||
{/* 注册链接 */}
|
||||
<div className="text-center text-sm">
|
||||
{t("no_account")}{" "}
|
||||
<a href="#" className="underline underline-offset-4">
|
||||
<Link href="#" className="underline underline-offset-4">
|
||||
{t("register")}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
103
web/src/components/reset-password/reset-password-form.tsx
Normal file
103
web/src/components/reset-password/reset-password-form.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"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 Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import { requestEmailVerifyCode, resetPassword } from "@/api/user"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { InputOTPControlled } from "@/components/common/input-otp"
|
||||
import { BaseErrorResponse } from "@/models/resp"
|
||||
import { loginPath, useToLogin } from "@/hooks/use-route"
|
||||
import router from "next/router"
|
||||
|
||||
export function ResetPasswordForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const t = useTranslations('ResetPassword')
|
||||
const toLogin = useToLogin();
|
||||
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">{t("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>
|
||||
)
|
||||
}
|
@ -5,11 +5,22 @@ import { useRouter, usePathname } from "next/navigation"
|
||||
* 用于跳转到登录页并自动带上 redirect_back 参数
|
||||
* 用法:const toLogin = useToLogin(); <Button onClick={toLogin}>去登录</Button>
|
||||
*/
|
||||
export const loginPath = "/login"
|
||||
export const resetPasswordPath = "/reset-password"
|
||||
|
||||
export function useToLogin() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
return () => {
|
||||
router.push(`/login?redirect_back=${encodeURIComponent(pathname)}`)
|
||||
router.push(`${loginPath}?redirect_back=${encodeURIComponent(pathname)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function useToResetPassword() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
return () => {
|
||||
router.push(`${resetPasswordPath}?redirect_back=${encodeURIComponent(pathname)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,15 +53,74 @@
|
||||
"unlike_success": "已取消点赞",
|
||||
"update": "更新"
|
||||
},
|
||||
"Common":{
|
||||
"Common": {
|
||||
"login": "登录",
|
||||
"daysAgo": "天前",
|
||||
"hoursAgo": "小时前",
|
||||
"minutesAgo": "分钟前",
|
||||
"secondsAgo": "秒前"
|
||||
"secondsAgo": "秒前",
|
||||
"submit": "提交",
|
||||
"update": "更新"
|
||||
},
|
||||
"Console": {
|
||||
"login_required": "请先登录再进入后台"
|
||||
"comment": {
|
||||
"title": "评论管理"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "大石坝"
|
||||
},
|
||||
"file": {
|
||||
"title": "文件管理"
|
||||
},
|
||||
"global": {
|
||||
"title": "全局配置"
|
||||
},
|
||||
"login_required": "请先登录再进入后台",
|
||||
"post": {
|
||||
"title": "文章管理"
|
||||
},
|
||||
"user": {
|
||||
"title": "用户管理"
|
||||
},
|
||||
"user_profile": {
|
||||
"title": "个人资料",
|
||||
"edit": "编辑",
|
||||
"failed_to_upload_avatar": "上传头像失败",
|
||||
"failed_to_update_profile": "更新个人资料失败",
|
||||
"gender": "性别",
|
||||
"nickname": "昵称",
|
||||
"nickname_and_username_cannot_be_empty": "昵称和用户名不能为空",
|
||||
"nickname_and_username_must_be_between": "昵称和用户名长度必须在{min}~{max}之间",
|
||||
"no_changes_made": "没有修改任何内容",
|
||||
"only_allow_picture": "仅允许上传图片格式:PNG / JPEG / WEBP / GIF",
|
||||
"picture": "头像",
|
||||
"picture_size_cannot_exceed": "图片大小不能超过{size}",
|
||||
"public_profile": "公开资料",
|
||||
"username": "用户名",
|
||||
"update_profile": "更新资料"
|
||||
},
|
||||
"user_security": {
|
||||
"title": "安全设置",
|
||||
"email": "邮箱",
|
||||
"email_setting": "邮箱设置",
|
||||
"forgot_password_or_no_password": "忘记密码或没有密码",
|
||||
"new_password": "新密码",
|
||||
"old_password": "旧密码",
|
||||
"password_setting": "密码设置",
|
||||
"send_verify_code": "发送验证码",
|
||||
"send_verify_code_failed": "发送验证码失败",
|
||||
"send_verify_code_success": "验证码已发送",
|
||||
"update_email": "更新邮箱",
|
||||
"update_email_failed": "更新邮箱失败",
|
||||
"update_email_success": "邮箱已更新",
|
||||
"update_password": "更新密码",
|
||||
"update_password_failed": "更新密码失败",
|
||||
"update_password_success": "密码已更新",
|
||||
"verify_code": "验证码"
|
||||
},
|
||||
"user-preference": {
|
||||
"title": "个性化"
|
||||
}
|
||||
},
|
||||
"Login": {
|
||||
"captcha_error": "验证错误,请重试。",
|
||||
@ -83,5 +142,17 @@
|
||||
"terms_of_service": "服务条款",
|
||||
"and": "和",
|
||||
"privacy_policy": "隐私政策"
|
||||
},
|
||||
"ResetPassword": {
|
||||
"title": "重置密码",
|
||||
"email": "邮箱",
|
||||
"new_password": "新密码",
|
||||
"reset_password": "重置密码",
|
||||
"reset_password_failed": "重置密码失败",
|
||||
"reset_password_success": "密码已重置,请使用新密码登录",
|
||||
"send_verify_code": "发送验证码",
|
||||
"send_verify_code_failed": "发送验证码失败",
|
||||
"send_verify_code_success": "验证码已发送",
|
||||
"verify_code": "验证码"
|
||||
}
|
||||
}
|
@ -1,5 +1,13 @@
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
|
||||
export interface BaseResponse<T> {
|
||||
data: T;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface BaseErrorResponse<T = unknown, E = Record<string, unknown>> extends AxiosError<T> {
|
||||
response: AxiosResponse & {
|
||||
data: E & BaseResponse<null>;
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user