1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 13:21:45 +00:00

feat: add support for email verification (#1223)

This commit is contained in:
Elias Schneider
2026-01-11 12:31:26 +01:00
committed by GitHub
parent e955118a6f
commit 1e7442f5df
60 changed files with 1452 additions and 700 deletions

View File

@@ -76,7 +76,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService) controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService) controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService) 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.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) 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.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, svc.versionService) controller.NewVersionController(apiGroup, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService) controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
// Add test controller in non-production environments // Add test controller in non-production environments
if !common.EnvConfig.AppEnv.IsProduction() { if !common.EnvConfig.AppEnv.IsProduction() {

View File

@@ -13,23 +13,25 @@ import (
) )
type services struct { type services struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
appImagesService *service.AppImagesService appImagesService *service.AppImagesService
emailService *service.EmailService emailService *service.EmailService
geoLiteService *service.GeoLiteService geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService auditLogService *service.AuditLogService
jwtService *service.JwtService jwtService *service.JwtService
webauthnService *service.WebAuthnService webauthnService *service.WebAuthnService
scimService *service.ScimService scimService *service.ScimService
userService *service.UserService userService *service.UserService
customClaimService *service.CustomClaimService customClaimService *service.CustomClaimService
oidcService *service.OidcService oidcService *service.OidcService
userGroupService *service.UserGroupService userGroupService *service.UserGroupService
ldapService *service.LdapService ldapService *service.LdapService
apiKeyService *service.ApiKeyService apiKeyService *service.ApiKeyService
versionService *service.VersionService versionService *service.VersionService
fileStorage storage.FileStorage fileStorage storage.FileStorage
appLockService *service.AppLockService appLockService *service.AppLockService
userSignUpService *service.UserSignUpService
oneTimeAccessService *service.OneTimeAccessService
} }
// Initializes all services // 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.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.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService) 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) svc.versionService = service.NewVersionService(httpClient)

View File

@@ -412,3 +412,13 @@ func (e *ImageNotFoundError) Error() string {
func (e *ImageNotFoundError) HttpStatusCode() int { func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound return http.StatusNotFound
} }
type InvalidEmailVerificationTokenError struct{}
func (e *InvalidEmailVerificationTokenError) Error() string {
return "Invalid email verification token"
}
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -14,19 +14,17 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
const ( const defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultSignupTokenDuration = time.Hour
)
// NewUserController creates a new controller for user management endpoints // NewUserController creates a new controller for user management endpoints
// @Summary User management controller // @Summary User management controller
// @Description Initializes all user-related API endpoints // @Description Initializes all user-related API endpoints
// @Tags Users // @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{ uc := UserController{
userService: userService, userService: userService,
appConfigService: appConfigService, oneTimeAccessService: oneTimeAccessService,
appConfigService: appConfigService,
} }
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler) 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/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler) group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler) group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
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)
} }
type UserController struct { type UserController struct {
userService *service.UserService userService *service.UserService
appConfigService *service.AppConfigService oneTimeAccessService *service.OneTimeAccessService
appConfigService *service.AppConfigService
} }
// getUserGroupsHandler godoc // getUserGroupsHandler godoc
@@ -342,7 +337,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
ttl = defaultOneTimeAccessTokenDuration 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 { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -391,7 +386,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
return 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 { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -424,7 +419,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
if ttl <= 0 { if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration ttl = defaultOneTimeAccessTokenDuration
} }
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl) err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -442,41 +437,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Router /api/one-time-access-token/{token} [post] // @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName) deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent()) 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
}
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)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -524,130 +485,6 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
c.JSON(http.StatusOK, userDto) 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 // updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto var input dto.UserCreateDto
@@ -714,3 +551,44 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
c.Status(http.StatusNoContent) 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)
}

View File

@@ -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)
}

View File

@@ -54,4 +54,5 @@ type AppConfigUpdateDto struct {
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"` EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"` EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
} }

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -4,35 +4,36 @@ import (
"errors" "errors"
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
"github.com/pocket-id/pocket-id/backend/internal/utils"
) )
type UserDto struct { type UserDto struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email *string `json:"email" ` Email *string `json:"email"`
FirstName string `json:"firstName"` EmailVerified bool `json:"emailVerified"`
LastName *string `json:"lastName"` FirstName string `json:"firstName"`
DisplayName string `json:"displayName"` LastName *string `json:"lastName"`
IsAdmin bool `json:"isAdmin"` DisplayName string `json:"displayName"`
Locale *string `json:"locale"` IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"` Locale *string `json:"locale"`
UserGroups []UserGroupMinimalDto `json:"userGroups"` CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"` UserGroups []UserGroupMinimalDto `json:"userGroups"`
Disabled bool `json:"disabled"` LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
} }
type UserCreateDto struct { type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` EmailVerified bool `json:"emailVerified"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"` DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
Locale *string `json:"locale"` IsAdmin bool `json:"isAdmin"`
Disabled bool `json:"disabled"` Locale *string `json:"locale"`
UserGroupIds []string `json:"userGroupIds"` Disabled bool `json:"disabled"`
LdapID string `json:"-"` UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
} }
func (u UserCreateDto) Validate() error { func (u UserCreateDto) Validate() error {
@@ -46,28 +47,10 @@ func (u UserCreateDto) Validate() error {
return e.Struct(u) return e.Struct(u)
} }
type OneTimeAccessTokenCreateDto struct { type EmailVerificationDto struct {
UserID string `json:"userId"` Token string `json:"token" binding:"required"`
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 UserUpdateUserGroupDto struct { type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"` 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"`
}

View File

@@ -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, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true), s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, 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, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true), s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true), s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
@@ -135,3 +136,16 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil 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
}

View File

@@ -59,6 +59,7 @@ type AppConfig struct {
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"` EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
// LDAP // LDAP
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"` LdapUrl AppConfigVariable `key:"ldapUrl"`

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -14,16 +14,17 @@ import (
type User struct { type User struct {
Base Base
Username string `sortable:"true"` Username string `sortable:"true"`
Email *string `sortable:"true"` Email *string `sortable:"true"`
FirstName string `sortable:"true"` EmailVerified bool `sortable:"true" filterable:"true"`
LastName string `sortable:"true"` FirstName string `sortable:"true"`
DisplayName string `sortable:"true"` LastName string `sortable:"true"`
IsAdmin bool `sortable:"true" filterable:"true"` DisplayName string `sortable:"true"`
Locale *string IsAdmin bool `sortable:"true" filterable:"true"`
LdapID *string Locale *string
Disabled bool `sortable:"true" filterable:"true"` LdapID *string
UpdatedAt *datatype.DateTime Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
CustomClaims []CustomClaim CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -93,13 +94,3 @@ func (u User) LastModified() time.Time {
} }
return u.CreatedAt.ToTime() return u.CreatedAt.ToTime()
} }
type OneTimeAccessToken struct {
Base
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -84,6 +84,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"}, EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"}, EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"}, EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
// LDAP // LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"}, LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{}, LdapUrl: model.AppConfigVariable{},

View File

@@ -80,23 +80,25 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{ Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
}, },
Username: "tim", Username: "tim",
Email: utils.Ptr("tim.cook@test.com"), Email: utils.Ptr("tim.cook@test.com"),
FirstName: "Tim", EmailVerified: true,
LastName: "Cook", FirstName: "Tim",
DisplayName: "Tim Cook", LastName: "Cook",
IsAdmin: true, DisplayName: "Tim Cook",
IsAdmin: true,
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
}, },
Username: "craig", Username: "craig",
Email: utils.Ptr("craig.federighi@test.com"), Email: utils.Ptr("craig.federighi@test.com"),
FirstName: "Craig", EmailVerified: false,
LastName: "Federighi", FirstName: "Craig",
DisplayName: "Craig Federighi", LastName: "Federighi",
IsAdmin: false, DisplayName: "Craig Federighi",
IsAdmin: false,
}, },
{ {
Base: model.Base{ 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{ keyValues := []model.KV{
{ {
Key: jwkutils.PrivateKeyDBKey, Key: jwkutils.PrivateKeyDBKey,

View File

@@ -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 { type NewLoginTemplateData struct {
IPAddress string IPAddress string
Country string Country string
@@ -70,5 +77,10 @@ type ApiKeyExpiringSoonTemplateData struct {
ExpiresAt time.Time ExpiresAt time.Time
} }
type EmailVerificationTemplateData struct {
UserFullName string
VerificationLink string
}
// this is list of all template paths used for preloading templates // 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}

View File

@@ -378,13 +378,14 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
} }
newUser := dto.UserCreateDto{ newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), EmailVerified: true,
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
IsAdmin: isAdmin, DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
LdapID: ldapId, IsAdmin: isAdmin,
LdapID: ldapId,
} }
if newUser.DisplayName == "" { if newUser.DisplayName == "" {

View File

@@ -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
}

View File

@@ -9,13 +9,11 @@ import (
"io" "io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/url"
"path" "path"
"strings"
"time" "time"
"github.com/google/uuid" "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"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -25,7 +23,6 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" 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/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "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" 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{ user := model.User{
FirstName: input.FirstName, FirstName: input.FirstName,
LastName: input.LastName, LastName: input.LastName,
DisplayName: input.DisplayName, DisplayName: input.DisplayName,
Email: input.Email, Email: input.Email,
Username: input.Username, EmailVerified: input.EmailVerified,
IsAdmin: input.IsAdmin, Username: input.Username,
Locale: input.Locale, IsAdmin: input.IsAdmin,
Disabled: input.Disabled, Locale: input.Locale,
UserGroups: userGroups, Disabled: input.Disabled,
UserGroups: userGroups,
} }
if input.LdapID != "" { if input.LdapID != "" {
user.LdapID = &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.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName user.DisplayName = updatedUser.DisplayName
user.Email = updatedUser.Email
user.Username = updatedUser.Username user.Username = updatedUser.Username
user.Locale = updatedUser.Locale 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 // Admin-only fields: Only allow updates when not updating own account
if !updateOwnUser { if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin user.IsAdmin = updatedUser.IsAdmin
user.EmailVerified = updatedUser.EmailVerified
user.Disabled = updatedUser.Disabled user.Disabled = updatedUser.Disabled
} }
} }
@@ -455,164 +460,6 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, nil 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) { func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroupIds []string) (user model.User, err error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
@@ -672,47 +519,6 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return user, nil 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 { func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
var result struct { var result struct {
Found bool Found bool
@@ -774,172 +580,72 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
return nil return nil
} }
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) { func (s *UserService) SendEmailVerification(ctx context.Context, userID string) error {
signupToken, err := NewSignupToken(ttl, usageLimit) user, err := s.GetUser(ctx, userID)
if err != nil { if err != nil {
return model.SignupToken{}, err return err
} }
var userGroups []model.UserGroup if user.Email == nil {
err = s.db.WithContext(ctx). return &common.UserEmailNotSetError{}
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 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() tx := s.db.Begin()
defer func() { defer tx.Rollback()
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 errors.Is(err, gorm.ErrRecordNotFound) {
if config.AllowUserSignups.Value != "open" && !tokenProvided { return &common.InvalidEmailVerificationTokenError{}
return model.User{}, "", &common.OpenSignupDisabledError{} } else if err != nil {
return err
} }
var signupToken model.SignupToken user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
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)
if err != nil { 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 { if err != nil {
return model.User{}, "", err return err
} }
if tokenProvided { err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error
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 { if err != nil {
return model.User{}, "", err return err
} }
return user, accessToken, nil return tx.Commit().Error
}
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
} }

View File

@@ -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
}

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table><!--/$--></body></html>{{end}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -6,7 +6,6 @@ API KEY EXPIRING SOON
Warning Warning
Hello {{.Data.Name}}, Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
Please generate a new API key if you need continued access.{{end}} Please generate a new API key if you need continued access.{{end}}

View File

@@ -0,0 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Email Verification</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.UserFullName}}<!-- -->, <br/>Click the button below to verify your email address for <!-- -->{{.AppName}}<!-- -->. This link will expire in 24 hours.<br/></p><div style="text-align:center"><a href="{{.Data.VerificationLink}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Verify</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -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}}

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--/$--></body></html>{{end}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -5,15 +5,13 @@ NEW SIGN-IN DETECTED
Warning Warning
Your {{.AppName}} account was recently accessed from a new IP address or Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
browser. If you recognize this activity, no further action is required.
DETAILS DETAILS
Approximate Location Approximate Location
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if {{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP Address IP Address

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--/$--></body></html>{{end}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -4,8 +4,7 @@
YOUR LOGIN CODE YOUR LOGIN CODE
Click the button below to sign in to {{.AppName}} with a 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 Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.
{{.Data.Code}}.
This code expires in {{.Data.ExpirationString}}. This code expires in {{.Data.ExpirationString}}.

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your email setup is working correctly!</p></div></td></tr></tbody></table><!--/$--></body></html>{{end}} {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your email setup is working correctly!</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -0,0 +1,2 @@
DROP TABLE email_verification_tokens;
ALTER TABLE users DROP COLUMN email_verified;

View File

@@ -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');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Email Verification" />
<Text>
Hello {data.userFullName}, <br />
Click the button below to verify your email address for {appName}. This
link will expire in 24 hours.
<br />
</Text>
<Button href={data.verificationLink}>Verify</Button>
</BaseTemplate>
);
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",
},
};

View File

@@ -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.", "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", "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.", "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_configuration_updated_successfully": "LDAP configuration updated successfully",
"ldap_disabled_successfully": "LDAP disabled successfully", "ldap_disabled_successfully": "LDAP disabled successfully",
"ldap_sync_finished": "LDAP sync finished", "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.", "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", "api_key_renewed": "API key renewed",
"app_config_home_page": "Home Page", "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."
} }

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { page } from '$app/state';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideAlertTriangle, LucideCheckCircle2, LucideCircleX } from '@lucide/svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { get } from 'svelte/store';
const userService = new UserService();
let emailVerificationState = $state(page.url.searchParams.get('emailVerificationState'));
async function sendEmailVerification() {
await userService
.sendEmailVerification()
.then(() => {
toast.success(m.email_verification_sent());
})
.catch(axiosErrorToast);
}
function onDismiss() {
const url = new URL(page.url);
url.searchParams.delete('emailVerificationState');
history.replaceState(null, '', url.toString());
emailVerificationState = null;
}
onMount(() => {
const user = get(userStore);
if (emailVerificationState === 'success' && user) {
user.emailVerified = true;
userStore.setUser(user);
}
});
</script>
{#if emailVerificationState}
{#if emailVerificationState === 'success'}
<Alert.Root variant="success" {onDismiss}>
<LucideCheckCircle2 class="size-4" />
<Alert.Title class="font-semibold">{m.email_verification_success_title()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_success_description()}
</Alert.Description>
</Alert.Root>
{:else}
<Alert.Root variant="destructive" {onDismiss}>
<LucideCircleX class="size-4" />
<Alert.Title class="font-semibold">{m.email_verification_error_title()}</Alert.Title>
<Alert.Description class="text-sm">
{emailVerificationState}
</Alert.Description>
</Alert.Root>
{/if}
{:else if $userStore && $appConfigStore.emailVerificationEnabled && !$userStore.emailVerified}
<Alert.Root variant="warning" class="flex gap-3">
<LucideAlertTriangle class="size-4" />
<div class="md:flex md:w-full md:place-content-between">
<div>
<Alert.Title class="font-semibold">{m.email_verification_warning()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_warning_description()}
</Alert.Description>
</div>
<div>
<Button class="mt-2 md:mt-0" usePromiseLoading onclick={sendEmailVerification}>
{m.send_email()}
</Button>
</div>
</div>
</Alert.Root>
{/if}

View File

@@ -31,6 +31,7 @@
children, children,
onInput, onInput,
labelFor, labelFor,
inputClass,
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & }: HTMLAttributes<HTMLDivElement> &
(WithChildren | WithoutChildren) & { (WithChildren | WithoutChildren) & {
@@ -39,6 +40,7 @@
docsLink?: string; docsLink?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
inputClass?: string;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date'; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
onInput?: (e: FormInputEvent) => void; onInput?: (e: FormInputEvent) => void;
} = $props(); } = $props();
@@ -73,6 +75,7 @@
{:else} {:else}
<Input <Input
aria-invalid={!!input.error} aria-invalid={!!input.error}
class={inputClass}
{id} {id}
{placeholder} {placeholder}
{type} {type}

View File

@@ -6,9 +6,11 @@
variants: { variants: {
variant: { variant: {
default: 'bg-card text-card-foreground', default: 'bg-card text-card-foreground',
success:
'bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100 [&>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', 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: 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: warning:
'bg-warning text-warning-foreground border-warning/40 [&>svg]:text-warning-foreground' 'bg-warning text-warning-foreground border-warning/40 [&>svg]:text-warning-foreground'
} }
@@ -32,10 +34,12 @@
class: className, class: className,
variant = 'default', variant = 'default',
children, children,
onDismiss,
dismissibleId = undefined, dismissibleId = undefined,
...restProps ...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant; variant?: AlertVariant;
onDismiss?: () => void;
dismissibleId?: string; dismissibleId?: string;
} = $props(); } = $props();
@@ -49,6 +53,7 @@
}); });
function dismiss() { function dismiss() {
onDismiss?.();
if (dismissibleId) { if (dismissibleId) {
const dismissedAlerts = JSON.parse(localStorage.getItem('dismissed-alerts') || '[]'); const dismissedAlerts = JSON.parse(localStorage.getItem('dismissed-alerts') || '[]');
localStorage.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId])); localStorage.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId]));
@@ -66,7 +71,7 @@
role="alert" role="alert"
> >
{@render children?.()} {@render children?.()}
{#if dismissibleId} {#if dismissibleId || onDismiss}
<button onclick={dismiss} class="absolute right-0 top-0 m-3 text-black dark:text-white" <button onclick={dismiss} class="absolute right-0 top-0 m-3 text-black dark:text-white"
><LucideX class="size-4" /></button ><LucideX class="size-4" /></button
> >

View File

@@ -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,
};

View File

@@ -0,0 +1,52 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const toggleVariants = tv({
base: "hover:bg-muted hover:text-muted-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-transparent",
outline:
"border-input hover:bg-accent hover:text-accent-foreground border bg-transparent shadow-xs",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ToggleVariant = VariantProps<typeof toggleVariants>["variant"];
export type ToggleSize = VariantProps<typeof toggleVariants>["size"];
export type ToggleVariants = VariantProps<typeof toggleVariants>;
</script>
<script lang="ts">
import { Toggle as TogglePrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
let {
ref = $bindable(null),
pressed = $bindable(false),
class: className,
size = "default",
variant = "default",
...restProps
}: TogglePrimitive.RootProps & {
variant?: ToggleVariant;
size?: ToggleSize;
} = $props();
</script>
<TogglePrimitive.Root
bind:ref
bind:pressed
data-slot="toggle"
class={cn(toggleVariants({ variant, size }), className)}
{...restProps}
/>

View File

@@ -2,7 +2,7 @@ import userStore from '$lib/stores/user-store';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type { SignupToken } from '$lib/types/signup-token.type'; import type { SignupToken } from '$lib/types/signup-token.type';
import type { UserGroup } from '$lib/types/user-group.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 { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import APIService from './api-service'; import APIService from './api-service';
@@ -38,7 +38,7 @@ export default class UserService extends APIService {
return res.data as User; return res.data as User;
}; };
updateCurrent = async (user: UserCreate) => { updateCurrent = async (user: AccountUpdate) => {
const res = await this.api.put('/users/me', user); const res = await this.api.put('/users/me', user);
return res.data as User; return res.data as User;
}; };
@@ -121,4 +121,14 @@ export default class UserService extends APIService {
deleteSignupToken = async (tokenId: string) => { deleteSignupToken = async (tokenId: string) => {
await this.api.delete(`/signup-tokens/${tokenId}`); 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;
};
} }

View File

@@ -7,6 +7,7 @@ export type AppConfig = {
allowUserSignups: 'disabled' | 'withToken' | 'open'; allowUserSignups: 'disabled' | 'withToken' | 'open';
emailOneTimeAccessAsUnauthenticatedEnabled: boolean; emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
emailOneTimeAccessAsAdminEnabled: boolean; emailOneTimeAccessAsAdminEnabled: boolean;
emailVerificationEnabled: boolean;
ldapEnabled: boolean; ldapEnabled: boolean;
disableAnimations: boolean; disableAnimations: boolean;
uiConfigDisabled: boolean; uiConfigDisabled: boolean;

View File

@@ -6,6 +6,7 @@ export type User = {
id: string; id: string;
username: string; username: string;
email: string | undefined; email: string | undefined;
emailVerified: boolean;
firstName: string; firstName: string;
lastName?: string; lastName?: string;
displayName: string; displayName: string;
@@ -19,6 +20,11 @@ export type User = {
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>; export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled' | 'displayName'> & { export type AccountUpdate = Omit<UserCreate, 'isAdmin' | 'disabled' | 'emailVerified'>
export type UserSignUp = Omit<
UserCreate,
'isAdmin' | 'disabled' | 'displayName' | 'emailVerified'
> & {
token?: string; token?: string;
}; };

View File

@@ -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 // Returns the path to redirect to based on the current path and user authentication status
// If no redirect is needed, it returns null // 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 isSignedIn = !!user;
const isAdmin = user?.isAdmin; 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/'); const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) { if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
return '/login'; const redirect = url.pathname + url.search;
return `/login?redirect=${encodeURIComponent(redirect)}`;
} }
if (isUnauthenticatedOnlyPath && isSignedIn) { if (isUnauthenticatedOnlyPath && isSignedIn) {
@@ -29,4 +31,6 @@ export function getAuthRedirectPath(path: string, user: User | null) {
if (isAdminPath && !isAdmin) { if (isAdminPath && !isAdmin) {
return '/settings'; return '/settings';
} }
return null;
} }

View File

@@ -24,7 +24,7 @@ export const load: LayoutLoad = async ({ url }) => {
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]); const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
const redirectPath = getAuthRedirectPath(url.pathname, user); const redirectPath = getAuthRedirectPath(url, user);
if (redirectPath) { if (redirectPath) {
redirect(302, redirectPath); redirect(302, redirectPath);
} }

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import EmailVerificationStateBox from '$lib/components/email-verification-state-box.svelte';
import FadeWrapper from '$lib/components/fade-wrapper.svelte'; import FadeWrapper from '$lib/components/fade-wrapper.svelte';
import Sidebar from '$lib/components/sidebar.svelte';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import Sidebar from '$lib/components/sidebar.svelte';
import { LucideSettings } from '@lucide/svelte'; import { LucideSettings } from '@lucide/svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
@@ -71,6 +72,7 @@
<div class="flex w-full flex-col gap-4 overflow-hidden"> <div class="flex w-full flex-col gap-4 overflow-hidden">
<FadeWrapper> <FadeWrapper>
<EmailVerificationStateBox />
{@render children()} {@render children()}
</FadeWrapper> </FadeWrapper>
</div> </div>

View File

@@ -1,8 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import appConfig from '$lib/stores/application-configuration-store'; import appConfig from '$lib/stores/application-configuration-store';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => { export const load: PageLoad = async () => {
throw redirect(307, get(appConfig).homePageUrl); throw redirect(307, get(appConfig)?.homePageUrl ?? '/');
}; };

View File

@@ -8,8 +8,9 @@
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { Passkey } from '$lib/types/passkey.type'; import type { Passkey } from '$lib/types/passkey.type';
import type { UserCreate } from '$lib/types/user.type'; import type { AccountUpdate, UserCreate } from '$lib/types/user.type';
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util'; import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
import { import {
KeyRound, KeyRound,
@@ -39,11 +40,14 @@
!$appConfigStore.allowOwnAccountEdit || (!!account.ldapId && $appConfigStore.ldapEnabled) !$appConfigStore.allowOwnAccountEdit || (!!account.ldapId && $appConfigStore.ldapEnabled)
); );
async function updateAccount(user: UserCreate) { async function updateAccount(user: AccountUpdate) {
let success = true; let success = true;
await userService await userService
.updateCurrent(user) .updateCurrent(user)
.then(() => toast.success(m.account_details_updated_successfully())) .then((user) => {
toast.success(m.account_details_updated_successfully());
userStore.setUser(user);
})
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
success = false; success = false;

View File

@@ -6,7 +6,7 @@
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserCreate } from '$lib/types/user.type'; import type { AccountUpdate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util'; import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
@@ -22,9 +22,9 @@
isLdapUser = false, isLdapUser = false,
userInfoInputDisabled = false userInfoInputDisabled = false
}: { }: {
account: UserCreate; account: AccountUpdate;
userId: string; userId: string;
callback: (user: UserCreate) => Promise<boolean>; callback: (user: AccountUpdate) => Promise<boolean>;
isLdapUser?: boolean; isLdapUser?: boolean;
userInfoInputDisabled?: boolean; userInfoInputDisabled?: boolean;
} = $props(); } = $props();
@@ -39,10 +39,7 @@
lastName: emptyToUndefined(z.string().max(50).optional()), lastName: emptyToUndefined(z.string().max(50).optional()),
displayName: z.string().min(1).max(100), displayName: z.string().min(1).max(100),
username: usernameSchema, username: usernameSchema,
email: get(appConfigStore).requireUserEmail email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
? z.email()
: emptyToUndefined(z.email().optional()),
isAdmin: z.boolean()
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;

View File

@@ -34,6 +34,7 @@
const formSchema = z const formSchema = z
.object({ .object({
requireUserEmail: z.boolean(), requireUserEmail: z.boolean(),
emailsVerified: z.boolean(),
smtpHost: z.string().optional(), smtpHost: z.string().optional(),
smtpPort: z smtpPort: z
.preprocess((v: string) => (!v ? undefined : parseInt(v)), z.number().optional().nullable()) .preprocess((v: string) => (!v ? undefined : parseInt(v)), z.number().optional().nullable())
@@ -45,6 +46,7 @@
smtpTls: z.enum(['none', 'starttls', 'tls']), smtpTls: z.enum(['none', 'starttls', 'tls']),
smtpSkipCertVerify: z.boolean(), smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(), emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),
emailVerificationEnabled: z.boolean(),
emailOneTimeAccessAsAdminEnabled: z.boolean(), emailOneTimeAccessAsAdminEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean(), emailLoginNotificationEnabled: z.boolean(),
emailApiKeyExpirationEnabled: z.boolean() emailApiKeyExpirationEnabled: z.boolean()
@@ -58,6 +60,7 @@
const emailFields: (keyof z.infer<typeof formSchema>)[] = [ const emailFields: (keyof z.infer<typeof formSchema>)[] = [
'emailOneTimeAccessAsUnauthenticatedEnabled', 'emailOneTimeAccessAsUnauthenticatedEnabled',
'emailVerificationEnabled',
'emailOneTimeAccessAsAdminEnabled', 'emailOneTimeAccessAsAdminEnabled',
'emailLoginNotificationEnabled', 'emailLoginNotificationEnabled',
'emailApiKeyExpirationEnabled' 'emailApiKeyExpirationEnabled'
@@ -137,12 +140,20 @@
<form onsubmit={preventDefault(onSubmit)}> <form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={$appConfigStore.uiConfigDisabled}> <fieldset disabled={$appConfigStore.uiConfigDisabled}>
<h4 class="mb-4 text-lg font-semibold">{m.general()}</h4> <h4 class="mb-4 text-lg font-semibold">{m.general()}</h4>
<SwitchWithLabel <div class="flex flex-col gap-5">
id="require-user-email" <SwitchWithLabel
label={m.require_user_email()} id="require-user-email"
description={m.require_user_email_description()} label={m.require_user_email()}
bind:checked={$inputs.requireUserEmail.value} description={m.require_user_email_description()}
/> bind:checked={$inputs.requireUserEmail.value}
/>
<SwitchWithLabel
id="emails-verified-by-default"
label={m.emails_verified_by_default()}
description={m.emails_verified_by_default_description()}
bind:checked={$inputs.emailsVerified.value}
/>
</div>
<h4 class="mt-10 text-lg font-semibold">{m.smtp_configuration()}</h4> <h4 class="mt-10 text-lg font-semibold">{m.smtp_configuration()}</h4>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label={m.smtp_host()} bind:input={$inputs.smtpHost} /> <FormInput label={m.smtp_host()} bind:input={$inputs.smtpHost} />
@@ -182,7 +193,12 @@
description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()} description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()}
bind:checked={$inputs.emailLoginNotificationEnabled.value} bind:checked={$inputs.emailLoginNotificationEnabled.value}
/> />
<SwitchWithLabel
id="email-verification"
label={m.email_verification()}
description={m.email_verification_description()}
bind:checked={$inputs.emailVerificationEnabled.value}
/>
<SwitchWithLabel <SwitchWithLabel
id="email-login-admin" id="email-login-admin"
label={m.email_login_code_from_admin()} label={m.email_login_code_from_admin()}

View File

@@ -32,7 +32,6 @@
appName: appConfig.appName, appName: appConfig.appName,
homePageUrl: appConfig.homePageUrl, homePageUrl: appConfig.homePageUrl,
sessionDuration: appConfig.sessionDuration, sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit, allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
disableAnimations: appConfig.disableAnimations, disableAnimations: appConfig.disableAnimations,
accentColor: appConfig.accentColor accentColor: appConfig.accentColor
@@ -42,7 +41,6 @@
appName: z.string().min(2).max(30), appName: z.string().min(2).max(30),
homePageUrl: z.string(), homePageUrl: z.string(),
sessionDuration: z.number().min(1).max(43200), sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean(), allowOwnAccountEdit: z.boolean(),
disableAnimations: z.boolean(), disableAnimations: z.boolean(),
accentColor: z.string() accentColor: z.string()
@@ -80,10 +78,7 @@
value={$inputs.homePageUrl.value} value={$inputs.homePageUrl.value}
onValueChange={(v) => ($inputs.homePageUrl.value = v as string)} onValueChange={(v) => ($inputs.homePageUrl.value = v as string)}
> >
<Select.Trigger <Select.Trigger class="w-full" aria-label={m.app_config_home_page()}>
class="w-full"
aria-label={m.app_config_home_page()}
>
{homePageUrlOptions.find((option) => option.value === $inputs.homePageUrl.value) {homePageUrlOptions.find((option) => option.value === $inputs.homePageUrl.value)
?.label ?? $inputs.homePageUrl.value} ?.label ?? $inputs.homePageUrl.value}
</Select.Trigger> </Select.Trigger>
@@ -102,12 +97,6 @@
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()} description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value} bind:checked={$inputs.allowOwnAccountEdit.value}
/> />
<SwitchWithLabel
id="emails-verified"
label={m.emails_verified()}
description={m.whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients()}
bind:checked={$inputs.emailsVerified.value}
/>
<SwitchWithLabel <SwitchWithLabel
id="disable-animations" id="disable-animations"
label={m.disable_animations()} label={m.disable_animations()}

View File

@@ -12,9 +12,12 @@
import { LucideMinus, UserPen, UserPlus } from '@lucide/svelte'; import { LucideMinus, UserPen, UserPlus } from '@lucide/svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import type { PageProps } from './$types';
import UserForm from './user-form.svelte'; import UserForm from './user-form.svelte';
import UserList from './user-list.svelte'; import UserList from './user-list.svelte';
let { data }: PageProps = $props();
let selectedCreateOptions = $state(m.add_user()); let selectedCreateOptions = $state(m.add_user());
let expandAddUser = $state(false); let expandAddUser = $state(false);
let signupTokenModalOpen = $state(false); let signupTokenModalOpen = $state(false);
@@ -91,7 +94,10 @@
{#if expandAddUser} {#if expandAddUser}
<div transition:slide> <div transition:slide>
<Card.Content> <Card.Content>
<UserForm callback={createUser} /> <UserForm
callback={createUser}
emailsVerifiedPerDefault={data.emailsVerifiedPerDefault}
/>
</Card.Content> </Card.Content>
</div> </div>
{/if} {/if}

View File

@@ -0,0 +1,12 @@
import AppConfigService from '$lib/services/app-config-service';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const appConfigService = new AppConfigService();
const appConfigData = await appConfigService.list(true);
return {
emailsVerifiedPerDefault: appConfigData.emailsVerified
};
};

View File

@@ -2,20 +2,25 @@
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte'; import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Toggle } from '$lib/components/ui/toggle';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util'; import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util'; import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { LucideMailCheck, LucideMailWarning } from '@lucide/svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
let { let {
callback, callback,
existingUser existingUser,
emailsVerifiedPerDefault = false
}: { }: {
existingUser?: User; existingUser?: User;
emailsVerifiedPerDefault?: boolean;
callback: (user: UserCreate) => Promise<boolean>; callback: (user: UserCreate) => Promise<boolean>;
} = $props(); } = $props();
@@ -28,6 +33,7 @@
lastName: existingUser?.lastName || '', lastName: existingUser?.lastName || '',
displayName: existingUser?.displayName || '', displayName: existingUser?.displayName || '',
email: existingUser?.email || '', email: existingUser?.email || '',
emailVerified: existingUser?.emailVerified ?? emailsVerifiedPerDefault,
username: existingUser?.username || '', username: existingUser?.username || '',
isAdmin: existingUser?.isAdmin || false, isAdmin: existingUser?.isAdmin || false,
disabled: existingUser?.disabled || false disabled: existingUser?.disabled || false
@@ -41,6 +47,7 @@
email: get(appConfigStore).requireUserEmail email: get(appConfigStore).requireUserEmail
? z.email() ? z.email()
: emptyToUndefined(z.email().optional()), : emptyToUndefined(z.email().optional()),
emailVerified: z.boolean(),
isAdmin: z.boolean(), isAdmin: z.boolean(),
disabled: z.boolean() disabled: z.boolean()
}); });
@@ -76,7 +83,34 @@
bind:input={$inputs.displayName} bind:input={$inputs.displayName}
/> />
<FormInput label={m.username()} bind:input={$inputs.username} /> <FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label={m.email()} bind:input={$inputs.email} /> <div class="flex items-end">
<FormInput
inputClass="rounded-r-none border-r-0"
label={m.email()}
bind:input={$inputs.email}
/>
<Tooltip.Provider>
{@const label = $inputs.emailVerified.value
? m.mark_as_unverified()
: m.mark_as_verified()}
<Tooltip.Root>
<Tooltip.Trigger>
<Toggle
bind:pressed={$inputs.emailVerified.value}
aria-label={label}
class="h-9 border-input bg-yellow-100 dark:bg-yellow-950 data-[state=on]:bg-green-100 dark:data-[state=on]:bg-green-950 rounded-l-none border px-2 py-1 shadow-xs flex items-center hover:data-[state=on]:bg-accent"
>
{#if $inputs.emailVerified.value}
<LucideMailCheck class="text-green-500 dark:text-green-600 size-5" />
{:else}
<LucideMailWarning class="text-yellow-500 dark:text-yellow-600 size-5" />
{/if}
</Toggle>
</Tooltip.Trigger>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
</div> </div>
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<SwitchWithLabel <SwitchWithLabel

View File

@@ -0,0 +1,22 @@
import UserService from '$lib/services/user-service';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const userService = new UserService();
const token = url.searchParams.get('token');
const searchParams = new URLSearchParams();
await userService
.verifyEmail(token!)
.then(() => {
searchParams.set('emailVerificationState', 'success');
})
.catch((e) => {
searchParams.set('emailVerificationState', getAxiosErrorMessage(e));
});
return redirect(302, '/settings/account?' + searchParams.toString());
};

View File

@@ -92,6 +92,11 @@ export const oneTimeAccessTokens = [
{ token: 'YCGDtftvsvYWiXd0', expired: true } { token: 'YCGDtftvsvYWiXd0', expired: true }
]; ];
export const emailVerificationTokens = [
{ token: '2FZFSoupBdHyqIL65bWTsgCgHIhxlXup', expired: false },
{ token: 'EXPIRED1234567890ABCDE', expired: true }
];
export const apiKeys = [ export const apiKeys = [
{ {
id: '5f1fa856-c164-4295-961e-175a0d22d725', id: '5f1fa856-c164-4295-961e-175a0d22d725',

View File

@@ -1,6 +1,6 @@
{ {
"provider": "sqlite", "provider": "sqlite",
"version": 20260106140900, "version": 20260109090200,
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"], "tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
"tables": { "tables": {
"api_keys": [ "api_keys": [
@@ -301,6 +301,7 @@
"disabled": false, "disabled": false,
"display_name": "Tim Cook", "display_name": "Tim Cook",
"email": "tim.cook@test.com", "email": "tim.cook@test.com",
"email_verified": true,
"first_name": "Tim", "first_name": "Tim",
"id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", "id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"is_admin": true, "is_admin": true,
@@ -315,6 +316,7 @@
"disabled": false, "disabled": false,
"display_name": "Craig Federighi", "display_name": "Craig Federighi",
"email": "craig.federighi@test.com", "email": "craig.federighi@test.com",
"email_verified": false,
"first_name": "Craig", "first_name": "Craig",
"id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", "id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
"is_admin": false, "is_admin": false,
@@ -329,6 +331,7 @@
"disabled": false, "disabled": false,
"display_name": "Eddy Cue", "display_name": "Eddy Cue",
"email": "eddy.cue@test.com", "email": "eddy.cue@test.com",
"email_verified": false,
"first_name": "Eddy", "first_name": "Eddy",
"id": "d9256384-98ad-49a7-bc58-99ad0b4dc23c", "id": "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
"is_admin": false, "is_admin": false,
@@ -373,6 +376,22 @@
"id": "267f6907-7bc8-4ea1-9d47-c42a172dc1c7", "id": "267f6907-7bc8-4ea1-9d47-c42a172dc1c7",
"user_verification": "preferred" "user_verification": "preferred"
} }
],
"email_verification_tokens": [
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-26T12:39:02Z",
"id": "ef9ca469-b178-4857-bd39-26639dca45de",
"token": "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup",
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
},
{
"created_at": "2025-11-24T12:39:02Z",
"expires_at": "2025-11-25T12:39:02Z",
"id": "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00",
"token": "EXPIRED1234567890ABCDE",
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
}
] ]
} }
} }

View File

@@ -1,5 +1,5 @@
import test, { expect } from '@playwright/test'; import test, { expect } from '@playwright/test';
import { users } from '../data'; import { emailVerificationTokens, users } from '../data';
import authUtil from '../utils/auth.util'; import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util'; import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util'; import passkeyUtil from '../utils/passkey.util';
@@ -128,3 +128,31 @@ test('Generate own one time access token as non admin', async ({ page, context }
await page.goto(link!); await page.goto(link!);
await page.waitForURL('/settings/account'); await page.waitForURL('/settings/account');
}); });
test('Email verification succeeds', async ({ page, context }) => {
await context.clearCookies();
const token = emailVerificationTokens.find((t) => !t.expired)!.token;
await page.goto(`/verify-email?token=${token}`);
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Authenticate' }).click();
await page.waitForURL('/settings/account?emailVerificationState=success');
await expect(page.getByText('Email Verified Successfully')).toBeVisible();
});
test('Email verification fails with expired token', async ({ page, context }) => {
await context.clearCookies();
const token = emailVerificationTokens.find((t) => t.expired)!.token;
await page.goto(`/verify-email?token=${token}`);
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Authenticate' }).click();
await page.waitForURL(
'/settings/account?emailVerificationState=Invalid+email+verification+token'
);
await expect(page.getByText('Invalid email verification token')).toBeVisible();
});

View File

@@ -113,7 +113,7 @@ test('End session without id token hint shows confirmation page', async ({ page
await expect(page).toHaveURL('/logout'); await expect(page).toHaveURL('/logout');
await page.getByRole('button', { name: 'Sign out' }).click(); await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page).toHaveURL('/login'); await expect(page).toHaveURL('/login?redirect=%2F');
}); });
test('End session with id token hint redirects to callback URL', async ({ page }) => { test('End session with id token hint redirects to callback URL', async ({ page }) => {