diff --git a/TODO.md b/TODO.md index 5f92b28..b904e78 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,7 @@ # TODO List -## 主页模块 +## 代办 -- [ ]主页文章列表 -- [ ]主页侧边栏卡片列表 - -## 评论模块 - -- [ ]评论列表 -- [ ]评论输入框 -- [ ]评论回复功能 \ No newline at end of file +- [ ] 给邮件验证码加冷却时间,前后端都加 +- [ ] 优化评论区的时间显示 +- [ ] 在当前有用户登录时,使用OIDC会自动绑定 \ No newline at end of file diff --git a/internal/controller/v1/user.go b/internal/controller/v1/user.go index 6c0e622..daaf2a2 100644 --- a/internal/controller/v1/user.go +++ b/internal/controller/v1/user.go @@ -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) diff --git a/internal/dto/user.go b/internal/dto/user.go index b8bdfc5..bcc7bd0 100644 --- a/internal/dto/user.go +++ b/internal/dto/user.go @@ -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 { diff --git a/internal/middleware/email_verify.go b/internal/middleware/email_verify.go index 818008a..91c1cdc 100644 --- a/internal/middleware/email_verify.go +++ b/internal/middleware/email_verify.go @@ -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) } diff --git a/internal/router/apiv1/user.go b/internal/router/apiv1/user.go index f852a3c..f31aa9b 100644 --- a/internal/router/apiv1/user.go +++ b/internal/router/apiv1/user.go @@ -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) diff --git a/internal/service/user.go b/internal/service/user.go index 272b811..ebd2d2b 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -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) diff --git a/web/src/api/user.ts b/web/src/api/user.ts index f86aea3..d470e34 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -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> { } export async function userRegister( - data: RegisterRequest, + { username, password, email, verifyCode, captchaToken }: { + username: string + password: string + email: string + verifyCode?: string + captchaToken?: string + }, ): Promise> { const res = await axiosClient.post>( '/user/register', - data, + { username, password, }, + { headers: { 'X-Email': email, 'X-VerifyCode': verifyCode || '' , 'X-Captcha-Token': captchaToken || ''} }, ) return res.data } diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx index 7c62a6e..4f3e464 100644 --- a/web/src/app/(main)/layout.tsx +++ b/web/src/app/(main)/layout.tsx @@ -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' diff --git a/web/src/app/login/page.tsx b/web/src/app/auth/login/page.tsx similarity index 90% rename from web/src/app/login/page.tsx rename to web/src/app/auth/login/page.tsx index 497ed2b..656f391 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -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 ( diff --git a/web/src/app/auth/register/page.tsx b/web/src/app/auth/register/page.tsx new file mode 100644 index 0000000..29e5476 --- /dev/null +++ b/web/src/app/auth/register/page.tsx @@ -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 ( +
+
+ + +
+
+ ) +} + +export default function Page() { + return ( + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + )} + > + +
+ ) +} diff --git a/web/src/app/reset-password/page.tsx b/web/src/app/auth/reset-password/page.tsx similarity index 64% rename from web/src/app/reset-password/page.tsx rename to web/src/app/auth/reset-password/page.tsx index 00e3a12..6ba8175 100644 --- a/web/src/app/reset-password/page.tsx +++ b/web/src/app/auth/reset-password/page.tsx @@ -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 (
diff --git a/web/src/components/common/auth-header.tsx b/web/src/components/auth/common/auth-header.tsx similarity index 100% rename from web/src/components/common/auth-header.tsx rename to web/src/components/auth/common/auth-header.tsx diff --git a/web/src/components/auth/common/current-logged.tsx b/web/src/components/auth/common/current-logged.tsx new file mode 100644 index 0000000..2f9914d --- /dev/null +++ b/web/src/components/auth/common/current-logged.tsx @@ -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 ( +
+ {t("currently_logged_in")} +
+
+ + + {getFallbackAvatarFromUsername(user.nickname || user.username)} + +
+
+ {formatDisplayName(user)} + + {user.email} + +
+
+
+ ) +} \ No newline at end of file diff --git a/web/src/components/login/login-form.tsx b/web/src/components/auth/login/login-form.tsx similarity index 85% rename from web/src/components/login/login-form.tsx rename to web/src/components/auth/login/login-form.tsx index 39b2188..a24888f 100644 --- a/web/src/components/login/login-form.tsx +++ b/web/src/components/auth/login/login-form.tsx @@ -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([]) 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({ {t("welcome")} - - {t("with_oidc")} - + + {t("with_oidc")}
{/* OIDC 登录选项 */} @@ -134,16 +128,10 @@ export function LoginForm({ })}
)} - {/* 分隔线 */} {oidcConfigs.length > 0 && ( -
- - {t("or_continue_with_local_account")} - -
+ {t("or_continue_with_local_account")} )} - {/* 邮箱密码登录 */}
@@ -193,7 +181,7 @@ export function LoginForm({ {/* 注册链接 */}
{t("no_account")}{" "} - + {t("register")}
@@ -201,18 +189,6 @@ export function LoginForm({ - - {/* 服务条款 */} -
- {t("by_logging_in_you_agree_to_our")}{" "} - - {t("terms_of_service")} - {" "} - {t("and")}{" "} - - {t("privacy_policy")} - -
) } diff --git a/web/src/components/auth/register/register-form.tsx b/web/src/components/auth/register/register-form.tsx new file mode 100644 index 0000000..3b89039 --- /dev/null +++ b/web/src/components/auth/register/register-form.tsx @@ -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(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 ( +
+ + + {t("title")} + + + +
+
+ {t("register_a_new_account")} + +
+ + {/* 用户名 */} +
+
+ +
+ setUsername(e.target.value)} + /> +
+ {/* 密码 */} +
+
+ +
+ setPassword(e.target.value)} + /> +
+ {/* 邮箱 */} +
+ +
+ setEmail(e.target.value)} + /> + +
+
+ {/* 邮箱验证码 */} +
+ + setVerifyCode(value)} + /> +
+ {captchaProps && +
+ +
+ } + +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/web/src/components/reset-password/reset-password-form.tsx b/web/src/components/auth/reset-password/reset-password-form.tsx similarity index 94% rename from web/src/components/reset-password/reset-password-form.tsx rename to web/src/components/auth/reset-password/reset-password-form.tsx index 410cf1e..c780858 100644 --- a/web/src/components/reset-password/reset-password-form.tsx +++ b/web/src/components/auth/reset-password/reset-password-form.tsx @@ -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({ setNewPassword(e.target.value)} />
- +
setEmail(e.target.value)} /> - + {t("forgot_password_or_no_password")}
@@ -79,7 +80,7 @@ export function UserSecurityPage() {

{t("email_setting")}

- +
setEmail(e.target.value)} /> diff --git a/web/src/components/layout/avatar-with-dropdown-menu.tsx b/web/src/components/layout/nav/avatar-with-dropdown-menu.tsx similarity index 76% rename from web/src/components/layout/avatar-with-dropdown-menu.tsx rename to web/src/components/layout/nav/avatar-with-dropdown-menu.tsx index c0ed32d..ae13bce 100644 --- a/web/src/components/layout/avatar-with-dropdown-menu.tsx +++ b/web/src/components/layout/nav/avatar-with-dropdown-menu.tsx @@ -31,6 +31,7 @@ export function AvatarWithDropdownMenu() { return ( + + - - {user &&
+ {user && +
{formatDisplayName(user)} {user.email}
-
} -
- - {user && - Profile - } - - Console - - - +
+
} + + {user && + <> + + + Profile + + + Console + + + + + } + {user ? "Logout" : "Login"} diff --git a/web/src/components/layout/navbar-or-side.tsx b/web/src/components/layout/nav/navbar-or-side.tsx similarity index 97% rename from web/src/components/layout/navbar-or-side.tsx rename to web/src/components/layout/nav/navbar-or-side.tsx index ece03ff..16726fa 100644 --- a/web/src/components/layout/navbar-or-side.tsx +++ b/web/src/components/layout/nav/navbar-or-side.tsx @@ -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 = [ diff --git a/web/src/hooks/use-route.ts b/web/src/hooks/use-route.ts index 1d5095e..ee8de65 100644 --- a/web/src/hooks/use-route.ts +++ b/web/src/hooks/use-route.ts @@ -5,8 +5,10 @@ import { useRouter, usePathname } from "next/navigation" * 用于跳转到登录页并自动带上 redirect_back 参数 * 用法:const toLogin = useToLogin(); */ -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) => { diff --git a/web/src/locales/zh-CN.json b/web/src/locales/zh-CN.json index effc8be..39db21d 100644 --- a/web/src/locales/zh-CN.json +++ b/web/src/locales/zh-CN.json @@ -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": "重置密码失败", diff --git a/web/src/models/user.ts b/web/src/models/user.ts index 764eade..6b0fc6c 100644 --- a/web/src/models/user.ts +++ b/web/src/models/user.ts @@ -14,11 +14,3 @@ export enum Role { USER = "user", EDITOR = "editor", } - -export interface RegisterRequest { - username: string - password: string - nickname: string - email: string - verificationCode?: string -}