feat: add captcha support for user login and enhance user profile page

- Refactored userLogin function to include captcha handling.
- Introduced getCaptchaConfig API to fetch captcha configuration.
- Added Captcha component to handle different captcha providers (hCaptcha, reCaptcha, Turnstile).
- Updated LoginForm component to integrate captcha verification.
- Created UserProfile component to display user information with avatar.
- Implemented getUserByUsername API to fetch user details by username.
- Removed deprecated LoginRequest interface from user model.
- Enhanced navbar and layout with animation effects.
- Removed unused user page component and added dynamic user profile routing.
- Updated localization files to include captcha-related messages.
- Improved Gravatar component for better avatar handling.
This commit is contained in:
2025-09-10 21:15:36 +08:00
parent a7da023b1e
commit 4781d81869
28 changed files with 1048 additions and 701 deletions

View File

@ -10,8 +10,10 @@ import (
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/service"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps"
utils2 "github.com/snowykami/neo-blog/pkg/utils"
)
type UserController struct {
@ -125,6 +127,22 @@ func (u *UserController) GetUser(ctx context.Context, c *app.RequestContext) {
return
}
resps.Ok(c, resps.Success, resp.User)
}
func (u *UserController) GetUserByUsername(ctx context.Context, c *app.RequestContext) {
username := c.Param("username")
if username == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
resp, err := u.service.GetUserByUsername(&dto.GetUserByUsernameReq{Username: username})
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
resps.Ok(c, resps.Success, resp.User)
}
func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) {
@ -184,3 +202,11 @@ func (u *UserController) ChangePassword(ctx context.Context, c *app.RequestConte
func (u *UserController) ChangeEmail(ctx context.Context, c *app.RequestContext) {
// TODO: 实现修改邮箱功能
}
func (u *UserController) GetCaptchaConfig(ctx context.Context, c *app.RequestContext) {
resps.Ok(c, "ok", utils.H{
"provider": utils2.Env.Get(constant.EnvKeyCaptchaProvider),
"site_key": utils2.Env.Get(constant.EnvKeyCaptchaSiteKey),
"url": utils2.Env.Get(constant.EnvKeyCaptchaUrl),
})
}

View File

@ -70,6 +70,10 @@ type GetUserReq struct {
UserID uint `json:"user_id"`
}
type GetUserByUsernameReq struct {
Username string `json:"username"`
}
type GetUserResp struct {
User UserDto `json:"user"` // 用户信息
}

View File

@ -2,6 +2,7 @@ package middleware
import (
"context"
"github.com/cloudwego/hertz/pkg/app"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/resps"

View File

@ -1,27 +1,29 @@
package apiv1
import (
"github.com/cloudwego/hertz/pkg/route"
"github.com/snowykami/neo-blog/internal/controller/v1"
"github.com/snowykami/neo-blog/internal/middleware"
"github.com/cloudwego/hertz/pkg/route"
"github.com/snowykami/neo-blog/internal/controller/v1"
"github.com/snowykami/neo-blog/internal/middleware"
)
func registerUserRoutes(group *route.RouterGroup) {
userController := v1.NewUserController()
userGroup := group.Group("/user").Use(middleware.UseAuth(true))
userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false))
userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha())
{
userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login)
userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register)
userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code
userGroupWithoutAuth.GET("/oidc/list", userController.OidcList)
userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin)
userGroupWithoutAuth.GET("/u/:id", userController.GetUser)
userGroup.GET("/me", userController.GetUser)
userGroupWithoutAuth.POST("/logout", userController.Logout)
userGroup.PUT("/u/:id", userController.UpdateUser)
userGroup.PUT("/password/edit", userController.ChangePassword)
userGroup.PUT("/email/edit", userController.ChangeEmail)
}
userController := v1.NewUserController()
userGroup := group.Group("/user").Use(middleware.UseAuth(true))
userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false))
userGroupWithoutAuthNeedsCaptcha := group.Group("/user").Use(middleware.UseCaptcha())
{
userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login)
userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register)
userGroupWithoutAuthNeedsCaptcha.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)
userGroupWithoutAuth.GET("/u/:id", userController.GetUser)
userGroupWithoutAuth.GET("/username/:username", userController.GetUserByUsername)
userGroup.GET("/me", userController.GetUser)
userGroupWithoutAuth.POST("/logout", userController.Logout)
userGroup.PUT("/u/:id", userController.UpdateUser)
userGroup.PUT("/password/edit", userController.ChangePassword)
userGroup.PUT("/email/edit", userController.ChangeEmail)
}
}

View File

@ -1,379 +1,400 @@
package service
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/internal/static"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm"
"net/http"
"strings"
"time"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/internal/static"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm"
)
type UserService struct{}
func NewUserService() *UserService {
return &UserService{}
return &UserService{}
}
func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) {
user, err := repo.User.GetUserByUsernameOrEmail(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Warnf("User not found: %s", req.Username)
return nil, errs.ErrNotFound
}
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) {
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.UserLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
return nil, errs.ErrInternalServer
}
user, err := repo.User.GetUserByUsernameOrEmail(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Warnf("User not found: %s", req.Username)
return nil, errs.ErrNotFound
}
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) {
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.UserLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
return nil, errs.ErrInternalServer
}
}
func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) {
// 验证邮箱验证码
if !utils.Env.GetAsBool("ENABLE_REGISTER", true) {
return nil, errs.ErrForbidden
}
if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) {
ok, err := s.verifyEmail(req.Email, req.VerificationCode)
if err != nil {
logrus.Errorln("Failed to verify email:", err)
return nil, errs.ErrInternalServer
}
if !ok {
return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
}
}
// 检查用户名或邮箱是否已存在
usernameExist, err := repo.User.CheckUsernameExists(req.Username)
if err != nil {
return nil, errs.ErrInternalServer
}
emailExist, err := repo.User.CheckEmailExists(req.Email)
if err != nil {
return nil, errs.ErrInternalServer
}
if usernameExist || emailExist {
return nil, errs.New(http.StatusConflict, "Username or email already exists", nil)
}
// 创建新用户
hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt"))
if err != nil {
logrus.Errorln("Failed to hash password:", err)
return nil, errs.ErrInternalServer
}
newUser := &model.User{
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
Gender: "",
Role: "user",
Password: hashedPassword,
}
err = repo.User.CreateUser(newUser)
if err != nil {
return nil, errs.ErrInternalServer
}
// 创建默认管理员账户
if newUser.ID == 1 {
newUser.Role = constant.RoleAdmin
err = repo.User.UpdateUser(newUser)
if err != nil {
logrus.Errorln("Failed to update user role to admin:", err)
return nil, errs.ErrInternalServer
}
}
// 生成访问令牌和刷新令牌
token, refreshToken, err := s.generate2Token(newUser.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.UserRegisterResp{
Token: token,
RefreshToken: refreshToken,
User: newUser.ToDto(),
}
return resp, nil
// 验证邮箱验证码
if !utils.Env.GetAsBool("ENABLE_REGISTER", true) {
return nil, errs.ErrForbidden
}
if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) {
ok, err := s.verifyEmail(req.Email, req.VerificationCode)
if err != nil {
logrus.Errorln("Failed to verify email:", err)
return nil, errs.ErrInternalServer
}
if !ok {
return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
}
}
// 检查用户名或邮箱是否已存在
usernameExist, err := repo.User.CheckUsernameExists(req.Username)
if err != nil {
return nil, errs.ErrInternalServer
}
emailExist, err := repo.User.CheckEmailExists(req.Email)
if err != nil {
return nil, errs.ErrInternalServer
}
if usernameExist || emailExist {
return nil, errs.New(http.StatusConflict, "Username or email already exists", nil)
}
// 创建新用户
hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt"))
if err != nil {
logrus.Errorln("Failed to hash password:", err)
return nil, errs.ErrInternalServer
}
newUser := &model.User{
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
Gender: "",
Role: "user",
Password: hashedPassword,
}
err = repo.User.CreateUser(newUser)
if err != nil {
return nil, errs.ErrInternalServer
}
// 创建默认管理员账户
if newUser.ID == 1 {
newUser.Role = constant.RoleAdmin
err = repo.User.UpdateUser(newUser)
if err != nil {
logrus.Errorln("Failed to update user role to admin:", err)
return nil, errs.ErrInternalServer
}
}
// 生成访问令牌和刷新令牌
token, refreshToken, err := s.generate2Token(newUser.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.UserRegisterResp{
Token: token,
RefreshToken: refreshToken,
User: newUser.ToDto(),
}
return resp, nil
}
func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) {
generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef")
kv := utils.KV.GetInstance()
kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10)
generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef")
kv := utils.KV.GetInstance()
kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10)
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{})
if err != nil {
return nil, errs.ErrInternalServer
}
if utils.IsDevMode {
logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode)
}
err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true)
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{})
if err != nil {
return nil, errs.ErrInternalServer
}
if utils.IsDevMode {
logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode)
}
err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true)
if err != nil {
return nil, errs.ErrInternalServer
}
return &dto.VerifyEmailResp{Success: true}, nil
if err != nil {
return nil, errs.ErrInternalServer
}
return &dto.VerifyEmailResp{Success: true}, nil
}
func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) {
enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true)
if err != nil {
return nil, errs.ErrInternalServer
}
var oidcConfigsDtos []dto.UserOidcConfigDto
enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true)
if err != nil {
return nil, errs.ErrInternalServer
}
var oidcConfigsDtos []dto.UserOidcConfigDto
for _, oidcConfig := range enabledOidcConfigs {
state := utils.Strings.GenerateRandomString(32)
kvStore := utils.KV.GetInstance()
kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute)
loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "openid email profile",
"state": state,
})
for _, oidcConfig := range enabledOidcConfigs {
state := utils.Strings.GenerateRandomString(32)
kvStore := utils.KV.GetInstance()
kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute)
loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "openid email profile",
"state": state,
})
if oidcConfig.Type == constant.OidcProviderTypeMisskey {
// Misskey OIDC 特殊处理
loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "read:account",
"state": state,
})
}
if oidcConfig.Type == constant.OidcProviderTypeMisskey {
// Misskey OIDC 特殊处理
loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix,
constant.OidcUri,
oidcConfig.Name,
),
"response_type": "code",
"scope": "read:account",
"state": state,
})
}
oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{
Name: oidcConfig.Name,
DisplayName: oidcConfig.DisplayName,
Icon: oidcConfig.Icon,
LoginUrl: loginUrl,
})
}
return oidcConfigsDtos, nil
oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{
Name: oidcConfig.Name,
DisplayName: oidcConfig.DisplayName,
Icon: oidcConfig.Icon,
LoginUrl: loginUrl,
})
}
return oidcConfigsDtos, nil
}
func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) {
// 验证state
kvStore := utils.KV.GetInstance()
storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State)
if !ok || storedName != req.Name {
return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil)
}
// 获取OIDC配置
oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name)
if err != nil {
return nil, errs.ErrInternalServer
}
if oidcConfig == nil {
return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil)
}
// 请求访问令牌
tokenResp, err := utils.Oidc.RequestToken(
oidcConfig.TokenEndpoint,
oidcConfig.ClientID,
oidcConfig.ClientSecret,
req.Code,
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name,
)
if err != nil {
logrus.Errorln("Failed to request OIDC token:", err)
return nil, errs.ErrInternalServer
}
userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken)
if err != nil {
logrus.Errorln("Failed to request OIDC user info:", err)
return nil, errs.ErrInternalServer
}
// 验证state
kvStore := utils.KV.GetInstance()
storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State)
if !ok || storedName != req.Name {
return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil)
}
// 获取OIDC配置
oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name)
if err != nil {
return nil, errs.ErrInternalServer
}
if oidcConfig == nil {
return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil)
}
// 请求访问令牌
tokenResp, err := utils.Oidc.RequestToken(
oidcConfig.TokenEndpoint,
oidcConfig.ClientID,
oidcConfig.ClientSecret,
req.Code,
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name,
)
if err != nil {
logrus.Errorln("Failed to request OIDC token:", err)
return nil, errs.ErrInternalServer
}
userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken)
if err != nil {
logrus.Errorln("Failed to request OIDC user info:", err)
return nil, errs.ErrInternalServer
}
// 绑定过登录
userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrInternalServer
}
if userOpenID != nil {
user, err := repo.User.GetUserByID(userOpenID.UserID)
if err != nil {
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
// 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户
user, err := repo.User.GetUserByEmail(userInfo.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Errorln("Failed to get user by email:", err)
return nil, errs.ErrInternalServer
}
if user != nil {
userOpenID = &model.UserOpenID{
UserID: user.ID,
Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub,
}
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
user = &model.User{
Username: userInfo.Name,
Nickname: userInfo.Name,
AvatarUrl: userInfo.Picture,
Email: userInfo.Email,
}
err = repo.User.CreateUser(user)
if err != nil {
logrus.Errorln("Failed to create user:", err)
return nil, errs.ErrInternalServer
}
userOpenID = &model.UserOpenID{
UserID: user.ID,
Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub,
}
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
}
}
// 绑定过登录
userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrInternalServer
}
if userOpenID != nil {
user, err := repo.User.GetUserByID(userOpenID.UserID)
if err != nil {
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
// 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户
user, err := repo.User.GetUserByEmail(userInfo.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Errorln("Failed to get user by email:", err)
return nil, errs.ErrInternalServer
}
if user != nil {
userOpenID = &model.UserOpenID{
UserID: user.ID,
Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub,
}
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
} else {
user = &model.User{
Username: userInfo.Name,
Nickname: userInfo.Name,
AvatarUrl: userInfo.Picture,
Email: userInfo.Email,
}
err = repo.User.CreateUser(user)
if err != nil {
logrus.Errorln("Failed to create user:", err)
return nil, errs.ErrInternalServer
}
userOpenID = &model.UserOpenID{
UserID: user.ID,
Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub,
}
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer
}
token, refreshToken, err := s.generate2Token(user.ID)
if err != nil {
logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer
}
resp := &dto.OidcLoginResp{
Token: token,
RefreshToken: refreshToken,
User: user.ToDto(),
}
return resp, nil
}
}
}
func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) {
if req.UserID == 0 {
return nil, errs.New(http.StatusBadRequest, "user_id is required", nil)
}
user, err := repo.User.GetUserByID(req.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to get user by ID:", err)
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
return &dto.GetUserResp{
User: user.ToDto(),
}, nil
if req.UserID == 0 {
return nil, errs.New(http.StatusBadRequest, "user_id is required", nil)
}
user, err := repo.User.GetUserByID(req.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to get user by ID:", err)
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
return &dto.GetUserResp{
User: user.ToDto(),
}, nil
}
func (s *UserService) GetUserByUsername(req *dto.GetUserByUsernameReq) (*dto.GetUserResp, error) {
if req.Username == "" {
return nil, errs.New(http.StatusBadRequest, "username is required", nil)
}
user, err := repo.User.GetUserByUsername(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to get user by username:", err)
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
return &dto.GetUserResp{
User: user.ToDto(),
}, nil
}
func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, error) {
user := &model.User{
Model: gorm.Model{
ID: req.ID,
},
Username: req.Username,
Nickname: req.Nickname,
Gender: req.Gender,
AvatarUrl: req.AvatarUrl,
}
err := repo.User.UpdateUser(user)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to update user:", err)
return nil, errs.ErrInternalServer
}
return &dto.UpdateUserResp{}, nil
user := &model.User{
Model: gorm.Model{
ID: req.ID,
},
Username: req.Username,
Nickname: req.Nickname,
Gender: req.Gender,
AvatarUrl: req.AvatarUrl,
}
err := repo.User.UpdateUser(user)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to update user:", err)
return nil, errs.ErrInternalServer
}
return &dto.UpdateUserResp{}, nil
}
func (s *UserService) generate2Token(userID uint) (string, string, error) {
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second)
tokenString, err := token.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second)
refreshTokenString, err := refreshToken.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
err = repo.Session.SaveSession(refreshToken.SessionKey)
if err != nil {
return "", "", errs.ErrInternalServer
}
return tokenString, refreshTokenString, nil
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second)
tokenString, err := token.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second)
refreshTokenString, err := refreshToken.ToString()
if err != nil {
return "", "", errs.ErrInternalServer
}
err = repo.Session.SaveSession(refreshToken.SessionKey)
if err != nil {
return "", "", errs.ErrInternalServer
}
return tokenString, refreshTokenString, nil
}
func (s *UserService) verifyEmail(email, code string) (bool, error) {
kv := utils.KV.GetInstance()
verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email)
if !ok || verificationCode != code {
return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
}
return true, nil
kv := utils.KV.GetInstance()
verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email)
if !ok || verificationCode != code {
return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
}
return true, nil
}

View File

@ -10,15 +10,19 @@ const (
ModeProd = "prod"
RoleUser = "user"
RoleAdmin = "admin"
EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
EnvKeyMode = "MODE" // 环境变量:运行模式
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量JWT密钥
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
EnvKeyTokenDurationDefault = 300
EnvKeyRefreshTokenDurationDefault = 604800
EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL
EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者
EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥
EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url
EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
EnvKeyMode = "MODE" // 环境变量:运行模式
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量JWT密钥
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
EnvKeyTokenDurationDefault = 300 // Token有效时长
EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储邮箱验证码

View File

@ -12,16 +12,16 @@ type captchaUtils struct{}
var Captcha = captchaUtils{}
type CaptchaConfig struct {
Type string
SiteSecret string // Site secret key for the captcha service
SecretKey string // Secret key for the captcha service
Type string
SiteKey string // Site secret key for the captcha service
SecretKey string // Secret key for the captcha service
}
func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig {
return &CaptchaConfig{
Type: Env.Get("CAPTCHA_TYPE", "disable"),
SiteSecret: Env.Get("CAPTCHA_SITE_SECRET", ""),
SecretKey: Env.Get("CAPTCHA_SECRET_KEY", ""),
Type: Env.Get(constant.EnvKeyCaptchaProvider, constant.CaptchaTypeDisable),
SiteKey: Env.Get(constant.EnvKeyCaptchaSiteKey, ""),
SecretKey: Env.Get(constant.EnvKeyCaptchaSecreteKey, ""),
}
}

View File

@ -9,6 +9,8 @@
"lint": "next lint"
},
"dependencies": {
"@hcaptcha/react-hcaptcha": "^1.12.1",
"@marsidev/react-turnstile": "^1.3.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
@ -30,6 +32,7 @@
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-google-recaptcha-v3": "^1.11.0",
"react-icons": "^5.5.0",
"rehype-highlight": "^7.0.2",
"sonner": "^2.0.6",

63
web/pnpm-lock.yaml generated
View File

@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@hcaptcha/react-hcaptcha':
specifier: ^1.12.1
version: 1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@marsidev/react-turnstile':
specifier: ^1.3.0
version: 1.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -71,6 +77,9 @@ importers:
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
react-google-recaptcha-v3:
specifier: ^1.11.0
version: 1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.1.0)
@ -130,6 +139,10 @@ packages:
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@emnapi/core@1.4.4':
resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==}
@ -195,6 +208,15 @@ packages:
'@formatjs/intl-localematcher@0.6.1':
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
'@hcaptcha/loader@2.0.1':
resolution: {integrity: sha512-L36qqdOmv8fL6VBZcH34JUI0/SvC5KPOZ5N/m+5pQAPPhtXXRdU4o9iosZr12hWAM2qf5hC92kmi+XdqxKOEZQ==}
'@hcaptcha/react-hcaptcha@1.12.1':
resolution: {integrity: sha512-/A08MOAHa5L9B8UfNRkTR/+x2dOyfk3pI1/qgXI4NpDl/z4CjnSxaYCDtkbD21vEocN1KKCggQD3wJ7OcY494w==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@ -354,6 +376,12 @@ packages:
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@marsidev/react-turnstile@1.3.0':
resolution: {integrity: sha512-VO99Nynt+j4ETfMImQCj5LgbUKZ9mWPpy3RjP/3e/3vZu+FIphjEdU6g+cq4FeDoNshSxLlRzBTKcH5JMeM1GQ==}
peerDependencies:
react: ^17.0.2 || ^18.0.0 || ^19.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
'@mdx-js/mdx@3.1.0':
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
@ -1689,6 +1717,9 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -2320,6 +2351,12 @@ packages:
peerDependencies:
react: ^19.1.0
react-google-recaptcha-v3@1.11.0:
resolution: {integrity: sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==}
peerDependencies:
react: ^16.3 || ^17.0 || ^18.0 || ^19.0
react-dom: ^17.0 || ^18.0 || ^19.0
react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies:
@ -2788,6 +2825,8 @@ snapshots:
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/runtime@7.28.4': {}
'@emnapi/core@1.4.4':
dependencies:
'@emnapi/wasi-threads': 1.0.3
@ -2878,6 +2917,15 @@ snapshots:
dependencies:
tslib: 2.8.1
'@hcaptcha/loader@2.0.1': {}
'@hcaptcha/react-hcaptcha@1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.28.4
'@hcaptcha/loader': 2.0.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@ -2995,6 +3043,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@marsidev/react-turnstile@1.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@mdx-js/mdx@3.1.0(acorn@8.15.0)':
dependencies:
'@types/estree': 1.0.8
@ -4499,6 +4552,10 @@ snapshots:
highlight.js@11.11.1: {}
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
ignore@5.3.2: {}
ignore@7.0.5: {}
@ -5313,6 +5370,12 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
react-google-recaptcha-v3@1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
hoist-non-react-statics: 3.3.2
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-icons@5.5.0(react@19.1.0):
dependencies:
react: 19.1.0

View File

@ -1,14 +1,26 @@
import type { OidcConfig } from '@/models/oidc-config'
import type { BaseResponse } from '@/models/resp'
import type { LoginRequest, RegisterRequest, User } from '@/models/user'
import type { RegisterRequest, User } from '@/models/user'
import axiosClient from './client'
import { CaptchaProvider } from '@/models/captcha'
export async function userLogin(
data: LoginRequest,
): Promise<BaseResponse<{ token: string, user: User }>> {
{
username,
password,
rememberMe,
captcha
}: {
username: string,
password: string,
rememberMe?: boolean,
captcha?: string,
}): Promise<BaseResponse<{ token: string, user: User }>> {
console.log("Logging in with captcha:", captcha)
const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>(
'/user/login',
data,
{ username, password, rememberMe },
{ headers: { 'X-Captcha-Token': captcha || '' } },
)
return res.data
}
@ -43,3 +55,21 @@ export async function getUserById(id: number): Promise<BaseResponse<User>> {
const res = await axiosClient.get<BaseResponse<User>>(`/user/u/${id}`)
return res.data
}
export async function getUserByUsername(username: string): Promise<BaseResponse<User>> {
const res = await axiosClient.get<BaseResponse<User>>(`/user/username/${username}`)
return res.data
}
export async function getCaptchaConfig(): Promise<BaseResponse<{
provider: CaptchaProvider
siteKey: string
url?: string
}>> {
const res = await axiosClient.get<BaseResponse<{
provider: CaptchaProvider
siteKey: string
url?: string
}>>('/user/captcha')
return res.data
}

View File

@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation'
import { Navbar } from '@/components/layout/navbar'
import { BackgroundProvider } from '@/contexts/background-context'
import Footer from '@/components/layout/footer'
import config from '@/config'
export default function RootLayout({
children,
@ -14,25 +15,18 @@ export default function RootLayout({
const pathname = usePathname()
return (
<>
<header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
<motion.nav
initial={{ y: -64, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
<header className="fixed top-0 left-0 h-16 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
<Navbar />
</header>
<motion.main
key={pathname}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
type: 'tween',
ease: 'easeOut',
duration: 0.30,
}}
className="pt-16"
>
<BackgroundProvider>
<div className='container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
</BackgroundProvider>
</motion.main>
</motion.nav>
<BackgroundProvider>
<div className='container mx-auto pt-16 px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
</BackgroundProvider>
<Footer />
</>
)

View File

@ -1,8 +0,0 @@
export default function Page() {
return (
<div>
<h1>Page Title</h1>
<p>This is the User content.</p>
</div>
)
}

View File

@ -0,0 +1,27 @@
"use client"
import { getUserByUsername } from "@/api/user";
import { UserPage } from "@/components/user";
import { User } from "@/models/user";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
export default function Page() {
const { username } = useParams() as { username: string };
const [user,setUser] = useState<User | null>(null);
useEffect(() => {
getUserByUsername(username).then(res => {
setUser(res.data);
});
},[username]);
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<UserPage user={user} />
</div>
)
}

View File

@ -13,150 +13,163 @@ import { useEffect, useState } from "react";
import { useStoredState } from '@/hooks/use-storage-state';
import { listLabels } from "@/api/label";
import { POST_SORT_TYPE } from "@/localstore";
import { motion } from "framer-motion";
// 定义排序类型
type SortType = 'latest' | 'popular';
export default function BlogHome() {
const [labels, setLabels] = useState<Label[]>([]);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
useEffect(() => {
if (!sortTypeLoaded) return;
const fetchPosts = async () => {
try {
setLoading(true);
let orderBy: string;
let desc: boolean;
switch (sortType) {
case 'latest':
orderBy = 'updated_at';
desc = true;
break;
case 'popular':
orderBy = 'heat';
desc = true;
break;
default:
orderBy = 'updated_at';
desc = true;
}
// 处理关键词,空格分割转逗号
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
const data = await listPosts({
page: 1,
size: 10,
orderBy: orderBy,
desc: desc,
keywords
});
setPosts(data.data);
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
setLoading(false);
}
};
fetchPosts();
}, [sortType, sortTypeLoaded]);
// 获取标签
useEffect(() => {
listLabels().then(data => {
setLabels(data.data || []);
}).catch(error => {
console.error("Failed to fetch labels:", error);
});
}, []);
// 处理排序切换
const handleSortChange = (type: SortType) => {
if (sortType !== type) {
setSortType(type);
const [labels, setLabels] = useState<Label[]>([]);
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
useEffect(() => {
if (!sortTypeLoaded) return;
const fetchPosts = async () => {
try {
setLoading(true);
let orderBy: string;
let desc: boolean;
switch (sortType) {
case 'latest':
orderBy = 'updated_at';
desc = true;
break;
case 'popular':
orderBy = 'heat';
desc = true;
break;
default:
orderBy = 'updated_at';
desc = true;
}
// 处理关键词,空格分割转逗号
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
const data = await listPosts({
page: 1,
size: 10,
orderBy: orderBy,
desc: desc,
keywords
});
setPosts(data.data);
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
setLoading(false);
}
};
fetchPosts();
}, [sortType, sortTypeLoaded]);
return (
<>
{/* 主内容区域 */}
<section className="py-16">
{/* 容器 - 关键布局 */}
<div className="">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容区域 */}
<div className="lg:col-span-3 self-start">
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
{sortType === 'latest' ? '最新文章' : '热门文章'}
{posts.length > 0 && (
<span className="text-sm font-normal text-slate-500 ml-2">
({posts.length} )
</span>
)}
</h2>
// 获取标签
useEffect(() => {
listLabels().then(data => {
setLabels(data.data || []);
}).catch(error => {
console.error("Failed to fetch labels:", error);
});
}, []);
{/* 排序按钮组 */}
<div className="flex items-center gap-2">
<Button
variant={sortType === 'latest' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('latest')}
disabled={loading}
className="transition-all duration-200"
>
<Clock className="w-4 h-4 mr-2" />
</Button>
<Button
variant={sortType === 'popular' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('popular')}
disabled={loading}
className="transition-all duration-200"
>
<TrendingUp className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
// 处理排序切换
const handleSortChange = (type: SortType) => {
if (sortType !== type) {
setSortType(type);
}
};
{/* 博客卡片网格 */}
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
return (
<>
{/* 主内容区域 */}
<section className="py-16">
{/* 容器 - 关键布局 */}
<div className="">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容区域 */}
<motion.div
className="lg:col-span-3 self-start"
initial={{ y: 150, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
{sortType === 'latest' ? '最新文章' : '热门文章'}
{posts.length > 0 && (
<span className="text-sm font-normal text-slate-500 ml-2">
({posts.length} )
</span>
)}
</h2>
{/* 加载更多按钮 */}
{!loading && posts.length > 0 && (
<div className="text-center mt-12">
<Button size="lg" className="px-8">
</Button>
</div>
)}
{/* 加载状态指示器 */}
{loading && (
<div className="text-center py-8">
<div className="inline-flex items-center gap-2 text-slate-600">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
{sortType === 'latest' ? '最新' : '热门'}...
</div>
</div>
)}
</div>
{/* 侧边栏 */}
<Sidebar
cards={[
<SidebarAbout key="about" config={config} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
<SidebarTags key="tags" labels={labels} />,
<SidebarMisskeyIframe key="misskey" />,
].filter(Boolean)}
/>
</div>
{/* 排序按钮 */}
<div className="flex items-center gap-2">
<Button
variant={sortType === 'latest' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('latest')}
disabled={loading}
className="transition-all duration-200"
>
<Clock className="w-4 h-4 mr-2" />
</Button>
<Button
variant={sortType === 'popular' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('popular')}
disabled={loading}
className="transition-all duration-200"
>
<TrendingUp className="w-4 h-4 mr-2" />
</Button>
</div>
</section>
</>
);
</div>
{/* 博客卡片网格 */}
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
{/* 加载更多按钮 */}
{!loading && posts.length > 0 && (
<div className="text-center mt-12">
<Button size="lg" className="px-8">
</Button>
</div>
)}
{/* 加载状态指示器 */}
{loading && (
<div className="text-center py-8">
<div className="inline-flex items-center gap-2 text-slate-600">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
{sortType === 'latest' ? '最新' : '热门'}...
</div>
</div>
)}
</motion.div>
{/* 侧边栏 */}
<motion.div
initial={{ x: 200, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
>
<Sidebar
cards={[
<SidebarAbout key="about" config={config} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
<SidebarTags key="tags" labels={labels} />,
<SidebarMisskeyIframe key="misskey" />,
].filter(Boolean)}
/>
</motion.div>
</div>
</div>
</section>
</>
);
}

View File

@ -3,7 +3,7 @@ import { User } from "@/models/user";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { getGravatarByUser } from "@/components/common/gravatar";
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
import { CircleUser } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox"
@ -60,7 +60,7 @@ export function CommentInput(
<div className="fade-in-up">
<div className="flex py-4 fade-in">
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
{user ? getGravatarByUser(user) : null}
{user && <GravatarAvatar url={user.avatarUrl} email={user.email} size={100}/>}
{!user && <CircleUser className="w-full h-full fade-in" />}
</div>
<div className="flex-1 pl-2 fade-in-up">

View File

@ -3,7 +3,7 @@ import { User } from "@/models/user";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { getGravatarByUser } from "@/components/common/gravatar";
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
import { Comment } from "@/models/comment";
import { TargetType } from "@/models/types";
@ -158,8 +158,8 @@ export function CommentItem(
return (
<div>
<div className="flex">
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in">
{getGravatarByUser(comment.user)}
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
<GravatarAvatar email={comment.user.email} size={120}/>
</div>
<div className="flex-1 pl-2 fade-in-up">
<div className="flex gap-2 md:gap-4 items-center">

View File

@ -0,0 +1 @@
/* 选择 Turnstile 组件内的 iframe */

View File

@ -0,0 +1,61 @@
"use client"
import { useEffect } from "react";
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Turnstile } from "@marsidev/react-turnstile";
import { CaptchaProvider } from "@/models/captcha";
import "./captcha.css";
import { TurnstileWidget } from "./turnstile";
export type CaptchaProps = {
provider: CaptchaProvider;
siteKey: string;
url?: string;
onSuccess: (token: string) => void;
onError: (error: string) => void;
};
export function ReCaptchaWidget(props: CaptchaProps) {
return (
<GoogleReCaptchaProvider reCaptchaKey={props.siteKey} useEnterprise={false}>
<GoogleReCaptcha action="submit" onVerify={props.onSuccess} />
</GoogleReCaptchaProvider>
);
}
export function NoCaptchaWidget(props: CaptchaProps) {
useEffect(() => {
props.onSuccess("no-captcha");
}, [props, props.onSuccess]);
return null;
}
export function HCaptchaWidget(props: CaptchaProps) {
return (
<HCaptcha
sitekey={props.siteKey}
onVerify={props.onSuccess}
onError={props.onError}
onExpire={() => props.onError?.("Captcha expired")}
/>
);
}
export default function AIOCaptchaWidget(props: CaptchaProps) {
switch (props.provider) {
case CaptchaProvider.HCAPTCHA:
return <HCaptchaWidget {...props} />;
case CaptchaProvider.RECAPTCHA:
return <ReCaptchaWidget {...props} />;
case CaptchaProvider.TURNSTILE:
return <TurnstileWidget {...props} />;
case CaptchaProvider.DISABLE:
return <NoCaptchaWidget {...props} />;
default:
throw new Error(`Unsupported captcha provider: ${props.provider}`);
}
}

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import { CaptchaProps } from ".";
import { Turnstile } from "@marsidev/react-turnstile";
import { useTranslations } from "next-intl";
// 简单的转圈圈动画
function Spinner() {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40 }}>
<svg className="animate-spin" width="32" height="32" viewBox="0 0 50 50">
<circle className="opacity-25" cx="25" cy="25" r="20" fill="none" stroke="#e5e7eb" strokeWidth="5" />
<circle className="opacity-75" cx="25" cy="25" r="20" fill="none" stroke="#6366f1" strokeWidth="5" strokeDasharray="90 150" strokeDashoffset="0" />
</svg>
</div>
);
}
// 勾勾动画
function CheckMark() {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40 }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 10 18 4 12" />
</svg>
</div>
);
}
export function OfficialTurnstileWidget(props: CaptchaProps) {
return <div>
<Turnstile className="w-full" options={{ size: "invisible" }} siteKey={props.siteKey} onSuccess={props.onSuccess} />
</div>;
}
// 自定义包装组件
export function TurnstileWidget(props: CaptchaProps) {
const t = useTranslations("Captcha");
const [status, setStatus] = useState<'loading' | 'success'>('loading');
// 只在验证通过时才显示勾
const handleSuccess = (token: string) => {
setStatus('success');
props.onSuccess(token);
};
return (
<div className="flex items-center justify-evenly w-full border border-gray-300 rounded-md px-4 py-2 relative">
{status === 'loading' && <Spinner />}
{status === 'success' && <CheckMark />}
<div className="flex-1 text-center">{status === 'success' ? t("success") :t("doing")}</div>
<div className="absolute inset-0 opacity-0 pointer-events-none">
<OfficialTurnstileWidget {...props} onSuccess={handleSuccess} />
</div>
</div>
);
}

View File

@ -28,27 +28,25 @@ const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
defaultType = "identicon"
}) => {
// 如果有自定义URL使用自定义URL
if (url) {
if (url && url.trim() !== "") {
return (
<Image
src={url}
width={size}
height={size}
className={`rounded-full object-cover ${className}`}
className={`rounded-full object-cover w-full h-full ${className}`}
alt={alt}
referrerPolicy="no-referrer"
/>
);
}
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
return (
<Image
src={gravatarUrl}
width={size}
height={size}
className={`rounded-full object-cover ${className}`}
className={`rounded-full object-cover w-full h-full ${className}`}
alt={alt}
referrerPolicy="no-referrer"
/>
@ -63,14 +61,14 @@ interface User {
avatarUrl?: string;
}
export function getGravatarByUser(user?: User, className: string = ""): React.ReactElement {
export function getGravatarByUser({user, className="", size=640}:{user?: User, className?: string, size?: number}): React.ReactElement {
if (!user) {
return <GravatarAvatar email="" className={className} />;
}
return (
<GravatarAvatar
email={user.email || ""}
size={40}
size={size}
className={className}
alt={user.displayName || user.name || "User Avatar"}
url={user.avatarUrl}

View File

@ -4,13 +4,13 @@ import * as React from "react"
import Link from "next/link"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
import GravatarAvatar from "@/components/common/gravatar"
import { useDevice } from "@/contexts/device-context"
@ -21,157 +21,156 @@ import { Menu } from "lucide-react"
import { Switch } from "../ui/switch"
const navbarMenuComponents = [
{
title: "首页",
href: "/"
},
{
title: "文章",
children: [
{ title: "归档", href: "/archives" },
{ title: "标签", href: "/labels" },
{ title: "随机", href: "/random" }
]
},
{
title: "页面",
children: [
{ title: "关于我", href: "/about" },
{ title: "联系我", href: "/contact" },
{ title: "友链", href: "/links" },
{ title: "隐私政策", href: "/privacy-policy" },
]
}
{
title: "首页",
href: "/"
},
{
title: "文章",
children: [
{ title: "归档", href: "/archives" },
{ title: "标签", href: "/labels" },
{ title: "随机", href: "/random" }
]
},
{
title: "页面",
children: [
{ title: "关于我", href: "/about" },
{ title: "联系我", href: "/contact" },
{ title: "友链", href: "/links" },
{ title: "隐私政策", href: "/privacy-policy" },
]
}
]
export function Navbar() {
const { navbarAdditionalClassName, setMode, mode } = useDevice()
return (
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full ${navbarAdditionalClassName}`}>
<div className="flex items-center justify-start">
<span className="font-bold truncate">{config.metadata.name}</span>
</div>
<div className="flex items-center justify-center">
<NavMenuCenter />
</div>
<div className="flex items-center justify-end space-x-2">
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
<GravatarAvatar email="snowykami@outlook.com" />
<SidebarMenuClientOnly />
</div>
</nav>
)
const { navbarAdditionalClassName, setMode, mode } = useDevice()
return (
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}>
<div className="flex items-center justify-start">
<span className="font-bold truncate">{config.metadata.name}</span>
</div>
<div className="flex items-center justify-center">
<NavMenuCenter />
</div>
<div className="flex items-center justify-end space-x-2">
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
<SidebarMenuClientOnly />
</div>
</nav>
)
}
function NavMenuCenter() {
return (
<NavigationMenu viewport={false} className="hidden md:block">
<NavigationMenuList className="flex space-x-1">
{navbarMenuComponents.map((item) => (
<NavigationMenuItem key={item.title}>
{item.href ? (
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href={item.href} className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span>
</Link>
</NavigationMenuLink>
) : item.children ? (
<>
<NavigationMenuTrigger className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span>
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]">
{item.children.map((child) => (
<ListItem
key={child.title}
title={child.title}
href={child.href}
/>
))}
</ul>
</NavigationMenuContent>
</>
) : null}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
)
return (
<NavigationMenu viewport={false} className="hidden md:block">
<NavigationMenuList className="flex space-x-1">
{navbarMenuComponents.map((item) => (
<NavigationMenuItem key={item.title}>
{item.href ? (
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href={item.href} className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span>
</Link>
</NavigationMenuLink>
) : item.children ? (
<>
<NavigationMenuTrigger className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span>
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]">
{item.children.map((child) => (
<ListItem
key={child.title}
title={child.title}
href={child.href}
/>
))}
</ul>
</NavigationMenuContent>
</>
) : null}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
)
}
function ListItem({
title,
children,
href,
...props
title,
children,
href,
...props
}: React.ComponentPropsWithoutRef<"li"> & { href: string }) {
return (
<li {...props} className="flex justify-center">
<NavigationMenuLink asChild>
<Link href={href} className="flex flex-col items-center text-center w-full">
<div className="text-sm leading-none font-medium">{title}</div>
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
{children}
</p>
</Link>
</NavigationMenuLink>
</li>
)
return (
<li {...props} className="flex justify-center">
<NavigationMenuLink asChild>
<Link href={href} className="flex flex-col items-center text-center w-full">
<div className="text-sm leading-none font-medium">{title}</div>
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
{children}
</p>
</Link>
</NavigationMenuLink>
</li>
)
}
function SidebarMenuClientOnly() {
return <SidebarMenu />;
return <SidebarMenu />;
}
function SidebarMenu() {
const [open, setOpen] = useState(false)
return (
<div className="md:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
aria-label="打开菜单"
className="p-2 rounded-md hover:bg-accent transition-colors"
>
<Menu className="w-6 h-6" />
</button>
</SheetTrigger>
<SheetContent side="right" className="p-0 w-64">
{/* 可访问性要求的标题,视觉上隐藏 */}
<SheetTitle className="sr-only"></SheetTitle>
<nav className="flex flex-col gap-2 p-4">
{navbarMenuComponents.map((item) =>
item.href ? (
<Link
key={item.title}
href={item.href}
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
onClick={() => setOpen(false)}
>
{item.title}
</Link>
) : item.children ? (
<div key={item.title} className="mb-2">
<div className="font-bold px-3 py-2">{item.title}</div>
<div className="flex flex-col pl-4">
{item.children.map((child) => (
<Link
key={child.title}
href={child.href}
className="py-2 px-3 rounded hover:bg-accent transition-colors"
onClick={() => setOpen(false)}
>
{child.title}
</Link>
))}
</div>
</div>
) : null
)}
</nav>
</SheetContent>
</Sheet></div>
const [open, setOpen] = useState(false)
return (
<div className="md:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
aria-label="打开菜单"
className="p-2 rounded-md hover:bg-accent transition-colors"
>
<Menu className="w-6 h-6" />
</button>
</SheetTrigger>
<SheetContent side="right" className="p-0 w-64">
{/* 可访问性要求的标题,视觉上隐藏 */}
<SheetTitle className="sr-only"></SheetTitle>
<nav className="flex flex-col gap-2 p-4">
{navbarMenuComponents.map((item) =>
item.href ? (
<Link
key={item.title}
href={item.href}
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
onClick={() => setOpen(false)}
>
{item.title}
</Link>
) : item.children ? (
<div key={item.title} className="mb-2">
<div className="font-bold px-3 py-2">{item.title}</div>
<div className="flex flex-col pl-4">
{item.children.map((child) => (
<Link
key={child.title}
href={child.href}
className="py-2 px-3 rounded hover:bg-accent transition-colors"
onClick={() => setOpen(false)}
>
{child.title}
</Link>
))}
</div>
</div>
) : null
)}
</nav>
</SheetContent>
</Sheet></div>
)
)
}

View File

@ -14,10 +14,12 @@ import { Label } from "@/components/ui/label"
import Image from "next/image"
import { useEffect, useState } from "react"
import type { OidcConfig } from "@/models/oidc-config"
import { 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"
import Captcha from "../common/captcha"
import { CaptchaProvider } from "@/models/captcha"
export function LoginForm({
className,
@ -25,12 +27,18 @@ export function LoginForm({
}: React.ComponentProps<"div">) {
const t = useTranslations('Login')
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
const [captchaProps, setCaptchaProps] = useState<{
provider: CaptchaProvider
siteKey: string
url?: string
} | null>(null)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaError, setCaptchaError] = useState<string | null>(null)
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
const router = useRouter()
const searchParams = useSearchParams()
const redirectBack = searchParams.get("redirect_back") || "/"
useEffect(() => {
ListOidcConfigs()
.then((res) => {
@ -42,10 +50,20 @@ export function LoginForm({
})
}, [])
useEffect(() => {
getCaptchaConfig()
.then((res) => {
setCaptchaProps(res.data)
})
.catch((error) => {
console.error("Error fetching captcha config:", error)
})
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
try {
const res = await userLogin({username, password})
const res = await userLogin({ username, password, captcha: captchaToken || "" })
console.log("Login successful:", res)
router.push(redirectBack)
} catch (error) {
@ -128,7 +146,19 @@ export function LoginForm({
onChange={e => setCredentials(c => ({ ...c, password: e.target.value }))}
/>
</div>
<Button type="submit" className="w-full" onClick={handleLogin}>
{captchaProps &&
<div className="flex justify-center items-center w-full">
<Captcha {...captchaProps} onSuccess={setCaptchaToken} onError={setCaptchaError} />
</div>
}
{captchaError && <div>
{t("captcha_error")}</div>}
<Button
type="submit"
className="w-full"
onClick={handleLogin}
disabled={!captchaToken}
>
{t("login")}
</Button>
</div>

View File

@ -0,0 +1,5 @@
import { User } from "@/models/user";
export function UserPage({user}: {user: User}) {
return <div>User: {user.username}</div>;
}

View File

@ -0,0 +1,13 @@
"use client"
import { getUserByUsername } from "@/api/user";
import { User } from "@/models/user";
import { useEffect, useState } from "react";
import { getGravatarByUser } from "../common/gravatar";
export function UserProfile({ user }: { user: User }) {
return (
<div className="flex">
{getGravatarByUser({user,className: "rounded-full mr-4"})}
</div>
);
}

View File

@ -16,6 +16,7 @@ const config = {
bodyWidthMobile: "100vw",
postsPerPage: 12,
commentsPerPage: 8,
animationDurationSecond: 0.5,
footer: {
text: "Liteyuki ICP备 1145141919810",
links: []

View File

@ -2,6 +2,10 @@
"HomePage": {
"title": "Hello world!"
},
"Captcha": {
"doing": "正在检测你是不是机器人...",
"success": "恭喜,你是人类!"
},
"Comment": {
"collapse_replies": "收起",
"comment": "评论",
@ -41,6 +45,7 @@
"secondsAgo": "秒前"
},
"Login": {
"captcha_error": "验证错误,请重试。",
"welcome": "欢迎回来",
"with_oidc": "使用第三方身份提供者",
"or_continue_with_local_account": "或使用用户名和密码",

View File

@ -0,0 +1,7 @@
export enum CaptchaProvider {
HCAPTCHA = "hcaptcha",
MCAPTCHA = "mcaptcha",
RECAPTCHA = "recaptcha",
TURNSTILE = "turnstile",
DISABLE = "disable",
}

View File

@ -10,13 +10,6 @@ export interface User {
}
export interface LoginRequest {
username: string
password: string
rememberMe?: boolean
captcha?: string
}
export interface RegisterRequest {
username: string
password: string