mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-25 18:46:23 +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:
13
TODO.md
13
TODO.md
@ -1,12 +1,7 @@
|
||||
# TODO List
|
||||
|
||||
## 主页模块
|
||||
## 代办
|
||||
|
||||
- [ ]主页文章列表
|
||||
- [ ]主页侧边栏卡片列表
|
||||
|
||||
## 评论模块
|
||||
|
||||
- [ ]评论列表
|
||||
- [ ]评论输入框
|
||||
- [ ]评论回复功能
|
||||
- [ ] 给邮件验证码加冷却时间,前后端都加
|
||||
- [ ] 优化评论区的时间显示
|
||||
- [ ] 在当前有用户登录时,使用OIDC会自动绑定
|
@ -52,6 +52,12 @@ func (u *UserController) Register(ctx context.Context, c *app.RequestContext) {
|
||||
resps.BadRequest(c, resps.ErrParamInvalid)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(string(c.GetHeader(constant.HeaderKeyEmail)))
|
||||
if email == "" {
|
||||
resps.BadRequest(c, "Email header is required")
|
||||
return
|
||||
}
|
||||
userRegisterReq.Email = email
|
||||
resp, err := u.service.UserRegister(&userRegisterReq)
|
||||
|
||||
if err != nil {
|
||||
@ -97,7 +103,7 @@ func (u *UserController) OidcLogin(ctx context.Context, c *app.RequestContext) {
|
||||
Code: code,
|
||||
State: state,
|
||||
}
|
||||
resp, err := u.service.OidcLogin(oidcLoginReq)
|
||||
resp, err := u.service.OidcLogin(ctx, oidcLoginReq)
|
||||
if err != nil {
|
||||
serviceErr := errs.AsServiceError(err)
|
||||
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
|
||||
|
@ -29,11 +29,10 @@ type UserLoginResp struct {
|
||||
}
|
||||
|
||||
type UserRegisterReq struct {
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Password string `json:"password"` // 密码
|
||||
Email string `json:"email"` // 邮箱
|
||||
VerificationCode string `json:"verification_code"` // 邮箱验证码
|
||||
Username string `json:"username"` // 用户名
|
||||
Nickname string `json:"nickname"` // 昵称
|
||||
Password string `json:"password"` // 密码
|
||||
Email string `json:"-" binding:"-"`
|
||||
}
|
||||
|
||||
type UserRegisterResp struct {
|
||||
|
@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/hertz/pkg/app"
|
||||
"github.com/snowykami/neo-blog/pkg/constant"
|
||||
@ -12,8 +13,8 @@ import (
|
||||
// UseEmailVerify 中间件函数,用于邮箱验证,使用前先调用请求发送邮件验证码函数
|
||||
func UseEmailVerify() app.HandlerFunc {
|
||||
return func(ctx context.Context, c *app.RequestContext) {
|
||||
email := string(c.GetHeader(constant.HeaderKeyEmail))
|
||||
verifyCode := string(c.GetHeader(constant.HeaderKeyVerifyCode))
|
||||
email := strings.TrimSpace(string(c.GetHeader(constant.HeaderKeyEmail)))
|
||||
verifyCode := strings.TrimSpace(string(c.GetHeader(constant.HeaderKeyVerifyCode)))
|
||||
if !utils.Env.GetAsBool(constant.EnvKeyEnableEmailVerify, true) {
|
||||
c.Next(ctx)
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ func registerUserRoutes(group *route.RouterGroup) {
|
||||
{
|
||||
userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login)
|
||||
userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register)
|
||||
userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code
|
||||
userGroupWithoutAuth.POST("/email/verify", userController.VerifyEmail) // Send email verification code
|
||||
userGroupWithoutAuth.GET("/captcha", userController.GetCaptchaConfig)
|
||||
userGroupWithoutAuth.GET("/oidc/list", userController.OidcList)
|
||||
userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin)
|
||||
|
@ -56,16 +56,9 @@ func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, erro
|
||||
}
|
||||
|
||||
func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) {
|
||||
// 验证邮箱验证码
|
||||
if !utils.Env.GetAsBool(constant.EnvKeyEnableRegister, true) {
|
||||
return nil, errs.ErrForbidden
|
||||
}
|
||||
if utils.Env.GetAsBool(constant.EnvKeyEnableEmailVerify, true) {
|
||||
ok := utils.VerifyEmailCode(req.Email, req.VerificationCode)
|
||||
if !ok {
|
||||
return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
|
||||
}
|
||||
}
|
||||
// 检查用户名或邮箱是否已存在
|
||||
usernameExist, err := repo.User.CheckUsernameExists(req.Username)
|
||||
if err != nil {
|
||||
@ -185,7 +178,7 @@ func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) {
|
||||
return oidcConfigsDtos, nil
|
||||
}
|
||||
|
||||
func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) {
|
||||
func (s *UserService) OidcLogin(ctx context.Context, req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) {
|
||||
// 验证state
|
||||
kvStore := utils.KV.GetInstance()
|
||||
storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { OidcConfig } from '@/models/oidc-config'
|
||||
import type { BaseResponse } from '@/models/resp'
|
||||
import type { RegisterRequest, User } from '@/models/user'
|
||||
import type { User } from '@/models/user'
|
||||
import { CaptchaProvider } from '@/models/captcha'
|
||||
import axiosClient from './client'
|
||||
|
||||
@ -31,11 +31,18 @@ export async function userLogout(): Promise<BaseResponse<null>> {
|
||||
}
|
||||
|
||||
export async function userRegister(
|
||||
data: RegisterRequest,
|
||||
{ username, password, email, verifyCode, captchaToken }: {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
verifyCode?: string
|
||||
captchaToken?: string
|
||||
},
|
||||
): Promise<BaseResponse<{ token: string, user: User }>> {
|
||||
const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>(
|
||||
'/user/register',
|
||||
data,
|
||||
{ username, password, },
|
||||
{ headers: { 'X-Email': email, 'X-VerifyCode': verifyCode || '' , 'X-Captcha-Token': captchaToken || ''} },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Navbar } from '@/components/layout/navbar-or-side'
|
||||
import { Navbar } from '@/components/layout/nav/navbar-or-side'
|
||||
import { BackgroundProvider } from '@/contexts/background-context'
|
||||
import Footer from '@/components/layout/footer'
|
||||
import config from '@/config'
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Suspense } from 'react'
|
||||
import { LoginForm } from '@/components/login/login-form'
|
||||
import { AuthHeader } from '@/components/common/auth-header'
|
||||
import { LoginForm } from '@/components/auth/login/login-form'
|
||||
import { AuthHeader } from '@/components/auth/common/auth-header'
|
||||
|
||||
function LoginPageContent() {
|
||||
return (
|
40
web/src/app/auth/register/page.tsx
Normal file
40
web/src/app/auth/register/page.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Suspense } from 'react'
|
||||
import { AuthHeader } from '@/components/auth/common/auth-header'
|
||||
import { RegisterForm } from '@/components/auth/register/register-form'
|
||||
|
||||
function PageContent() {
|
||||
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">
|
||||
<AuthHeader />
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={(
|
||||
<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">
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center gap-3 self-center mb-6">
|
||||
<div className="size-10 bg-gray-300 rounded-full"></div>
|
||||
<div className="h-8 bg-gray-300 rounded w-32"></div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-6 space-y-4">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
<div className="h-10 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { AuthHeader } from "@/components/common/auth-header";
|
||||
import { ResetPasswordForm } from "@/components/reset-password/reset-password-form";
|
||||
import { AuthHeader } from "@/components/auth/common/auth-header";
|
||||
import { ResetPasswordForm } from "@/components/auth/reset-password/reset-password-form";
|
||||
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">
|
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>
|
||||
)
|
||||
}
|
@ -18,19 +18,20 @@ 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 "../common/captcha"
|
||||
import Captcha from "@/components/common/captcha"
|
||||
import { CaptchaProvider } from "@/models/captcha"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { resetPasswordPath, useToResetPassword } from "@/hooks/use-route"
|
||||
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 toResetPassword = useToResetPassword();
|
||||
const {user, setUser} = useAuth();
|
||||
const { user, setUser } = useAuth();
|
||||
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
||||
const [captchaProps, setCaptchaProps] = useState<{
|
||||
provider: CaptchaProvider
|
||||
@ -45,12 +46,6 @@ export function LoginForm({
|
||||
const searchParams = useSearchParams()
|
||||
const redirectBack = searchParams.get("redirect_back") || "/"
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
router.push(redirectBack);
|
||||
}
|
||||
}, [user, router, redirectBack]);
|
||||
|
||||
useEffect(() => {
|
||||
listOidcConfigs()
|
||||
.then((res) => {
|
||||
@ -105,11 +100,10 @@ export function LoginForm({
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t("welcome")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("with_oidc")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CurrentLogged />
|
||||
<SectionDivider className="my-4">{t("with_oidc")}</SectionDivider>
|
||||
<form>
|
||||
<div className="grid gap-6">
|
||||
{/* OIDC 登录选项 */}
|
||||
@ -134,16 +128,10 @@ export function LoginForm({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分隔线 */}
|
||||
{oidcConfigs.length > 0 && (
|
||||
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
|
||||
<span className="bg-card text-muted-foreground relative z-10 px-2">
|
||||
{t("or_continue_with_local_account")}
|
||||
</span>
|
||||
</div>
|
||||
<SectionDivider className="my-0"> {t("or_continue_with_local_account")}</SectionDivider>
|
||||
)}
|
||||
|
||||
{/* 邮箱密码登录 */}
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-3">
|
||||
@ -193,7 +181,7 @@ export function LoginForm({
|
||||
{/* 注册链接 */}
|
||||
<div className="text-center text-sm">
|
||||
{t("no_account")}{" "}
|
||||
<Link href="#" className="underline underline-offset-4">
|
||||
<Link href={registerPath+"?redirect_back="+encodeURIComponent(redirectBack)} className="underline underline-offset-4">
|
||||
{t("register")}
|
||||
</Link>
|
||||
</div>
|
||||
@ -201,18 +189,6 @@ export function LoginForm({
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 服务条款 */}
|
||||
<div className="text-muted-foreground text-center text-xs text-balance">
|
||||
{t("by_logging_in_you_agree_to_our")}{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
{t("terms_of_service")}
|
||||
</a>{" "}
|
||||
{t("and")}{" "}
|
||||
<a href="#" className="underline underline-offset-4 hover:text-primary">
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
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>
|
||||
)
|
||||
}
|
@ -10,15 +10,13 @@ import {
|
||||
} 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 { loginPath } from "@/hooks/use-route"
|
||||
import router from "next/router"
|
||||
|
||||
export function ResetPasswordForm({
|
||||
@ -26,7 +24,7 @@ export function ResetPasswordForm({
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const t = useTranslations('ResetPassword')
|
||||
const toLogin = useToLogin();
|
||||
const commonT = useTranslations('Common')
|
||||
const [email, setEmail] = useState("")
|
||||
const [verifyCode, setVerifyCode] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
@ -65,7 +63,7 @@ export function ResetPasswordForm({
|
||||
<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>
|
||||
<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
|
15
web/src/components/common/section-divider.tsx
Normal file
15
web/src/components/common/section-divider.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SectionDivider({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
cn("after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t",
|
||||
className)
|
||||
}
|
||||
>
|
||||
<span className="bg-card text-muted-foreground relative z-10 px-2" {...props}>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -9,14 +9,15 @@ 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 { resetPasswordPath } from "@/hooks/use-route";
|
||||
import { InputOTPControlled } from "@/components/common/input-otp";
|
||||
import Link from "next/link";
|
||||
// const VERIFY_CODE_COOL_DOWN = 60; // seconds
|
||||
|
||||
export function UserSecurityPage() {
|
||||
const t = useTranslations("Console.user_security")
|
||||
const commonT = useTranslations("Common")
|
||||
const { user, setUser } = useAuth();
|
||||
const toResetPassword = useToResetPassword();
|
||||
const [email, setEmail] = useState(user?.email || "")
|
||||
const [verifyCode, setVerifyCode] = useState("")
|
||||
const [oldPassword, setOldPassword] = useState("")
|
||||
@ -70,7 +71,7 @@ export function UserSecurityPage() {
|
||||
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
<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>
|
||||
<Link href={resetPasswordPath}>{t("forgot_password_or_no_password")}</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -79,7 +80,7 @@ export function UserSecurityPage() {
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("email_setting")}
|
||||
</h1>
|
||||
<Label htmlFor="email">{t("email")}</Label>
|
||||
<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 || email == user.email} variant="outline" className="border-2" onClick={handleSendVerifyCode}>{t("send_verify_code")}</Button>
|
||||
|
@ -31,6 +31,7 @@ export function AvatarWithDropdownMenu() {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="rounded-full overflow-hidden">
|
||||
{user ? <Avatar className="h-8 w-8 rounded-full">
|
||||
@ -39,26 +40,33 @@ export function AvatarWithDropdownMenu() {
|
||||
</Avatar> : <CircleUser className="h-8 w-8" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-auto" align="start">
|
||||
<DropdownMenuLabel>
|
||||
{user && <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
{user && <DropdownMenuLabel>
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{formatDisplayName(user)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{user && <DropdownMenuItem asChild>
|
||||
<Link href={`/u/${user?.username}`}>Profile</Link>
|
||||
</DropdownMenuItem>}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/console">Console</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
</DropdownMenuLabel>}
|
||||
|
||||
{user &&
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/u/${user?.username}`}>Profile</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/console">Console</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
}
|
||||
|
||||
<DropdownMenuItem onClick={user ? handleLogout : toLogin}>
|
||||
{user ? "Logout" : "Login"}
|
||||
</DropdownMenuItem>
|
@ -17,8 +17,8 @@ import config from "@/config"
|
||||
import { useState } from "react"
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { Menu } from "lucide-react"
|
||||
import { ThemeModeToggle } from "../common/theme-toggle"
|
||||
import { AvatarWithDropdownMenu } from "./avatar-with-dropdown-menu"
|
||||
import { ThemeModeToggle } from "@/components/common/theme-toggle"
|
||||
import { AvatarWithDropdownMenu } from "@/components/layout/nav/avatar-with-dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const navbarMenuComponents = [
|
@ -5,8 +5,10 @@ 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 const authPath = "/auth"
|
||||
export const loginPath = authPath + "/login"
|
||||
export const registerPath = authPath + "/register"
|
||||
export const resetPasswordPath = authPath + "/reset-password"
|
||||
|
||||
export function useToLogin() {
|
||||
const router = useRouter()
|
||||
@ -16,14 +18,6 @@ export function useToLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
export function useToResetPassword() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
return () => {
|
||||
router.push(`${resetPasswordPath}?redirect_back=${encodeURIComponent(pathname)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function useToUserProfile() {
|
||||
const router = useRouter();
|
||||
return (username: string) => {
|
||||
|
@ -54,13 +54,19 @@
|
||||
"update": "更新"
|
||||
},
|
||||
"Common": {
|
||||
"email": "邮箱",
|
||||
"forgot_password": "忘记密码?",
|
||||
"login": "登录",
|
||||
"daysAgo": "天前",
|
||||
"hoursAgo": "小时前",
|
||||
"minutesAgo": "分钟前",
|
||||
"password": "密码",
|
||||
"secondsAgo": "秒前",
|
||||
"send_verify_code": "发送验证码",
|
||||
"submit": "提交",
|
||||
"update": "更新"
|
||||
"update": "更新",
|
||||
"username": "用户名",
|
||||
"verify_code": "验证码"
|
||||
},
|
||||
"Console": {
|
||||
"comment": {
|
||||
@ -101,7 +107,6 @@
|
||||
},
|
||||
"user_security": {
|
||||
"title": "安全设置",
|
||||
"email": "邮箱",
|
||||
"email_setting": "邮箱设置",
|
||||
"forgot_password_or_no_password": "忘记密码或没有密码",
|
||||
"new_password": "新密码",
|
||||
@ -124,6 +129,8 @@
|
||||
},
|
||||
"Login": {
|
||||
"captcha_error": "验证错误,请重试。",
|
||||
"continue": "继续",
|
||||
"currently_logged_in": "当前已登录为",
|
||||
"fetch_captcha_config_failed": "获取验证码失败,请稍后重试。",
|
||||
"fetch_oidc_configs_failed": "获取第三方身份提供者配置失败。",
|
||||
"logging": "正在登录...",
|
||||
@ -143,9 +150,16 @@
|
||||
"and": "和",
|
||||
"privacy_policy": "隐私政策"
|
||||
},
|
||||
"Register": {
|
||||
"title": "注册",
|
||||
"register": "注册",
|
||||
"registering": "注册中...",
|
||||
"register_a_new_account": "注册一个新账号",
|
||||
"register_failed": "注册失败",
|
||||
"register_success": "注册成功!"
|
||||
},
|
||||
"ResetPassword": {
|
||||
"title": "重置密码",
|
||||
"email": "邮箱",
|
||||
"new_password": "新密码",
|
||||
"reset_password": "重置密码",
|
||||
"reset_password_failed": "重置密码失败",
|
||||
|
@ -14,11 +14,3 @@ export enum Role {
|
||||
USER = "user",
|
||||
EDITOR = "editor",
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
nickname: string
|
||||
email: string
|
||||
verificationCode?: string
|
||||
}
|
||||
|
Reference in New Issue
Block a user