diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go
index 0fea2ace..346bc090 100644
--- a/backend/internal/bootstrap/router_bootstrap.go
+++ b/backend/internal/bootstrap/router_bootstrap.go
@@ -76,7 +76,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
- controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
+ controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
@@ -84,6 +84,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
+ controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
// Add test controller in non-production environments
if !common.EnvConfig.AppEnv.IsProduction() {
diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go
index 86a33a93..4cb6c86d 100644
--- a/backend/internal/bootstrap/services_bootstrap.go
+++ b/backend/internal/bootstrap/services_bootstrap.go
@@ -13,23 +13,25 @@ import (
)
type services struct {
- appConfigService *service.AppConfigService
- appImagesService *service.AppImagesService
- emailService *service.EmailService
- geoLiteService *service.GeoLiteService
- auditLogService *service.AuditLogService
- jwtService *service.JwtService
- webauthnService *service.WebAuthnService
- scimService *service.ScimService
- userService *service.UserService
- customClaimService *service.CustomClaimService
- oidcService *service.OidcService
- userGroupService *service.UserGroupService
- ldapService *service.LdapService
- apiKeyService *service.ApiKeyService
- versionService *service.VersionService
- fileStorage storage.FileStorage
- appLockService *service.AppLockService
+ appConfigService *service.AppConfigService
+ appImagesService *service.AppImagesService
+ emailService *service.EmailService
+ geoLiteService *service.GeoLiteService
+ auditLogService *service.AuditLogService
+ jwtService *service.JwtService
+ webauthnService *service.WebAuthnService
+ scimService *service.ScimService
+ userService *service.UserService
+ customClaimService *service.CustomClaimService
+ oidcService *service.OidcService
+ userGroupService *service.UserGroupService
+ ldapService *service.LdapService
+ apiKeyService *service.ApiKeyService
+ versionService *service.VersionService
+ fileStorage storage.FileStorage
+ appLockService *service.AppLockService
+ userSignUpService *service.UserSignUpService
+ oneTimeAccessService *service.OneTimeAccessService
}
// Initializes all services
@@ -74,6 +76,8 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
+ svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService)
+ svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.versionService = service.NewVersionService(httpClient)
diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go
index c2bd43d6..13fb9a13 100644
--- a/backend/internal/common/errors.go
+++ b/backend/internal/common/errors.go
@@ -412,3 +412,13 @@ func (e *ImageNotFoundError) Error() string {
func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound
}
+
+type InvalidEmailVerificationTokenError struct{}
+
+func (e *InvalidEmailVerificationTokenError) Error() string {
+ return "Invalid email verification token"
+}
+
+func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
+ return http.StatusBadRequest
+}
diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go
index d348e893..78a5563c 100644
--- a/backend/internal/controller/user_controller.go
+++ b/backend/internal/controller/user_controller.go
@@ -14,19 +14,17 @@ import (
"golang.org/x/time/rate"
)
-const (
- defaultOneTimeAccessTokenDuration = 15 * time.Minute
- defaultSignupTokenDuration = time.Hour
-)
+const defaultOneTimeAccessTokenDuration = 15 * time.Minute
// NewUserController creates a new controller for user management endpoints
// @Summary User management controller
// @Description Initializes all user-related API endpoints
// @Tags Users
-func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
+func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) {
uc := UserController{
- userService: userService,
- appConfigService: appConfigService,
+ userService: userService,
+ oneTimeAccessService: oneTimeAccessService,
+ appConfigService: appConfigService,
}
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
@@ -54,17 +52,14 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
- group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
- group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
- group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
- group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
- group.POST("/signup/setup", uc.signUpInitialAdmin)
-
+ group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
+ group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
}
type UserController struct {
- userService *service.UserService
- appConfigService *service.AppConfigService
+ userService *service.UserService
+ oneTimeAccessService *service.OneTimeAccessService
+ appConfigService *service.AppConfigService
}
// getUserGroupsHandler godoc
@@ -342,7 +337,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
ttl = defaultOneTimeAccessTokenDuration
}
}
- token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
+ token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -391,7 +386,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
return
}
- deviceToken, err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
+ deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
@@ -424,7 +419,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
- err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
+ err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -442,41 +437,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
- user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- var userDto dto.UserDto
- if err := dto.MapStruct(user, &userDto); err != nil {
- _ = c.Error(err)
- return
- }
-
- maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
- cookie.AddAccessTokenCookie(c, maxAge, token)
-
- c.JSON(http.StatusOK, userDto)
-}
-
-// signUpInitialAdmin godoc
-// @Summary Sign up initial admin user
-// @Description Sign up and generate setup access token for initial admin user
-// @Tags Users
-// @Accept json
-// @Produce json
-// @Param body body dto.SignUpDto true "User information"
-// @Success 200 {object} dto.UserDto
-// @Router /api/signup/setup [post]
-func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
- var input dto.SignUpDto
- if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
- _ = c.Error(err)
- return
- }
-
- user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
+ user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
@@ -524,130 +485,6 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
-// createSignupTokenHandler godoc
-// @Summary Create signup token
-// @Description Create a new signup token that allows user registration
-// @Tags Users
-// @Accept json
-// @Produce json
-// @Param token body dto.SignupTokenCreateDto true "Signup token information"
-// @Success 201 {object} dto.SignupTokenDto
-// @Router /api/signup-tokens [post]
-func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
- var input dto.SignupTokenCreateDto
- if err := c.ShouldBindJSON(&input); err != nil {
- _ = c.Error(err)
- return
- }
-
- ttl := input.TTL.Duration
- if ttl <= 0 {
- ttl = defaultSignupTokenDuration
- }
-
- signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- var tokenDto dto.SignupTokenDto
- err = dto.MapStruct(signupToken, &tokenDto)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusCreated, tokenDto)
-}
-
-// listSignupTokensHandler godoc
-// @Summary List signup tokens
-// @Description Get a paginated list of signup tokens
-// @Tags Users
-// @Param pagination[page] query int false "Page number for pagination" default(1)
-// @Param pagination[limit] query int false "Number of items per page" default(20)
-// @Param sort[column] query string false "Column to sort by"
-// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
-// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
-// @Router /api/signup-tokens [get]
-func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
- listRequestOptions := utils.ParseListRequestOptions(c)
-
- tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- var tokensDto []dto.SignupTokenDto
- if err := dto.MapStructList(tokens, &tokensDto); err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
- Data: tokensDto,
- Pagination: pagination,
- })
-}
-
-// deleteSignupTokenHandler godoc
-// @Summary Delete signup token
-// @Description Delete a signup token by ID
-// @Tags Users
-// @Param id path string true "Token ID"
-// @Success 204 "No Content"
-// @Router /api/signup-tokens/{id} [delete]
-func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
- tokenID := c.Param("id")
-
- err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.Status(http.StatusNoContent)
-}
-
-// signupWithTokenHandler godoc
-// @Summary Sign up
-// @Description Create a new user account
-// @Tags Users
-// @Accept json
-// @Produce json
-// @Param user body dto.SignUpDto true "User information"
-// @Success 201 {object} dto.SignUpDto
-// @Router /api/signup [post]
-func (uc *UserController) signupHandler(c *gin.Context) {
- var input dto.SignUpDto
- if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
- _ = c.Error(err)
- return
- }
-
- ipAddress := c.ClientIP()
- userAgent := c.GetHeader("User-Agent")
-
- user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
- cookie.AddAccessTokenCookie(c, maxAge, accessToken)
-
- var userDto dto.UserDto
- if err := dto.MapStruct(user, &userDto); err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusCreated, userDto)
-}
-
// updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
@@ -714,3 +551,44 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
c.Status(http.StatusNoContent)
}
+
+// sendEmailVerificationHandler godoc
+// @Summary Send email verification
+// @Description Send an email verification to the currently authenticated user
+// @Tags Users
+// @Produce json
+// @Success 204 "No Content"
+// @Router /api/users/me/send-email-verification [post]
+func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) {
+ userID := c.GetString("userID")
+
+ if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+
+// verifyEmailHandler godoc
+// @Summary Verify email
+// @Description Verify the currently authenticated user's email using a verification token
+// @Tags Users
+// @Param body body dto.EmailVerificationDto true "Email verification token"
+// @Success 204 "No Content"
+// @Router /api/users/me/verify-email [post]
+func (uc *UserController) verifyEmailHandler(c *gin.Context) {
+ var input dto.EmailVerificationDto
+ if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ userID := c.GetString("userID")
+ if err := uc.userService.VerifyEmail(c.Request.Context(), userID, input.Token); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
diff --git a/backend/internal/controller/user_signup_controller.go b/backend/internal/controller/user_signup_controller.go
new file mode 100644
index 00000000..4b53c44f
--- /dev/null
+++ b/backend/internal/controller/user_signup_controller.go
@@ -0,0 +1,198 @@
+package controller
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
+
+ "github.com/gin-gonic/gin"
+ "github.com/pocket-id/pocket-id/backend/internal/dto"
+ "github.com/pocket-id/pocket-id/backend/internal/middleware"
+ "github.com/pocket-id/pocket-id/backend/internal/service"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+ "golang.org/x/time/rate"
+)
+
+const defaultSignupTokenDuration = time.Hour
+
+// NewUserSignupController creates a new controller for user signup and signup token management
+// @Summary User signup and signup token management controller
+// @Description Initializes all user signup-related API endpoints
+// @Tags Users
+func NewUserSignupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userSignUpService *service.UserSignUpService, appConfigService *service.AppConfigService) {
+ usc := UserSignupController{
+ userSignUpService: userSignUpService,
+ appConfigService: appConfigService,
+ }
+
+ group.POST("/signup-tokens", authMiddleware.Add(), usc.createSignupTokenHandler)
+ group.GET("/signup-tokens", authMiddleware.Add(), usc.listSignupTokensHandler)
+ group.DELETE("/signup-tokens/:id", authMiddleware.Add(), usc.deleteSignupTokenHandler)
+ group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), usc.signupHandler)
+ group.POST("/signup/setup", usc.signUpInitialAdmin)
+
+}
+
+type UserSignupController struct {
+ userSignUpService *service.UserSignUpService
+ appConfigService *service.AppConfigService
+}
+
+// signUpInitialAdmin godoc
+// @Summary Sign up initial admin user
+// @Description Sign up and generate setup access token for initial admin user
+// @Tags Users
+// @Accept json
+// @Produce json
+// @Param body body dto.SignUpDto true "User information"
+// @Success 200 {object} dto.UserDto
+// @Router /api/signup/setup [post]
+func (usc *UserSignupController) signUpInitialAdmin(c *gin.Context) {
+ var input dto.SignUpDto
+ if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ user, token, err := usc.userSignUpService.SignUpInitialAdmin(c.Request.Context(), input)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ var userDto dto.UserDto
+ if err := dto.MapStruct(user, &userDto); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
+ cookie.AddAccessTokenCookie(c, maxAge, token)
+
+ c.JSON(http.StatusOK, userDto)
+}
+
+// createSignupTokenHandler godoc
+// @Summary Create signup token
+// @Description Create a new signup token that allows user registration
+// @Tags Users
+// @Accept json
+// @Produce json
+// @Param token body dto.SignupTokenCreateDto true "Signup token information"
+// @Success 201 {object} dto.SignupTokenDto
+// @Router /api/signup-tokens [post]
+func (usc *UserSignupController) createSignupTokenHandler(c *gin.Context) {
+ var input dto.SignupTokenCreateDto
+ if err := c.ShouldBindJSON(&input); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ ttl := input.TTL.Duration
+ if ttl <= 0 {
+ ttl = defaultSignupTokenDuration
+ }
+
+ signupToken, err := usc.userSignUpService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ var tokenDto dto.SignupTokenDto
+ err = dto.MapStruct(signupToken, &tokenDto)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, tokenDto)
+}
+
+// listSignupTokensHandler godoc
+// @Summary List signup tokens
+// @Description Get a paginated list of signup tokens
+// @Tags Users
+// @Param pagination[page] query int false "Page number for pagination" default(1)
+// @Param pagination[limit] query int false "Number of items per page" default(20)
+// @Param sort[column] query string false "Column to sort by"
+// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
+// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
+// @Router /api/signup-tokens [get]
+func (usc *UserSignupController) listSignupTokensHandler(c *gin.Context) {
+ listRequestOptions := utils.ParseListRequestOptions(c)
+
+ tokens, pagination, err := usc.userSignUpService.ListSignupTokens(c.Request.Context(), listRequestOptions)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ var tokensDto []dto.SignupTokenDto
+ if err := dto.MapStructList(tokens, &tokensDto); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
+ Data: tokensDto,
+ Pagination: pagination,
+ })
+}
+
+// deleteSignupTokenHandler godoc
+// @Summary Delete signup token
+// @Description Delete a signup token by ID
+// @Tags Users
+// @Param id path string true "Token ID"
+// @Success 204 "No Content"
+// @Router /api/signup-tokens/{id} [delete]
+func (usc *UserSignupController) deleteSignupTokenHandler(c *gin.Context) {
+ tokenID := c.Param("id")
+
+ err := usc.userSignUpService.DeleteSignupToken(c.Request.Context(), tokenID)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+
+// signupWithTokenHandler godoc
+// @Summary Sign up
+// @Description Create a new user account
+// @Tags Users
+// @Accept json
+// @Produce json
+// @Param user body dto.SignUpDto true "User information"
+// @Success 201 {object} dto.SignUpDto
+// @Router /api/signup [post]
+func (usc *UserSignupController) signupHandler(c *gin.Context) {
+ var input dto.SignUpDto
+ if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ ipAddress := c.ClientIP()
+ userAgent := c.GetHeader("User-Agent")
+
+ user, accessToken, err := usc.userSignUpService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
+ cookie.AddAccessTokenCookie(c, maxAge, accessToken)
+
+ var userDto dto.UserDto
+ if err := dto.MapStruct(user, &userDto); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, userDto)
+}
diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go
index 646550be..7fbabb9a 100644
--- a/backend/internal/dto/app_config_dto.go
+++ b/backend/internal/dto/app_config_dto.go
@@ -54,4 +54,5 @@ type AppConfigUpdateDto struct {
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
+ EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
}
diff --git a/backend/internal/dto/one_time_access_dto.go b/backend/internal/dto/one_time_access_dto.go
new file mode 100644
index 00000000..336def70
--- /dev/null
+++ b/backend/internal/dto/one_time_access_dto.go
@@ -0,0 +1,17 @@
+package dto
+
+import "github.com/pocket-id/pocket-id/backend/internal/utils"
+
+type OneTimeAccessTokenCreateDto struct {
+ UserID string `json:"userId"`
+ TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
+}
+
+type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
+ Email string `json:"email" binding:"required,email" unorm:"nfc"`
+ RedirectPath string `json:"redirectPath"`
+}
+
+type OneTimeAccessEmailAsAdminDto struct {
+ TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
+}
diff --git a/backend/internal/dto/signup_dto.go b/backend/internal/dto/signup_dto.go
new file mode 100644
index 00000000..b135a49f
--- /dev/null
+++ b/backend/internal/dto/signup_dto.go
@@ -0,0 +1,9 @@
+package dto
+
+type SignUpDto struct {
+ Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
+ Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
+ FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
+ LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
+ Token string `json:"token"`
+}
diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go
index 671142f5..90e457ff 100644
--- a/backend/internal/dto/user_dto.go
+++ b/backend/internal/dto/user_dto.go
@@ -4,35 +4,36 @@ import (
"errors"
"github.com/gin-gonic/gin/binding"
- "github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserDto struct {
- ID string `json:"id"`
- Username string `json:"username"`
- Email *string `json:"email" `
- FirstName string `json:"firstName"`
- LastName *string `json:"lastName"`
- DisplayName string `json:"displayName"`
- IsAdmin bool `json:"isAdmin"`
- Locale *string `json:"locale"`
- CustomClaims []CustomClaimDto `json:"customClaims"`
- UserGroups []UserGroupMinimalDto `json:"userGroups"`
- LdapID *string `json:"ldapId"`
- Disabled bool `json:"disabled"`
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Email *string `json:"email"`
+ EmailVerified bool `json:"emailVerified"`
+ FirstName string `json:"firstName"`
+ LastName *string `json:"lastName"`
+ DisplayName string `json:"displayName"`
+ IsAdmin bool `json:"isAdmin"`
+ Locale *string `json:"locale"`
+ CustomClaims []CustomClaimDto `json:"customClaims"`
+ UserGroups []UserGroupMinimalDto `json:"userGroups"`
+ LdapID *string `json:"ldapId"`
+ Disabled bool `json:"disabled"`
}
type UserCreateDto struct {
- Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
- Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
- FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
- LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
- DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
- IsAdmin bool `json:"isAdmin"`
- Locale *string `json:"locale"`
- Disabled bool `json:"disabled"`
- UserGroupIds []string `json:"userGroupIds"`
- LdapID string `json:"-"`
+ Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
+ Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
+ EmailVerified bool `json:"emailVerified"`
+ FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
+ LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
+ DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
+ IsAdmin bool `json:"isAdmin"`
+ Locale *string `json:"locale"`
+ Disabled bool `json:"disabled"`
+ UserGroupIds []string `json:"userGroupIds"`
+ LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {
@@ -46,28 +47,10 @@ func (u UserCreateDto) Validate() error {
return e.Struct(u)
}
-type OneTimeAccessTokenCreateDto struct {
- UserID string `json:"userId"`
- TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
-}
-
-type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
- Email string `json:"email" binding:"required,email" unorm:"nfc"`
- RedirectPath string `json:"redirectPath"`
-}
-
-type OneTimeAccessEmailAsAdminDto struct {
- TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
+type EmailVerificationDto struct {
+ Token string `json:"token" binding:"required"`
}
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}
-
-type SignUpDto struct {
- Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
- Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
- FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
- LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
- Token string `json:"token"`
-}
diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go
index 338f989c..fca96516 100644
--- a/backend/internal/job/db_cleanup_job.go
+++ b/backend/internal/job/db_cleanup_job.go
@@ -24,6 +24,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
+ s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
@@ -135,3 +136,16 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil
}
+
+// ClearEmailVerificationTokens deletes email verification tokens that have expired
+func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
+ st := j.db.
+ WithContext(ctx).
+ Delete(&model.EmailVerificationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
+ if st.Error != nil {
+ return fmt.Errorf("failed to clean expired email verification tokens: %w", st.Error)
+ }
+
+ slog.InfoContext(ctx, "Cleaned expired email verification tokens", slog.Int64("count", st.RowsAffected))
+ return nil
+}
diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go
index 424a2c42..3e008478 100644
--- a/backend/internal/model/app_config.go
+++ b/backend/internal/model/app_config.go
@@ -59,6 +59,7 @@ type AppConfig struct {
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
+ EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
// LDAP
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"`
diff --git a/backend/internal/model/email_verification_token.go b/backend/internal/model/email_verification_token.go
new file mode 100644
index 00000000..d93d6c6d
--- /dev/null
+++ b/backend/internal/model/email_verification_token.go
@@ -0,0 +1,13 @@
+package model
+
+import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+
+type EmailVerificationToken struct {
+ Base
+
+ Token string
+ ExpiresAt datatype.DateTime
+
+ UserID string
+ User User
+}
diff --git a/backend/internal/model/one_time_access_token.go b/backend/internal/model/one_time_access_token.go
new file mode 100644
index 00000000..3a3c095d
--- /dev/null
+++ b/backend/internal/model/one_time_access_token.go
@@ -0,0 +1,13 @@
+package model
+
+import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+
+type OneTimeAccessToken struct {
+ Base
+ Token string
+ DeviceToken *string
+ ExpiresAt datatype.DateTime
+
+ UserID string
+ User User
+}
diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go
index a2fb44d3..4426512a 100644
--- a/backend/internal/model/user.go
+++ b/backend/internal/model/user.go
@@ -14,16 +14,17 @@ import (
type User struct {
Base
- Username string `sortable:"true"`
- Email *string `sortable:"true"`
- FirstName string `sortable:"true"`
- LastName string `sortable:"true"`
- DisplayName string `sortable:"true"`
- IsAdmin bool `sortable:"true" filterable:"true"`
- Locale *string
- LdapID *string
- Disabled bool `sortable:"true" filterable:"true"`
- UpdatedAt *datatype.DateTime
+ Username string `sortable:"true"`
+ Email *string `sortable:"true"`
+ EmailVerified bool `sortable:"true" filterable:"true"`
+ FirstName string `sortable:"true"`
+ LastName string `sortable:"true"`
+ DisplayName string `sortable:"true"`
+ IsAdmin bool `sortable:"true" filterable:"true"`
+ Locale *string
+ LdapID *string
+ Disabled bool `sortable:"true" filterable:"true"`
+ UpdatedAt *datatype.DateTime
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -93,13 +94,3 @@ func (u User) LastModified() time.Time {
}
return u.CreatedAt.ToTime()
}
-
-type OneTimeAccessToken struct {
- Base
- Token string
- DeviceToken *string
- ExpiresAt datatype.DateTime
-
- UserID string
- User User
-}
diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go
index 1f8ea731..e05427f6 100644
--- a/backend/internal/service/app_config_service.go
+++ b/backend/internal/service/app_config_service.go
@@ -84,6 +84,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
+ EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
// LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{},
diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go
index 7cb98530..4e65064d 100644
--- a/backend/internal/service/e2etest_service.go
+++ b/backend/internal/service/e2etest_service.go
@@ -80,23 +80,25 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
},
- Username: "tim",
- Email: utils.Ptr("tim.cook@test.com"),
- FirstName: "Tim",
- LastName: "Cook",
- DisplayName: "Tim Cook",
- IsAdmin: true,
+ Username: "tim",
+ Email: utils.Ptr("tim.cook@test.com"),
+ EmailVerified: true,
+ FirstName: "Tim",
+ LastName: "Cook",
+ DisplayName: "Tim Cook",
+ IsAdmin: true,
},
{
Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
},
- Username: "craig",
- Email: utils.Ptr("craig.federighi@test.com"),
- FirstName: "Craig",
- LastName: "Federighi",
- DisplayName: "Craig Federighi",
- IsAdmin: false,
+ Username: "craig",
+ Email: utils.Ptr("craig.federighi@test.com"),
+ EmailVerified: false,
+ FirstName: "Craig",
+ LastName: "Federighi",
+ DisplayName: "Craig Federighi",
+ IsAdmin: false,
},
{
Base: model.Base{
@@ -427,6 +429,31 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
+ emailVerificationTokens := []model.EmailVerificationToken{
+ {
+ Base: model.Base{
+ ID: "ef9ca469-b178-4857-bd39-26639dca45de",
+ },
+ Token: "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup",
+ ExpiresAt: datatype.DateTime(time.Now().Add(2 * time.Hour)),
+ UserID: users[1].ID,
+ },
+ {
+ Base: model.Base{
+ ID: "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00",
+ },
+ Token: "EXPIRED1234567890ABCDE",
+ ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Hour)),
+ UserID: users[1].ID,
+ },
+ }
+
+ for _, token := range emailVerificationTokens {
+ if err := tx.Create(&token).Error; err != nil {
+ return err
+ }
+ }
+
keyValues := []model.KV{
{
Key: jwkutils.PrivateKeyDBKey,
diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go
index a7ca10a0..694e6613 100644
--- a/backend/internal/service/email_service_templates.go
+++ b/backend/internal/service/email_service_templates.go
@@ -49,6 +49,13 @@ var ApiKeyExpiringSoonTemplate = email.Template[ApiKeyExpiringSoonTemplateData]{
},
}
+var EmailVerificationTemplate = email.Template[EmailVerificationTemplateData]{
+ Path: "email-verification",
+ Title: func(data *email.TemplateData[EmailVerificationTemplateData]) string {
+ return "Verify your " + data.AppName + " email address"
+ },
+}
+
type NewLoginTemplateData struct {
IPAddress string
Country string
@@ -70,5 +77,10 @@ type ApiKeyExpiringSoonTemplateData struct {
ExpiresAt time.Time
}
+type EmailVerificationTemplateData struct {
+ UserFullName string
+ VerificationLink string
+}
+
// this is list of all template paths used for preloading templates
-var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path}
+var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path, EmailVerificationTemplate.Path}
diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go
index 93778e96..01a8ff3d 100644
--- a/backend/internal/service/ldap_service.go
+++ b/backend/internal/service/ldap_service.go
@@ -378,13 +378,14 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
}
newUser := dto.UserCreateDto{
- Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
- Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
- FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
- LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
- DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
- IsAdmin: isAdmin,
- LdapID: ldapId,
+ Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
+ Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
+ EmailVerified: true,
+ FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
+ LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
+ DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
+ IsAdmin: isAdmin,
+ LdapID: ldapId,
}
if newUser.DisplayName == "" {
diff --git a/backend/internal/service/one_time_access_service.go b/backend/internal/service/one_time_access_service.go
new file mode 100644
index 00000000..a9d80d80
--- /dev/null
+++ b/backend/internal/service/one_time_access_service.go
@@ -0,0 +1,229 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+ "github.com/pocket-id/pocket-id/backend/internal/utils/email"
+ "go.opentelemetry.io/otel/trace"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type OneTimeAccessService struct {
+ db *gorm.DB
+ userService *UserService
+ appConfigService *AppConfigService
+ jwtService *JwtService
+ auditLogService *AuditLogService
+ emailService *EmailService
+}
+
+func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService {
+ return &OneTimeAccessService{
+ db: db,
+ userService: userService,
+ appConfigService: appConfigService,
+ jwtService: jwtService,
+ auditLogService: auditLogService,
+ emailService: emailService,
+ }
+}
+
+func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
+ isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
+ if isDisabled {
+ return &common.OneTimeAccessDisabledError{}
+ }
+
+ _, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
+ return err
+}
+
+func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
+ isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
+ if isDisabled {
+ return "", &common.OneTimeAccessDisabledError{}
+ }
+
+ var userId string
+ err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ // Do not return error if user not found to prevent email enumeration
+ return "", nil
+ } else if err != nil {
+ return "", err
+ }
+
+ deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
+ if err != nil {
+ return "", err
+ } else if deviceToken == nil {
+ return "", errors.New("device token expected but not returned")
+ }
+
+ return *deviceToken, nil
+}
+
+func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
+ tx := s.db.Begin()
+ defer func() {
+ tx.Rollback()
+ }()
+
+ user, err := s.userService.GetUser(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+
+ if user.Email == nil {
+ return nil, &common.UserEmailNotSetError{}
+ }
+
+ oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
+ if err != nil {
+ return nil, err
+ }
+ err = tx.Commit().Error
+ if err != nil {
+ return nil, err
+ }
+
+ // We use a background context here as this is running in a goroutine
+ //nolint:contextcheck
+ go func() {
+ span := trace.SpanFromContext(ctx)
+ innerCtx := trace.ContextWithSpan(context.Background(), span)
+
+ link := common.EnvConfig.AppURL + "/lc"
+ linkWithCode := link + "/" + oneTimeAccessToken
+
+ // Add redirect path to the link
+ if strings.HasPrefix(redirectPath, "/") {
+ encodedRedirectPath := url.QueryEscape(redirectPath)
+ linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
+ }
+
+ errInternal := SendEmail(innerCtx, s.emailService, email.Address{
+ Name: user.FullName(),
+ Email: *user.Email,
+ }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
+ Code: oneTimeAccessToken,
+ LoginLink: link,
+ LoginLinkWithCode: linkWithCode,
+ ExpirationString: utils.DurationToString(ttl),
+ })
+ if errInternal != nil {
+ slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
+ return
+ }
+ }()
+
+ return deviceToken, nil
+}
+
+func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
+ token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
+ return token, err
+}
+
+func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
+ oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
+ if err != nil {
+ return "", nil, err
+ }
+
+ err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
+ if err != nil {
+ return "", nil, err
+ }
+
+ return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
+}
+
+func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
+ tx := s.db.Begin()
+ defer func() {
+ tx.Rollback()
+ }()
+
+ var oneTimeAccessToken model.OneTimeAccessToken
+ err := tx.
+ WithContext(ctx).
+ Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
+ Preload("User").
+ Clauses(clause.Locking{Strength: "UPDATE"}).
+ First(&oneTimeAccessToken).
+ Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return model.User{}, "", &common.TokenInvalidOrExpiredError{}
+ }
+ return model.User{}, "", err
+ }
+ if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
+ return model.User{}, "", &common.DeviceCodeInvalid{}
+ }
+
+ accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ err = tx.
+ WithContext(ctx).
+ Delete(&oneTimeAccessToken).
+ Error
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
+
+ err = tx.Commit().Error
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ return oneTimeAccessToken.User, accessToken, nil
+}
+
+func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
+ // If expires at is less than 15 minutes, use a 6-character token instead of 16
+ tokenLength := 16
+ if ttl <= 15*time.Minute {
+ tokenLength = 6
+ }
+
+ token, err := utils.GenerateRandomUnambiguousString(tokenLength)
+ if err != nil {
+ return nil, err
+ }
+
+ var deviceToken *string
+ if withDeviceToken {
+ dt, err := utils.GenerateRandomAlphanumericString(16)
+ if err != nil {
+ return nil, err
+ }
+ deviceToken = &dt
+ }
+
+ now := time.Now().Round(time.Second)
+ o := &model.OneTimeAccessToken{
+ UserID: userID,
+ ExpiresAt: datatype.DateTime(now.Add(ttl)),
+ Token: token,
+ DeviceToken: deviceToken,
+ }
+
+ return o, nil
+}
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
index b263aeed..b02dcab3 100644
--- a/backend/internal/service/user_service.go
+++ b/backend/internal/service/user_service.go
@@ -9,13 +9,11 @@ import (
"io"
"io/fs"
"log/slog"
- "net/url"
"path"
- "strings"
"time"
"github.com/google/uuid"
- "go.opentelemetry.io/otel/trace"
+ "github.com/pocket-id/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -25,7 +23,6 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
- "github.com/pocket-id/pocket-id/backend/internal/utils/email"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
)
@@ -269,15 +266,16 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
}
user := model.User{
- FirstName: input.FirstName,
- LastName: input.LastName,
- DisplayName: input.DisplayName,
- Email: input.Email,
- Username: input.Username,
- IsAdmin: input.IsAdmin,
- Locale: input.Locale,
- Disabled: input.Disabled,
- UserGroups: userGroups,
+ FirstName: input.FirstName,
+ LastName: input.LastName,
+ DisplayName: input.DisplayName,
+ Email: input.Email,
+ EmailVerified: input.EmailVerified,
+ Username: input.Username,
+ IsAdmin: input.IsAdmin,
+ Locale: input.Locale,
+ Disabled: input.Disabled,
+ UserGroups: userGroups,
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
@@ -419,13 +417,20 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName
- user.Email = updatedUser.Email
user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
+ if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) {
+ // Email has changed, reset email verification status
+ user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
+ }
+
+ user.Email = updatedUser.Email
+
// Admin-only fields: Only allow updates when not updating own account
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
+ user.EmailVerified = updatedUser.EmailVerified
user.Disabled = updatedUser.Disabled
}
}
@@ -455,164 +460,6 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, nil
}
-func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
- isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
- if isDisabled {
- return &common.OneTimeAccessDisabledError{}
- }
-
- _, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
- return err
-}
-
-func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
- isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
- if isDisabled {
- return "", &common.OneTimeAccessDisabledError{}
- }
-
- var userId string
- err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- // Do not return error if user not found to prevent email enumeration
- return "", nil
- } else if err != nil {
- return "", err
- }
-
- deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
- if err != nil {
- return "", err
- } else if deviceToken == nil {
- return "", errors.New("device token expected but not returned")
- }
-
- return *deviceToken, nil
-}
-
-func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
- tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
-
- user, err := s.GetUser(ctx, userID)
- if err != nil {
- return nil, err
- }
-
- if user.Email == nil {
- return nil, &common.UserEmailNotSetError{}
- }
-
- oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
- if err != nil {
- return nil, err
- }
- err = tx.Commit().Error
- if err != nil {
- return nil, err
- }
-
- // We use a background context here as this is running in a goroutine
- //nolint:contextcheck
- go func() {
- span := trace.SpanFromContext(ctx)
- innerCtx := trace.ContextWithSpan(context.Background(), span)
-
- link := common.EnvConfig.AppURL + "/lc"
- linkWithCode := link + "/" + oneTimeAccessToken
-
- // Add redirect path to the link
- if strings.HasPrefix(redirectPath, "/") {
- encodedRedirectPath := url.QueryEscape(redirectPath)
- linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
- }
-
- errInternal := SendEmail(innerCtx, s.emailService, email.Address{
- Name: user.FullName(),
- Email: *user.Email,
- }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
- Code: oneTimeAccessToken,
- LoginLink: link,
- LoginLinkWithCode: linkWithCode,
- ExpirationString: utils.DurationToString(ttl),
- })
- if errInternal != nil {
- slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
- return
- }
- }()
-
- return deviceToken, nil
-}
-
-func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
- token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
- return token, err
-}
-
-func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
- oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
- if err != nil {
- return "", nil, err
- }
-
- err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
- if err != nil {
- return "", nil, err
- }
-
- return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
-}
-
-func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
- tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
-
- var oneTimeAccessToken model.OneTimeAccessToken
- err := tx.
- WithContext(ctx).
- Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
- Preload("User").
- Clauses(clause.Locking{Strength: "UPDATE"}).
- First(&oneTimeAccessToken).
- Error
- if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return model.User{}, "", &common.TokenInvalidOrExpiredError{}
- }
- return model.User{}, "", err
- }
- if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
- return model.User{}, "", &common.DeviceCodeInvalid{}
- }
-
- accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
- if err != nil {
- return model.User{}, "", err
- }
-
- err = tx.
- WithContext(ctx).
- Delete(&oneTimeAccessToken).
- Error
- if err != nil {
- return model.User{}, "", err
- }
-
- s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
-
- err = tx.Commit().Error
- if err != nil {
- return model.User{}, "", err
- }
-
- return oneTimeAccessToken.User, accessToken, nil
-}
-
func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) {
tx := s.db.Begin()
defer func() {
@@ -672,47 +519,6 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return user, nil
}
-func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
- tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
-
- var userCount int64
- if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
- return model.User{}, "", err
- }
- if userCount != 0 {
- return model.User{}, "", &common.SetupAlreadyCompletedError{}
- }
-
- userToCreate := dto.UserCreateDto{
- FirstName: signUpData.FirstName,
- LastName: signUpData.LastName,
- DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
- Username: signUpData.Username,
- Email: signUpData.Email,
- IsAdmin: true,
- }
-
- user, err := s.createUserInternal(ctx, userToCreate, false, tx)
- if err != nil {
- return model.User{}, "", err
- }
-
- token, err := s.jwtService.GenerateAccessToken(user)
- if err != nil {
- return model.User{}, "", err
- }
-
- err = tx.Commit().Error
- if err != nil {
- return model.User{}, "", err
- }
-
- return user, token, nil
-}
-
func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
var result struct {
Found bool
@@ -774,172 +580,72 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
return nil
}
-func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
- signupToken, err := NewSignupToken(ttl, usageLimit)
+func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error {
+ user, err := s.GetUser(ctx, userID)
if err != nil {
- return model.SignupToken{}, err
+ return err
}
- var userGroups []model.UserGroup
- err = s.db.WithContext(ctx).
- Where("id IN ?", userGroupIDs).
- Find(&userGroups).
- Error
- if err != nil {
- return model.SignupToken{}, err
- }
- signupToken.UserGroups = userGroups
-
- err = s.db.WithContext(ctx).Create(signupToken).Error
- if err != nil {
- return model.SignupToken{}, err
+ if user.Email == nil {
+ return &common.UserEmailNotSetError{}
}
- return *signupToken, nil
+ randomToken, err := utils.GenerateRandomAlphanumericString(32)
+ if err != nil {
+ return err
+ }
+
+ expiration := time.Now().Add(24 * time.Hour)
+ emailVerificationToken := &model.EmailVerificationToken{
+ UserID: user.ID,
+ Token: randomToken,
+ ExpiresAt: datatype.DateTime(expiration),
+ }
+
+ err = s.db.WithContext(ctx).Create(emailVerificationToken).Error
+ if err != nil {
+ return err
+ }
+
+ return SendEmail(ctx, s.emailService, email.Address{
+ Name: user.FullName(),
+ Email: *user.Email,
+ }, EmailVerificationTemplate, &EmailVerificationTemplateData{
+ UserFullName: user.FullName(),
+ VerificationLink: common.EnvConfig.AppURL + "/verify-email?token=" + emailVerificationToken.Token,
+ })
}
-func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) {
+func (s *UserService) VerifyEmail(ctx context.Context, userID string, token string) error {
tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
+ defer tx.Rollback()
- tokenProvided := signupData.Token != ""
+ var emailVerificationToken model.EmailVerificationToken
+ err := tx.WithContext(ctx).Where("token = ? AND user_id = ? AND expires_at > ?",
+ token, userID, datatype.DateTime(time.Now())).First(&emailVerificationToken).Error
- config := s.appConfigService.GetDbConfig()
- if config.AllowUserSignups.Value != "open" && !tokenProvided {
- return model.User{}, "", &common.OpenSignupDisabledError{}
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return &common.InvalidEmailVerificationTokenError{}
+ } else if err != nil {
+ return err
}
- var signupToken model.SignupToken
- var userGroupIDs []string
- if tokenProvided {
- err := tx.
- WithContext(ctx).
- Preload("UserGroups").
- Where("token = ?", signupData.Token).
- Clauses(clause.Locking{Strength: "UPDATE"}).
- First(&signupToken).
- Error
- if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return model.User{}, "", &common.TokenInvalidOrExpiredError{}
- }
- return model.User{}, "", err
- }
-
- if !signupToken.IsValid() {
- return model.User{}, "", &common.TokenInvalidOrExpiredError{}
- }
-
- for _, group := range signupToken.UserGroups {
- userGroupIDs = append(userGroupIDs, group.ID)
- }
- }
-
- userToCreate := dto.UserCreateDto{
- Username: signupData.Username,
- Email: signupData.Email,
- FirstName: signupData.FirstName,
- LastName: signupData.LastName,
- DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
- UserGroupIds: userGroupIDs,
- }
-
- user, err := s.createUserInternal(ctx, userToCreate, false, tx)
+ user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
if err != nil {
- return model.User{}, "", err
+ return err
}
- accessToken, err := s.jwtService.GenerateAccessToken(user)
+ user.EmailVerified = true
+ user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
+ err = tx.WithContext(ctx).Save(&user).Error
if err != nil {
- return model.User{}, "", err
+ return err
}
- if tokenProvided {
- s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
- "signupToken": signupToken.Token,
- }, tx)
-
- signupToken.UsageCount++
-
- err = tx.WithContext(ctx).Save(&signupToken).Error
- if err != nil {
- return model.User{}, "", err
-
- }
- } else {
- s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
- "method": "open_signup",
- }, tx)
- }
-
- err = tx.Commit().Error
+ err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error
if err != nil {
- return model.User{}, "", err
+ return err
}
- return user, accessToken, nil
-}
-
-func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
- var tokens []model.SignupToken
- query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
-
- pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
- return tokens, pagination, err
-}
-
-func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) error {
- return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
-}
-
-func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
- // If expires at is less than 15 minutes, use a 6-character token instead of 16
- tokenLength := 16
- if ttl <= 15*time.Minute {
- tokenLength = 6
- }
-
- token, err := utils.GenerateRandomUnambiguousString(tokenLength)
- if err != nil {
- return nil, err
- }
-
- var deviceToken *string
- if withDeviceToken {
- dt, err := utils.GenerateRandomAlphanumericString(16)
- if err != nil {
- return nil, err
- }
- deviceToken = &dt
- }
-
- now := time.Now().Round(time.Second)
- o := &model.OneTimeAccessToken{
- UserID: userID,
- ExpiresAt: datatype.DateTime(now.Add(ttl)),
- Token: token,
- DeviceToken: deviceToken,
- }
-
- return o, nil
-}
-
-func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
- // Generate a random token
- randomString, err := utils.GenerateRandomAlphanumericString(16)
- if err != nil {
- return nil, err
- }
-
- now := time.Now().Round(time.Second)
- token := &model.SignupToken{
- Token: randomString,
- ExpiresAt: datatype.DateTime(now.Add(ttl)),
- UsageLimit: usageLimit,
- UsageCount: 0,
- }
-
- return token, nil
+ return tx.Commit().Error
}
diff --git a/backend/internal/service/user_signup_service.go b/backend/internal/service/user_signup_service.go
new file mode 100644
index 00000000..604a81b2
--- /dev/null
+++ b/backend/internal/service/user_signup_service.go
@@ -0,0 +1,214 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/dto"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type UserSignUpService struct {
+ db *gorm.DB
+ userService *UserService
+ jwtService *JwtService
+ auditLogService *AuditLogService
+ appConfigService *AppConfigService
+}
+
+func NewUserSignupService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService, userService *UserService) *UserSignUpService {
+ return &UserSignUpService{
+ db: db,
+ jwtService: jwtService,
+ auditLogService: auditLogService,
+ appConfigService: appConfigService,
+ userService: userService,
+ }
+}
+
+func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) {
+ tx := s.db.Begin()
+ defer func() {
+ tx.Rollback()
+ }()
+
+ tokenProvided := signupData.Token != ""
+
+ config := s.appConfigService.GetDbConfig()
+ if config.AllowUserSignups.Value != "open" && !tokenProvided {
+ return model.User{}, "", &common.OpenSignupDisabledError{}
+ }
+
+ var signupToken model.SignupToken
+ var userGroupIDs []string
+ if tokenProvided {
+ err := tx.
+ WithContext(ctx).
+ Preload("UserGroups").
+ Where("token = ?", signupData.Token).
+ Clauses(clause.Locking{Strength: "UPDATE"}).
+ First(&signupToken).
+ Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return model.User{}, "", &common.TokenInvalidOrExpiredError{}
+ }
+ return model.User{}, "", err
+ }
+
+ if !signupToken.IsValid() {
+ return model.User{}, "", &common.TokenInvalidOrExpiredError{}
+ }
+
+ for _, group := range signupToken.UserGroups {
+ userGroupIDs = append(userGroupIDs, group.ID)
+ }
+ }
+
+ userToCreate := dto.UserCreateDto{
+ Username: signupData.Username,
+ Email: signupData.Email,
+ FirstName: signupData.FirstName,
+ LastName: signupData.LastName,
+ DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
+ UserGroupIds: userGroupIDs,
+ EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
+ }
+
+ user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ accessToken, err := s.jwtService.GenerateAccessToken(user)
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ if tokenProvided {
+ s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
+ "signupToken": signupToken.Token,
+ }, tx)
+
+ signupToken.UsageCount++
+
+ err = tx.WithContext(ctx).Save(&signupToken).Error
+ if err != nil {
+ return model.User{}, "", err
+
+ }
+ } else {
+ s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
+ "method": "open_signup",
+ }, tx)
+ }
+
+ err = tx.Commit().Error
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ return user, accessToken, nil
+}
+
+func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
+ tx := s.db.Begin()
+ defer func() {
+ tx.Rollback()
+ }()
+
+ var userCount int64
+ if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
+ return model.User{}, "", err
+ }
+ if userCount != 0 {
+ return model.User{}, "", &common.SetupAlreadyCompletedError{}
+ }
+
+ userToCreate := dto.UserCreateDto{
+ FirstName: signUpData.FirstName,
+ LastName: signUpData.LastName,
+ DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
+ Username: signUpData.Username,
+ Email: signUpData.Email,
+ IsAdmin: true,
+ }
+
+ user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ token, err := s.jwtService.GenerateAccessToken(user)
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ err = tx.Commit().Error
+ if err != nil {
+ return model.User{}, "", err
+ }
+
+ return user, token, nil
+}
+
+func (s *UserSignUpService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
+ var tokens []model.SignupToken
+ query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
+
+ pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
+ return tokens, pagination, err
+}
+
+func (s *UserSignUpService) DeleteSignupToken(ctx context.Context, tokenID string) error {
+ return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
+}
+
+func (s *UserSignUpService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
+ signupToken, err := NewSignupToken(ttl, usageLimit)
+ if err != nil {
+ return model.SignupToken{}, err
+ }
+
+ var userGroups []model.UserGroup
+ err = s.db.WithContext(ctx).
+ Where("id IN ?", userGroupIDs).
+ Find(&userGroups).
+ Error
+ if err != nil {
+ return model.SignupToken{}, err
+ }
+ signupToken.UserGroups = userGroups
+
+ err = s.db.WithContext(ctx).Create(signupToken).Error
+ if err != nil {
+ return model.SignupToken{}, err
+ }
+
+ return *signupToken, nil
+}
+
+func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
+ // Generate a random token
+ randomString, err := utils.GenerateRandomAlphanumericString(16)
+ if err != nil {
+ return nil, err
+ }
+
+ now := time.Now().Round(time.Second)
+ token := &model.SignupToken{
+ Token: randomString,
+ ExpiresAt: datatype.DateTime(now.Add(ttl)),
+ UsageLimit: usageLimit,
+ UsageCount: 0,
+ }
+
+ return token, nil
+}
diff --git a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl
index a7ee76b1..8b52a5a0 100644
--- a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl
+++ b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl
@@ -1 +1 @@
-{{define "root"}}
{{.AppName}}
API Key Expiring Soon Warning
Hello {{.Data.Name}}, This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}} .
Please generate a new API key if you need continued access.
{{end}}
\ No newline at end of file
+{{define "root"}}{{.AppName}}
API Key Expiring Soon Warning
Hello {{.Data.Name}}, This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}} .
Please generate a new API key if you need continued access.
{{end}}
\ No newline at end of file
diff --git a/backend/resources/email-templates/api-key-expiring-soon_text.tmpl b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl
index e9f285d8..ae7ba74b 100644
--- a/backend/resources/email-templates/api-key-expiring-soon_text.tmpl
+++ b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl
@@ -6,7 +6,6 @@ API KEY EXPIRING SOON
Warning
Hello {{.Data.Name}},
-This is a reminder that your API key {{.Data.APIKeyName}} will expire on
-{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
+This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
Please generate a new API key if you need continued access.{{end}}
\ No newline at end of file
diff --git a/backend/resources/email-templates/email-verification_html.tmpl b/backend/resources/email-templates/email-verification_html.tmpl
new file mode 100644
index 00000000..3d32f78f
--- /dev/null
+++ b/backend/resources/email-templates/email-verification_html.tmpl
@@ -0,0 +1 @@
+{{define "root"}}{{.AppName}}
Hello {{.Data.UserFullName}}, Click the button below to verify your email address for {{.AppName}}. This link will expire in 24 hours.
{{end}}
\ No newline at end of file
diff --git a/backend/resources/email-templates/email-verification_text.tmpl b/backend/resources/email-templates/email-verification_text.tmpl
new file mode 100644
index 00000000..660394ed
--- /dev/null
+++ b/backend/resources/email-templates/email-verification_text.tmpl
@@ -0,0 +1,10 @@
+{{define "root"}}{{.AppName}}
+
+
+EMAIL VERIFICATION
+
+Hello {{.Data.UserFullName}},
+Click the button below to verify your email address for {{.AppName}}. This link will expire in 24 hours.
+
+
+Verify {{.Data.VerificationLink}}{{end}}
\ No newline at end of file
diff --git a/backend/resources/email-templates/login-with-new-device_html.tmpl b/backend/resources/email-templates/login-with-new-device_html.tmpl
index c91a7ea1..a3c5aa5c 100644
--- a/backend/resources/email-templates/login-with-new-device_html.tmpl
+++ b/backend/resources/email-templates/login-with-new-device_html.tmpl
@@ -1 +1 @@
-{{define "root"}}{{.AppName}}
New Sign-In Detected Warning
Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
Details Approximate Location
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP Address
{{.Data.IPAddress}}
Device
{{.Data.Device}}
Sign-In Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}
{{end}}
\ No newline at end of file
+{{define "root"}}{{.AppName}}
New Sign-In Detected Warning
Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
Details Approximate Location
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP Address
{{.Data.IPAddress}}
Device
{{.Data.Device}}
Sign-In Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}
{{end}}
\ No newline at end of file
diff --git a/backend/resources/email-templates/login-with-new-device_text.tmpl b/backend/resources/email-templates/login-with-new-device_text.tmpl
index b4baba0b..9d8c183c 100644
--- a/backend/resources/email-templates/login-with-new-device_text.tmpl
+++ b/backend/resources/email-templates/login-with-new-device_text.tmpl
@@ -5,15 +5,13 @@ NEW SIGN-IN DETECTED
Warning
-Your {{.AppName}} account was recently accessed from a new IP address or
-browser. If you recognize this activity, no further action is required.
+Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
DETAILS
Approximate Location
-{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if
-.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
+{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP Address
diff --git a/backend/resources/email-templates/one-time-access_html.tmpl b/backend/resources/email-templates/one-time-access_html.tmpl
index 6b6fb0b6..86007b1c 100644
--- a/backend/resources/email-templates/one-time-access_html.tmpl
+++ b/backend/resources/email-templates/one-time-access_html.tmpl
@@ -1 +1 @@
-{{define "root"}}{{.AppName}}
Click the button below to sign in to {{.AppName}} with a login code. Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}} . This code expires in {{.Data.ExpirationString}}.
{{end}}
\ No newline at end of file
+{{define "root"}}{{.AppName}}
Click the button below to sign in to {{.AppName}} with a login code. Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}} . This code expires in {{.Data.ExpirationString}}.
{{end}}
\ No newline at end of file
diff --git a/backend/resources/email-templates/one-time-access_text.tmpl b/backend/resources/email-templates/one-time-access_text.tmpl
index 4a166150..8a311977 100644
--- a/backend/resources/email-templates/one-time-access_text.tmpl
+++ b/backend/resources/email-templates/one-time-access_text.tmpl
@@ -4,8 +4,7 @@
YOUR LOGIN CODE
Click the button below to sign in to {{.AppName}} with a login code.
-Or visit {{.Data.LoginLink}} {{.Data.LoginLink}} and enter the code
-{{.Data.Code}}.
+Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.
This code expires in {{.Data.ExpirationString}}.
diff --git a/backend/resources/email-templates/test_html.tmpl b/backend/resources/email-templates/test_html.tmpl
index 21a4bdde..ac1b65be 100644
--- a/backend/resources/email-templates/test_html.tmpl
+++ b/backend/resources/email-templates/test_html.tmpl
@@ -1 +1 @@
-{{define "root"}}{{.AppName}}
Your email setup is working correctly!
{{end}}
\ No newline at end of file
+{{define "root"}}{{.AppName}}
Your email setup is working correctly!
{{end}}
\ No newline at end of file
diff --git a/backend/resources/migrations/postgres/20260109090200_email_verification.down.sql b/backend/resources/migrations/postgres/20260109090200_email_verification.down.sql
new file mode 100644
index 00000000..85cc57a0
--- /dev/null
+++ b/backend/resources/migrations/postgres/20260109090200_email_verification.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE email_verification_tokens;
+ALTER TABLE users DROP COLUMN email_verified;
\ No newline at end of file
diff --git a/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql b/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql
new file mode 100644
index 00000000..c9342103
--- /dev/null
+++ b/backend/resources/migrations/postgres/20260109090200_email_verification.up.sql
@@ -0,0 +1,17 @@
+CREATE TABLE email_verification_tokens
+(
+ id UUID PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE
+);
+
+ALTER TABLE users
+ ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
+
+UPDATE users
+SET email_verified = EXISTS (SELECT 1
+ FROM app_config_variables
+ WHERE key = 'emailsVerified'
+ AND value = 'true');
\ No newline at end of file
diff --git a/backend/resources/migrations/sqlite/20260109090200_email_verification.down.sql b/backend/resources/migrations/sqlite/20260109090200_email_verification.down.sql
new file mode 100644
index 00000000..3e7e669f
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20260109090200_email_verification.down.sql
@@ -0,0 +1,8 @@
+PRAGMA foreign_keys= OFF;
+BEGIN;
+
+DROP TABLE email_verification_tokens;
+ALTER TABLE users DROP COLUMN email_verified;
+
+COMMIT;
+PRAGMA foreign_keys= ON;
diff --git a/backend/resources/migrations/sqlite/20260109090200_email_verification.up.sql b/backend/resources/migrations/sqlite/20260109090200_email_verification.up.sql
new file mode 100644
index 00000000..c680d516
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20260109090200_email_verification.up.sql
@@ -0,0 +1,24 @@
+PRAGMA foreign_keys= OFF;
+BEGIN;
+
+CREATE TABLE email_verification_tokens
+(
+ id TEXT PRIMARY KEY,
+ created_at DATETIME NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ expires_at DATETIME NOT NULL,
+ user_id TEXT NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+ALTER TABLE users
+ ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE;
+
+UPDATE users
+SET email_verified =EXISTS (SELECT 1
+ FROM app_config_variables
+ WHERE key = 'emailsVerified'
+ AND value = 'true');
+
+COMMIT;
+PRAGMA foreign_keys= ON;
diff --git a/email-templates/emails/email-verification.tsx b/email-templates/emails/email-verification.tsx
new file mode 100644
index 00000000..11e4d4c2
--- /dev/null
+++ b/email-templates/emails/email-verification.tsx
@@ -0,0 +1,54 @@
+import { Text } from "@react-email/components";
+import { BaseTemplate } from "../components/base-template";
+import { Button } from "../components/button";
+import CardHeader from "../components/card-header";
+import { sharedPreviewProps, sharedTemplateProps } from "../props";
+
+interface EmailVerificationData {
+ userFullName: string;
+ verificationLink: string;
+}
+
+interface EmailVerificationProps {
+ logoURL: string;
+ appName: string;
+ data: EmailVerificationData;
+}
+
+export const EmailVerification = ({
+ logoURL,
+ appName,
+ data,
+}: EmailVerificationProps) => (
+
+
+
+
+ Hello {data.userFullName},
+ Click the button below to verify your email address for {appName}. This
+ link will expire in 24 hours.
+
+
+
+ Verify
+
+);
+
+export default EmailVerification;
+
+EmailVerification.TemplateProps = {
+ ...sharedTemplateProps,
+ data: {
+ userFullName: "{{.Data.UserFullName}}",
+ verificationLink: "{{.Data.VerificationLink}}",
+ },
+};
+
+EmailVerification.PreviewProps = {
+ ...sharedPreviewProps,
+ data: {
+ userFullName: "Tim Cook",
+ verificationLink:
+ "https://localhost:1411/user/verify-email?code=abcdefg12345",
+ },
+};
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 18a9d9bf..e328330e 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -196,8 +196,6 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
"enable_self_account_editing": "Enable Self-Account Editing",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
- "emails_verified": "Emails Verified",
- "whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
"ldap_disabled_successfully": "LDAP disabled successfully",
"ldap_sync_finished": "LDAP sync finished",
@@ -511,5 +509,17 @@
"renew_api_key_description": "Renewing the API key will generate a new key. Make sure to update any integrations using this key.",
"api_key_renewed": "API key renewed",
"app_config_home_page": "Home Page",
- "app_config_home_page_description": "The page users are redirected to after signing in."
+ "app_config_home_page_description": "The page users are redirected to after signing in.",
+ "email_verification_warning": "Verify your email address",
+ "email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.",
+ "email_verification": "Email Verification",
+ "email_verification_description": "Send a verification email to users when they sign up or change their email address.",
+ "email_verification_success_title": "Email Verified Successfully",
+ "email_verification_success_description": "Your email address has been verified successfully.",
+ "email_verification_error_title": "Email Verification Failed",
+ "mark_as_unverified": "Mark as unverified",
+ "mark_as_verified": "Mark as verified",
+ "email_verification_sent": "Verification email sent successfully.",
+ "emails_verified_by_default": "Emails verified by default",
+ "emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
}
diff --git a/frontend/src/lib/components/email-verification-state-box.svelte b/frontend/src/lib/components/email-verification-state-box.svelte
new file mode 100644
index 00000000..e684c180
--- /dev/null
+++ b/frontend/src/lib/components/email-verification-state-box.svelte
@@ -0,0 +1,79 @@
+
+
+{#if emailVerificationState}
+ {#if emailVerificationState === 'success'}
+
+
+ {m.email_verification_success_title()}
+
+ {m.email_verification_success_description()}
+
+
+ {:else}
+
+
+ {m.email_verification_error_title()}
+
+ {emailVerificationState}
+
+
+ {/if}
+{:else if $userStore && $appConfigStore.emailVerificationEnabled && !$userStore.emailVerified}
+
+
+
+
+
{m.email_verification_warning()}
+
+ {m.email_verification_warning_description()}
+
+
+
+
+ {m.send_email()}
+
+
+
+
+{/if}
diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte
index b2ef00e1..09273c36 100644
--- a/frontend/src/lib/components/form/form-input.svelte
+++ b/frontend/src/lib/components/form/form-input.svelte
@@ -31,6 +31,7 @@
children,
onInput,
labelFor,
+ inputClass,
...restProps
}: HTMLAttributes &
(WithChildren | WithoutChildren) & {
@@ -39,6 +40,7 @@
docsLink?: string;
placeholder?: string;
disabled?: boolean;
+ inputClass?: string;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
onInput?: (e: FormInputEvent) => void;
} = $props();
@@ -73,6 +75,7 @@
{:else}
svg]:text-green-900 dark:[&>svg]:text-green-100',
info: 'bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100 [&>svg]:text-blue-900 dark:[&>svg]:text-blue-100',
destructive:
- 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
+ 'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100 [&>svg]:text-red-900 dark:[&>svg]:text-red-100',
warning:
'bg-warning text-warning-foreground border-warning/40 [&>svg]:text-warning-foreground'
}
@@ -32,10 +34,12 @@
class: className,
variant = 'default',
children,
+ onDismiss,
dismissibleId = undefined,
...restProps
}: WithElementRef> & {
variant?: AlertVariant;
+ onDismiss?: () => void;
dismissibleId?: string;
} = $props();
@@ -49,6 +53,7 @@
});
function dismiss() {
+ onDismiss?.();
if (dismissibleId) {
const dismissedAlerts = JSON.parse(localStorage.getItem('dismissed-alerts') || '[]');
localStorage.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId]));
@@ -66,7 +71,7 @@
role="alert"
>
{@render children?.()}
- {#if dismissibleId}
+ {#if dismissibleId || onDismiss}
diff --git a/frontend/src/lib/components/ui/toggle/index.ts b/frontend/src/lib/components/ui/toggle/index.ts
new file mode 100644
index 00000000..8cb2936f
--- /dev/null
+++ b/frontend/src/lib/components/ui/toggle/index.ts
@@ -0,0 +1,13 @@
+import Root from "./toggle.svelte";
+export {
+ toggleVariants,
+ type ToggleSize,
+ type ToggleVariant,
+ type ToggleVariants,
+} from "./toggle.svelte";
+
+export {
+ Root,
+ //
+ Root as Toggle,
+};
diff --git a/frontend/src/lib/components/ui/toggle/toggle.svelte b/frontend/src/lib/components/ui/toggle/toggle.svelte
new file mode 100644
index 00000000..f48b95f7
--- /dev/null
+++ b/frontend/src/lib/components/ui/toggle/toggle.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+
diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts
index 9f3163f0..e577d513 100644
--- a/frontend/src/lib/services/user-service.ts
+++ b/frontend/src/lib/services/user-service.ts
@@ -2,7 +2,7 @@ import userStore from '$lib/stores/user-store';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type { SignupToken } from '$lib/types/signup-token.type';
import type { UserGroup } from '$lib/types/user-group.type';
-import type { User, UserCreate, UserSignUp } from '$lib/types/user.type';
+import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { get } from 'svelte/store';
import APIService from './api-service';
@@ -38,7 +38,7 @@ export default class UserService extends APIService {
return res.data as User;
};
- updateCurrent = async (user: UserCreate) => {
+ updateCurrent = async (user: AccountUpdate) => {
const res = await this.api.put('/users/me', user);
return res.data as User;
};
@@ -121,4 +121,14 @@ export default class UserService extends APIService {
deleteSignupToken = async (tokenId: string) => {
await this.api.delete(`/signup-tokens/${tokenId}`);
};
+
+ sendEmailVerification = async () => {
+ const res = await this.api.post('/users/me/send-email-verification');
+ return res.data as User;
+ };
+
+ verifyEmail = async (token: string) => {
+ const res = await this.api.post('/users/me/verify-email', { token });
+ return res.data as User;
+ };
}
diff --git a/frontend/src/lib/types/application-configuration.type.ts b/frontend/src/lib/types/application-configuration.type.ts
index a45fd131..011fe5b7 100644
--- a/frontend/src/lib/types/application-configuration.type.ts
+++ b/frontend/src/lib/types/application-configuration.type.ts
@@ -7,6 +7,7 @@ export type AppConfig = {
allowUserSignups: 'disabled' | 'withToken' | 'open';
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
emailOneTimeAccessAsAdminEnabled: boolean;
+ emailVerificationEnabled: boolean;
ldapEnabled: boolean;
disableAnimations: boolean;
uiConfigDisabled: boolean;
diff --git a/frontend/src/lib/types/user.type.ts b/frontend/src/lib/types/user.type.ts
index 710a7d99..13c6f721 100644
--- a/frontend/src/lib/types/user.type.ts
+++ b/frontend/src/lib/types/user.type.ts
@@ -6,6 +6,7 @@ export type User = {
id: string;
username: string;
email: string | undefined;
+ emailVerified: boolean;
firstName: string;
lastName?: string;
displayName: string;
@@ -19,6 +20,11 @@ export type User = {
export type UserCreate = Omit;
-export type UserSignUp = Omit & {
+export type AccountUpdate = Omit
+
+export type UserSignUp = Omit<
+ UserCreate,
+ 'isAdmin' | 'disabled' | 'displayName' | 'emailVerified'
+> & {
token?: string;
};
diff --git a/frontend/src/lib/utils/redirection-util.ts b/frontend/src/lib/utils/redirection-util.ts
index 4240d83e..b6c878b0 100644
--- a/frontend/src/lib/utils/redirection-util.ts
+++ b/frontend/src/lib/utils/redirection-util.ts
@@ -2,7 +2,8 @@ import type { User } from '$lib/types/user.type';
// Returns the path to redirect to based on the current path and user authentication status
// If no redirect is needed, it returns null
-export function getAuthRedirectPath(path: string, user: User | null) {
+export function getAuthRedirectPath(url: URL, user: User | null) {
+ const path = url.pathname;
const isSignedIn = !!user;
const isAdmin = user?.isAdmin;
@@ -19,7 +20,8 @@ export function getAuthRedirectPath(path: string, user: User | null) {
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
- return '/login';
+ const redirect = url.pathname + url.search;
+ return `/login?redirect=${encodeURIComponent(redirect)}`;
}
if (isUnauthenticatedOnlyPath && isSignedIn) {
@@ -29,4 +31,6 @@ export function getAuthRedirectPath(path: string, user: User | null) {
if (isAdminPath && !isAdmin) {
return '/settings';
}
+
+ return null;
}
diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts
index 7f5a9015..d33bb849 100644
--- a/frontend/src/routes/+layout.ts
+++ b/frontend/src/routes/+layout.ts
@@ -24,7 +24,7 @@ export const load: LayoutLoad = async ({ url }) => {
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
- const redirectPath = getAuthRedirectPath(url.pathname, user);
+ const redirectPath = getAuthRedirectPath(url, user);
if (redirectPath) {
redirect(302, redirectPath);
}
diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte
index 3f318d8b..b5cd4232 100644
--- a/frontend/src/routes/settings/+layout.svelte
+++ b/frontend/src/routes/settings/+layout.svelte
@@ -1,8 +1,9 @@