mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-23 09:15:13 +03:00
feat: self-service user signup (#672)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -349,3 +349,13 @@ func (e *OidcAuthorizationPendingError) Error() string {
|
|||||||
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenSignupDisabledError struct{}
|
||||||
|
|
||||||
|
func (e *OpenSignupDisabledError) Error() string {
|
||||||
|
return "Open user signup is not enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *OpenSignupDisabledError) HttpStatusCode() int {
|
||||||
|
return http.StatusForbidden
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ 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.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
|
||||||
|
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
|
||||||
|
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
@@ -495,6 +501,128 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), input.ExpiresAt, input.UsageLimit)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenDto dto.SignupTokenDto
|
||||||
|
if err := dto.MapStruct(signupToken, &tokenDto); 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) {
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
|
||||||
|
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 := c.ShouldBindJSON(&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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type AppConfigUpdateDto struct {
|
|||||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||||
|
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||||
AccentColor string `json:"accentColor"`
|
AccentColor string `json:"accentColor"`
|
||||||
SmtpHost string `json:"smtpHost"`
|
SmtpHost string `json:"smtpHost"`
|
||||||
SmtpPort string `json:"smtpPort"`
|
SmtpPort string `json:"smtpPort"`
|
||||||
|
|||||||
21
backend/internal/dto/signup_token_dto.go
Normal file
21
backend/internal/dto/signup_token_dto.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupTokenCreateDto struct {
|
||||||
|
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||||
|
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignupTokenDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
|
UsageLimit int `json:"usageLimit"`
|
||||||
|
UsageCount int `json:"usageCount"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
}
|
||||||
@@ -44,3 +44,11 @@ type OneTimeAccessEmailAsAdminDto struct {
|
|||||||
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"`
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||||
|
LastName string `json:"lastName" binding:"max=50"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
|||||||
return errors.Join(
|
return errors.Join(
|
||||||
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, "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, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||||
@@ -60,6 +61,21 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearSignupTokens deletes signup tokens that have expired
|
||||||
|
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
|
||||||
|
// Delete tokens that are expired OR have reached their usage limit
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
||||||
st := j.db.
|
st := j.db.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type AppConfig struct {
|
|||||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||||
|
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
||||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
|
|||||||
const (
|
const (
|
||||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||||
|
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
|
||||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||||
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
|
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
|
||||||
|
|||||||
28
backend/internal/model/signup_token.go
Normal file
28
backend/internal/model/signup_token.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupToken struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||||
|
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||||
|
UsageCount int `json:"usageCount" sortable:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *SignupToken) IsExpired() bool {
|
||||||
|
return time.Time(st.ExpiresAt).Before(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *SignupToken) IsUsageLimitReached() bool {
|
||||||
|
return st.UsageCount >= st.UsageLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *SignupToken) IsValid() bool {
|
||||||
|
return !st.IsExpired() && !st.IsUsageLimitReached()
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||||
|
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
||||||
|
|||||||
@@ -310,6 +310,50 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signupTokens := []model.SignupToken{
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
},
|
||||||
|
Token: "VALID1234567890A",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||||
|
UsageLimit: 1,
|
||||||
|
UsageCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "b2c3d4e5-f6g7-8901-bcde-f12345678901",
|
||||||
|
},
|
||||||
|
Token: "PARTIAL567890ABC",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
|
||||||
|
UsageLimit: 5,
|
||||||
|
UsageCount: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "c3d4e5f6-g7h8-9012-cdef-123456789012",
|
||||||
|
},
|
||||||
|
Token: "EXPIRED34567890B",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
|
||||||
|
UsageLimit: 3,
|
||||||
|
UsageCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "d4e5f6g7-h8i9-0123-def0-234567890123",
|
||||||
|
},
|
||||||
|
Token: "FULLYUSED567890C",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||||
|
UsageLimit: 1,
|
||||||
|
UsageCount: 1, // Usage limit reached
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, token := range signupTokens {
|
||||||
|
if err := tx.Create(&token).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -636,6 +636,110 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
|
|||||||
Error
|
Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserService) CreateSignupToken(ctx context.Context, expiresAt time.Time, usageLimit int) (model.SignupToken, error) {
|
||||||
|
return s.createSignupTokenInternal(ctx, expiresAt, usageLimit, s.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) createSignupTokenInternal(ctx context.Context, expiresAt time.Time, usageLimit int, tx *gorm.DB) (model.SignupToken, error) {
|
||||||
|
signupToken, err := NewSignupToken(expiresAt, usageLimit)
|
||||||
|
if err != nil {
|
||||||
|
return model.SignupToken{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.WithContext(ctx).Create(signupToken).Error; err != nil {
|
||||||
|
return model.SignupToken{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return *signupToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) 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
|
||||||
|
if tokenProvided {
|
||||||
|
err := tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Where("token = ?", signupData.Token).
|
||||||
|
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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userToCreate := dto.UserCreateDto{
|
||||||
|
Username: signupData.Username,
|
||||||
|
Email: signupData.Email,
|
||||||
|
FirstName: signupData.FirstName,
|
||||||
|
LastName: signupData.LastName,
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.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 *UserService) ListSignupTokens(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.SignupToken, utils.PaginationResponse, error) {
|
||||||
|
var tokens []model.SignupToken
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
|
||||||
|
|
||||||
|
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, 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, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
|
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
|
||||||
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
||||||
tokenLength := 16
|
tokenLength := 16
|
||||||
@@ -656,3 +760,20 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
|
|||||||
|
|
||||||
return o, nil
|
return o, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewSignupToken(expiresAt time.Time, usageLimit int) (*model.SignupToken, error) {
|
||||||
|
// Generate a random token
|
||||||
|
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token := &model.SignupToken{
|
||||||
|
Token: randomString,
|
||||||
|
ExpiresAt: datatype.DateTime(expiresAt),
|
||||||
|
UsageLimit: usageLimit,
|
||||||
|
UsageCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_signup_tokens_expires_at;
|
||||||
|
DROP INDEX IF EXISTS idx_signup_tokens_token;
|
||||||
|
DROP TABLE IF EXISTS signup_tokens;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE signup_tokens (
|
||||||
|
id UUID NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
|
||||||
|
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_signup_tokens_expires_at;
|
||||||
|
DROP INDEX IF EXISTS idx_signup_tokens_token;
|
||||||
|
DROP TABLE IF EXISTS signup_tokens;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE signup_tokens (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
|
||||||
|
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
|
||||||
"sign_in_to_appname": "Sign in to {appName}",
|
"sign_in_to_appname": "Sign in to {appName}",
|
||||||
"please_try_to_sign_in_again": "Please try to sign in again.",
|
"please_try_to_sign_in_again": "Please try to sign in again.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
|
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||||
"authenticate": "Authenticate",
|
"authenticate": "Authenticate",
|
||||||
"appname_setup": "{appName} Setup",
|
"appname_setup": "{appName} Setup",
|
||||||
"please_try_again": "Please try again.",
|
"please_try_again": "Please try again.",
|
||||||
@@ -379,5 +379,43 @@
|
|||||||
"custom_accent_color": "Custom Accent Color",
|
"custom_accent_color": "Custom Accent Color",
|
||||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||||
"color_value": "Color Value",
|
"color_value": "Color Value",
|
||||||
"apply": "Apply"
|
"apply": "Apply",
|
||||||
|
"signup_token": "Signup Token",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||||
|
"usage_limit": "Usage Limit",
|
||||||
|
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||||
|
"expires": "Expires",
|
||||||
|
"signup": "Sign Up",
|
||||||
|
"signup_requires_valid_token": "A valid signup token is required to create an account.",
|
||||||
|
"validating_signup_token": "Validating signup token",
|
||||||
|
"go_to_login": "Go to login",
|
||||||
|
"signup_to_appname": "Sign Up to {appName}",
|
||||||
|
"create_your_account_to_get_started": "Create your account to get started.",
|
||||||
|
"setup_your_passkey": "Set up your passkey",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||||
|
"skip_for_now": "Skip for now",
|
||||||
|
"account_created": "Account Created",
|
||||||
|
"enable_user_signups": "Enable User Signups",
|
||||||
|
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
|
||||||
|
"user_signups_are_disabled": "User signups are currently disabled.",
|
||||||
|
"create_signup_token": "Create Signup Token",
|
||||||
|
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||||
|
"manage_signup_tokens": "Manage Signup Tokens",
|
||||||
|
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||||
|
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||||
|
"expired": "Expired",
|
||||||
|
"used_up": "Used Up",
|
||||||
|
"active": "Active",
|
||||||
|
"usage": "Usage",
|
||||||
|
"created": "Created",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Loading",
|
||||||
|
"delete_signup_token": "Delete Signup Token",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
|
||||||
|
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
|
||||||
|
"signup_with_token": "Signup with token",
|
||||||
|
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
|
||||||
|
"signup_open": "Open Signup",
|
||||||
|
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||||
|
"of": "of"
|
||||||
}
|
}
|
||||||
|
|||||||
43
frontend/package-lock.json
generated
43
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "1.3.1",
|
"version": "1.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "1.3.1",
|
"version": "1.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.1.0",
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@inlang/plugin-m-function-matcher": "^2.0.10",
|
"@inlang/plugin-m-function-matcher": "^2.0.10",
|
||||||
"@inlang/plugin-message-format": "^4.0.0",
|
"@inlang/plugin-message-format": "^4.0.0",
|
||||||
"@internationalized/date": "^3.8.2",
|
"@internationalized/date": "^3.8.2",
|
||||||
"@lucide/svelte": "^0.513.0",
|
"@lucide/svelte": "^0.522.0",
|
||||||
"@playwright/test": "^1.50.0",
|
"@playwright/test": "^1.50.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.20.7",
|
"@sveltejs/kit": "^2.20.7",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^22.10.10",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"bits-ui": "^2.5.0",
|
"bits-ui": "^2.8.8",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
@@ -920,9 +920,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@lucide/svelte": {
|
"node_modules/@lucide/svelte": {
|
||||||
"version": "0.513.0",
|
"version": "0.522.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.513.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.522.0.tgz",
|
||||||
"integrity": "sha512-XwBQMQkMlr9qp9yVg+epx5MzhBBrqul8atO00y/ZfhlKRJuQZVmq3ELibApqyBtj9ys0Ai4FH/SZcODTUFYXig==",
|
"integrity": "sha512-Q10WkheTfb0sZ6y+EQVTqW4DN8nD2mym00gVpinbe3dJWRW3/R7SeTGF9pW+Ux4PrzkNV9dUdUTWcbO/AdKwQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2078,23 +2078,21 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/bits-ui": {
|
"node_modules/bits-ui": {
|
||||||
"version": "2.5.0",
|
"version": "2.8.8",
|
||||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.8.8.tgz",
|
||||||
"integrity": "sha512-PbjylA1UWd4A/c5AYqie/EVxQ1/8uugmJKLg9whLoBBHbfPEBGhK09dCPrahK9kA6DRHhMmij0XXIUGIfrmNow==",
|
"integrity": "sha512-SFBFztROZf04e/zUsOlsbm4l6Nn5rBKAXS1nTWYJDxgbcFA+8DtjlZgbr03oFEkTcVZ5GAmLDIjbZ/KNg5GFxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.0",
|
"@floating-ui/core": "^1.7.1",
|
||||||
"@floating-ui/dom": "^1.7.0",
|
"@floating-ui/dom": "^1.7.1",
|
||||||
"css.escape": "^1.5.1",
|
|
||||||
"esm-env": "^1.1.2",
|
"esm-env": "^1.1.2",
|
||||||
"runed": "^0.28.0",
|
"runed": "^0.28.0",
|
||||||
"svelte-toolbelt": "^0.9.1",
|
"svelte-toolbelt": "^0.9.2",
|
||||||
"tabbable": "^6.2.0"
|
"tabbable": "^6.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20",
|
"node": ">=20"
|
||||||
"pnpm": ">=8.7.0"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/huntabyte"
|
"url": "https://github.com/sponsors/huntabyte"
|
||||||
@@ -2122,9 +2120,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bits-ui/node_modules/svelte-toolbelt": {
|
"node_modules/bits-ui/node_modules/svelte-toolbelt": {
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.2.tgz",
|
||||||
"integrity": "sha512-wBX6MtYw/kpht80j5zLpxJyR9soLizXPIAIWEVd9llAi17SR44ZdG291bldjB7r/K5duC0opDFcuhk2cA1hb8g==",
|
"integrity": "sha512-REb1cENGnFbhNSmIdCb1SDIpjEa3n1kXhNVHqGNEesjmPX3bG87gUZiCG8cqOt9AAarqzTzOtI2jEEWr/ZbHwA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/huntabyte"
|
"https://github.com/sponsors/huntabyte"
|
||||||
@@ -2361,13 +2359,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/css.escape": {
|
|
||||||
"version": "1.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
|
||||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@inlang/plugin-m-function-matcher": "^2.0.10",
|
"@inlang/plugin-m-function-matcher": "^2.0.10",
|
||||||
"@inlang/plugin-message-format": "^4.0.0",
|
"@inlang/plugin-message-format": "^4.0.0",
|
||||||
"@internationalized/date": "^3.8.2",
|
"@internationalized/date": "^3.8.2",
|
||||||
"@lucide/svelte": "^0.513.0",
|
"@lucide/svelte": "^0.522.0",
|
||||||
"@playwright/test": "^1.50.0",
|
"@playwright/test": "^1.50.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.20.7",
|
"@sveltejs/kit": "^2.20.7",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/node": "^22.10.10",
|
"@types/node": "^22.10.10",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"bits-ui": "^2.5.0",
|
"bits-ui": "^2.8.8",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^2.46.1",
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if input?.error}
|
{#if input?.error}
|
||||||
<p class="text-destructive mt-1 text-xs">{input.error}</p>
|
<p class="text-destructive mt-1 text-xs text-start">{input.error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
import Logo from '../logo.svelte';
|
import Logo from '../logo.svelte';
|
||||||
import HeaderAvatar from './header-avatar.svelte';
|
import HeaderAvatar from './header-avatar.svelte';
|
||||||
|
|
||||||
const authUrls = [/^\/authorize$/, /^\/device$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
|
const authUrls = [
|
||||||
|
/^\/authorize$/,
|
||||||
|
/^\/device$/,
|
||||||
|
/^\/login(?:\/.*)?$/,
|
||||||
|
/^\/logout$/,
|
||||||
|
/^\/signup(?:\/.*)?$/
|
||||||
|
];
|
||||||
|
|
||||||
let isAuthPage = $derived(
|
let isAuthPage = $derived(
|
||||||
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
|
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
|
||||||
|
|||||||
64
frontend/src/lib/components/signup/signup-form.svelte
Normal file
64
frontend/src/lib/components/signup/signup-form.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type { UserSignUp } from '$lib/types/user.type';
|
||||||
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
let {
|
||||||
|
callback,
|
||||||
|
isLoading
|
||||||
|
}: {
|
||||||
|
callback: (user: UserSignUp) => Promise<boolean>;
|
||||||
|
isLoading: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const initialData: UserSignUp = {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
username: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
firstName: z.string().min(1).max(50),
|
||||||
|
lastName: z.string().max(50).optional(),
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.max(30)
|
||||||
|
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
|
||||||
|
email: z.email()
|
||||||
|
});
|
||||||
|
type FormSchema = typeof formSchema;
|
||||||
|
|
||||||
|
const { inputs, ...form } = createForm<FormSchema>(formSchema, initialData);
|
||||||
|
|
||||||
|
let userData: UserSignUp | null = $state(null);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const data = form.validate();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
const result = await tryCatch(callback(data));
|
||||||
|
if (result.data) {
|
||||||
|
userData = data;
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
|
||||||
|
<div class="mt-7 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
||||||
|
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||||
|
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
|
import { Badge, type BadgeVariant } from '$lib/components/ui/badge';
|
||||||
|
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
|
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
import { Copy, Ellipsis, Trash2 } from '@lucide/svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
signupTokens = $bindable(),
|
||||||
|
signupTokensRequestOptions,
|
||||||
|
onTokenDeleted
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
signupTokens: Paginated<SignupTokenDto>;
|
||||||
|
signupTokensRequestOptions: SearchPaginationSortRequest;
|
||||||
|
onTokenDeleted?: () => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | undefined) {
|
||||||
|
if (!dateStr) return m.never();
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteToken(token: SignupTokenDto) {
|
||||||
|
openConfirmDialog({
|
||||||
|
title: m.delete_signup_token(),
|
||||||
|
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
|
||||||
|
confirm: {
|
||||||
|
label: m.delete(),
|
||||||
|
destructive: true,
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await userService.deleteSignupToken(token.id);
|
||||||
|
toast.success(m.signup_token_deleted_successfully());
|
||||||
|
|
||||||
|
// Refresh the tokens
|
||||||
|
if (onTokenDeleted) {
|
||||||
|
await onTokenDeleted();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenChange(isOpen: boolean) {
|
||||||
|
open = isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenExpired(expiresAt: string) {
|
||||||
|
return new Date(expiresAt) < new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenUsedUp(token: SignupTokenDto) {
|
||||||
|
return token.usageCount >= token.usageLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenStatus(token: SignupTokenDto) {
|
||||||
|
if (isTokenExpired(token.expiresAt)) return 'expired';
|
||||||
|
if (isTokenUsedUp(token)) return 'used-up';
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status: string): { variant: BadgeVariant; text: string } {
|
||||||
|
switch (status) {
|
||||||
|
case 'expired':
|
||||||
|
return { variant: 'destructive', text: m.expired() };
|
||||||
|
case 'used-up':
|
||||||
|
return { variant: 'secondary', text: m.used_up() };
|
||||||
|
default:
|
||||||
|
return { variant: 'default', text: m.active() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySignupLink(token: SignupTokenDto) {
|
||||||
|
const signupLink = `${$page.url.origin}/st/${token.token}`;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(signupLink)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(m.copied());
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
axiosErrorToast(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root {open} {onOpenChange}>
|
||||||
|
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{m.manage_signup_tokens()}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{m.view_and_manage_active_signup_tokens()}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<AdvancedTable
|
||||||
|
items={signupTokens}
|
||||||
|
requestOptions={signupTokensRequestOptions}
|
||||||
|
withoutSearch={true}
|
||||||
|
onRefresh={async (options) => {
|
||||||
|
const result = await userService.listSignupTokens(options);
|
||||||
|
signupTokens = result;
|
||||||
|
return result;
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{ label: m.token() },
|
||||||
|
{ label: m.status() },
|
||||||
|
{ label: m.usage(), sortColumn: 'usageCount' },
|
||||||
|
{ label: m.expires(), sortColumn: 'expiresAt' },
|
||||||
|
{ label: m.created(), sortColumn: 'createdAt' },
|
||||||
|
{ label: m.actions(), hidden: true }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{#snippet rows({ item })}
|
||||||
|
<Table.Cell class="font-mono text-xs">
|
||||||
|
{item.token.substring(0, 2)}...{item.token.substring(item.token.length - 4)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{@const status = getTokenStatus(item)}
|
||||||
|
{@const statusBadge = getStatusBadge(status)}
|
||||||
|
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||||
|
{statusBadge.text}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{`${item.usageCount} ${m.of()} ${item.usageLimit}`}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{formatDate(item.expiresAt)}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm">
|
||||||
|
{formatDate(item.createdAt)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
||||||
|
<Ellipsis class="size-4" />
|
||||||
|
<span class="sr-only">{m.toggle_menu()}</span>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item onclick={() => copySignupLink(item)}>
|
||||||
|
<Copy class="mr-2 size-4" />
|
||||||
|
{m.copy()}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="text-red-500 focus:!text-red-700"
|
||||||
|
onclick={() => deleteToken(item)}
|
||||||
|
>
|
||||||
|
<Trash2 class="mr-2 size-4" />
|
||||||
|
{m.delete()}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Table.Cell>
|
||||||
|
{/snippet}
|
||||||
|
</AdvancedTable>
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer class="mt-3">
|
||||||
|
<Button onclick={() => (open = false)}>
|
||||||
|
{m.close()}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
138
frontend/src/lib/components/signup/signup-token-modal.svelte
Normal file
138
frontend/src/lib/components/signup/signup-token-modal.svelte
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||||
|
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
onTokenCreated
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onTokenCreated?: () => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
let signupToken: string | null = $state(null);
|
||||||
|
let signupLink: string | null = $state(null);
|
||||||
|
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_day());
|
||||||
|
let usageLimit: number = $state(1);
|
||||||
|
|
||||||
|
let availableExpirations = {
|
||||||
|
[m.one_hour()]: 60 * 60,
|
||||||
|
[m.twelve_hours()]: 60 * 60 * 12,
|
||||||
|
[m.one_day()]: 60 * 60 * 24,
|
||||||
|
[m.one_week()]: 60 * 60 * 24 * 7,
|
||||||
|
[m.one_month()]: 60 * 60 * 24 * 30
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createSignupToken() {
|
||||||
|
try {
|
||||||
|
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||||
|
signupToken = await userService.createSignupToken(expiration, usageLimit);
|
||||||
|
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||||
|
|
||||||
|
if (onTokenCreated) {
|
||||||
|
await onTokenCreated();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenChange(isOpen: boolean) {
|
||||||
|
open = isOpen;
|
||||||
|
if (!isOpen) {
|
||||||
|
signupToken = null;
|
||||||
|
signupLink = null;
|
||||||
|
selectedExpiration = m.one_day();
|
||||||
|
usageLimit = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root {open} {onOpenChange}>
|
||||||
|
<Dialog.Content class="max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{m.signup_token()}</Dialog.Title>
|
||||||
|
<Dialog.Description
|
||||||
|
>{m.create_a_signup_token_to_allow_new_user_registration()}</Dialog.Description
|
||||||
|
>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if signupToken === null}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label for="expiration">{m.expiration()}</Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={Object.keys(availableExpirations)[0]}
|
||||||
|
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
|
||||||
|
>
|
||||||
|
<Select.Trigger id="expiration" class="h-9 w-full">
|
||||||
|
{selectedExpiration}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each Object.keys(availableExpirations) as key}
|
||||||
|
<Select.Item value={key}>{key}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-0" for="usage-limit">{m.usage_limit()}</Label>
|
||||||
|
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||||
|
{m.number_of_times_token_can_be_used()}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="usage-limit"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
bind:value={usageLimit}
|
||||||
|
class="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer class="mt-4">
|
||||||
|
<Button
|
||||||
|
onclick={() => createSignupToken()}
|
||||||
|
disabled={!selectedExpiration || usageLimit < 1}
|
||||||
|
>
|
||||||
|
{m.create()}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<Qrcode
|
||||||
|
class="mb-2"
|
||||||
|
value={signupLink}
|
||||||
|
size={180}
|
||||||
|
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
|
||||||
|
backgroundColor={mode.current === 'dark' ? '#000000' : '#FFFFFF'}
|
||||||
|
/>
|
||||||
|
<CopyToClipboard value={signupLink!}>
|
||||||
|
<p data-testId="signup-token-link" class="px-2 text-center text-sm break-all">
|
||||||
|
{signupLink!}
|
||||||
|
</p>
|
||||||
|
</CopyToClipboard>
|
||||||
|
|
||||||
|
<div class="text-muted-foreground mt-2 text-center text-sm">
|
||||||
|
<p>{m.usage_limit()}: {usageLimit}</p>
|
||||||
|
<p>{m.expiration()}: {selectedExpiration}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn } from '$lib/utils/style.js';
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
export type DropdownButtonContentProps = DropdownMenuPrimitive.ContentProps;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.ContentProps & {
|
||||||
|
portalProps?: DropdownMenuPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md outline-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.Arrow />
|
||||||
|
{@render children?.()}
|
||||||
|
</DropdownMenuPrimitive.Content>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils/style.js';
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.ItemProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
'data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||||
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
import {
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonVariant,
|
||||||
|
type ButtonSize
|
||||||
|
} from '$lib/components/ui/button/button.svelte';
|
||||||
|
|
||||||
|
export type DropdownButtonMainProps = WithElementRef<HTMLButtonAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
ref = $bindable(null),
|
||||||
|
type = 'button',
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: DropdownButtonMainProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dropdown-button-main"
|
||||||
|
class={cn(buttonVariants({ variant, size }), 'rounded-r-none border-r-0', className)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn } from '$lib/utils/style.js';
|
||||||
|
|
||||||
|
export type DropdownButtonSeparatorProps = DropdownMenuPrimitive.SeparatorProps;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
bind:ref
|
||||||
|
class={cn('bg-muted -mx-1 my-1 h-px', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||||
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
import {
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonVariant,
|
||||||
|
type ButtonSize
|
||||||
|
} from '$lib/components/ui/button/button.svelte';
|
||||||
|
|
||||||
|
export type DropdownButtonTriggerProps = WithElementRef<HTMLButtonAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
builders?: any[];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
ref = $bindable(null),
|
||||||
|
type = 'button',
|
||||||
|
disabled,
|
||||||
|
builders = [],
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: DropdownButtonTriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
use:builders[0]
|
||||||
|
data-slot="dropdown-button-trigger"
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant, size }),
|
||||||
|
'border-l-background/20 rounded-l-none border-l px-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<ChevronDown class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
export type DropdownButtonProps = WithElementRef<HTMLAttributes<HTMLDivElement>>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: DropdownButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={ref} data-slot="dropdown-button" class={cn('flex', className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
30
frontend/src/lib/components/ui/dropdown-button/index.ts
Normal file
30
frontend/src/lib/components/ui/dropdown-button/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
|
||||||
|
import Root from './dropdown-button.svelte';
|
||||||
|
import Main from './dropdown-button-main.svelte';
|
||||||
|
import Trigger from './dropdown-button-trigger.svelte';
|
||||||
|
import Content from './dropdown-button-content.svelte';
|
||||||
|
import Item from './dropdown-button-item.svelte';
|
||||||
|
import Separator from './dropdown-button-separator.svelte';
|
||||||
|
|
||||||
|
const DropdownRoot = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Main,
|
||||||
|
Trigger,
|
||||||
|
Content,
|
||||||
|
Item,
|
||||||
|
Separator,
|
||||||
|
DropdownRoot,
|
||||||
|
DropdownTrigger,
|
||||||
|
//
|
||||||
|
Root as DropdownButton,
|
||||||
|
Main as DropdownButtonMain,
|
||||||
|
Trigger as DropdownButtonTrigger,
|
||||||
|
Content as DropdownButtonContent,
|
||||||
|
Item as DropdownButtonItem,
|
||||||
|
Separator as DropdownButtonSeparator,
|
||||||
|
DropdownRoot as DropdownButtonRoot,
|
||||||
|
DropdownTrigger as DropdownButtonPrimitiveTrigger
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
|
import type { SignupTokenDto } 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 } from '$lib/types/user.type';
|
import type { 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';
|
||||||
@@ -82,6 +83,14 @@ export default class UserService extends APIService {
|
|||||||
return res.data.token;
|
return res.data.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createSignupToken(expiresAt: Date, usageLimit: number) {
|
||||||
|
const res = await this.api.post(`/signup-tokens`, {
|
||||||
|
expiresAt,
|
||||||
|
usageLimit
|
||||||
|
});
|
||||||
|
return res.data.token;
|
||||||
|
}
|
||||||
|
|
||||||
async exchangeOneTimeAccessToken(token: string) {
|
async exchangeOneTimeAccessToken(token: string) {
|
||||||
const res = await this.api.post(`/one-time-access-token/${token}`);
|
const res = await this.api.post(`/one-time-access-token/${token}`);
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
@@ -99,4 +108,20 @@ export default class UserService extends APIService {
|
|||||||
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
|
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async signup(data: UserSignUp) {
|
||||||
|
const res = await this.api.post(`/signup`, data);
|
||||||
|
return res.data as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSignupTokens(options?: SearchPaginationSortRequest) {
|
||||||
|
const res = await this.api.get('/signup-tokens', {
|
||||||
|
params: options
|
||||||
|
});
|
||||||
|
return res.data as Paginated<SignupTokenDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSignupToken(tokenId: string) {
|
||||||
|
await this.api.delete(`/signup-tokens/${tokenId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type AppConfig = {
|
export type AppConfig = {
|
||||||
appName: string;
|
appName: string;
|
||||||
allowOwnAccountEdit: boolean;
|
allowOwnAccountEdit: boolean;
|
||||||
|
allowUserSignups: 'disabled' | 'withToken' | 'open';
|
||||||
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
|
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
|
||||||
emailOneTimeAccessAsAdminEnabled: boolean;
|
emailOneTimeAccessAsAdminEnabled: boolean;
|
||||||
ldapEnabled: boolean;
|
ldapEnabled: boolean;
|
||||||
|
|||||||
8
frontend/src/lib/types/signup-token.type.ts
Normal file
8
frontend/src/lib/types/signup-token.type.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface SignupTokenDto {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
usageLimit: number;
|
||||||
|
usageCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@@ -17,3 +17,7 @@ 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'> & {
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ export function getAuthRedirectPath(path: string, user: User | null) {
|
|||||||
const isAdmin = user?.isAdmin;
|
const isAdmin = user?.isAdmin;
|
||||||
|
|
||||||
const isUnauthenticatedOnlyPath =
|
const isUnauthenticatedOnlyPath =
|
||||||
path == '/login' || path.startsWith('/login/') || path == '/lc' || path.startsWith('/lc/');
|
path == '/login' ||
|
||||||
|
path.startsWith('/login/') ||
|
||||||
|
path == '/lc' ||
|
||||||
|
path.startsWith('/lc/') ||
|
||||||
|
path == '/signup' ||
|
||||||
|
path.startsWith('/signup/') ||
|
||||||
|
path.startsWith('/st/');
|
||||||
const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path);
|
const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path);
|
||||||
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
|
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
|
||||||
|
|
||||||
|
|||||||
20
frontend/src/lib/utils/try-catch-util.ts
Normal file
20
frontend/src/lib/utils/try-catch-util.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
type Success<T> = {
|
||||||
|
data: T;
|
||||||
|
error: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Failure<E> = {
|
||||||
|
data: null;
|
||||||
|
error: E;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Result<T, E = Error> = Success<T> | Failure<E>;
|
||||||
|
|
||||||
|
export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
|
||||||
|
try {
|
||||||
|
const data = await promise;
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
return { data: null, error: error as E };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
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 userStore from '$lib/stores/user-store';
|
||||||
@@ -9,7 +10,6 @@
|
|||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
|
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
const webauthnService = new WebAuthnService();
|
const webauthnService = new WebAuthnService();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
@@ -49,10 +49,17 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-muted-foreground mt-2" in:fade>
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
{m.authenticate_yourself_with_your_passkey_to_access_the_admin_panel()}
|
{m.authenticate_with_passkey_to_access_account()}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<Button class="mt-10" {isLoading} onclick={authenticate} autofocus={true}>
|
<div class="mt-10 flex justify-center gap-3">
|
||||||
|
{#if $appConfigStore.allowUserSignups === 'open'}
|
||||||
|
<Button variant="secondary" href="/signup">
|
||||||
|
{m.signup()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button {isLoading} onclick={authenticate} autofocus={true}>
|
||||||
{error ? m.try_again() : m.authenticate()}
|
{error ? m.try_again() : m.authenticate()}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</SignInWrapper>
|
</SignInWrapper>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
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 { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
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 { AllAppConfig } from '$lib/types/application-configuration';
|
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||||
@@ -22,11 +23,27 @@
|
|||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
const signupOptions = {
|
||||||
|
disabled: {
|
||||||
|
label: m.disabled(),
|
||||||
|
description: m.signup_disabled_description()
|
||||||
|
},
|
||||||
|
withToken: {
|
||||||
|
label: m.signup_with_token(),
|
||||||
|
description: m.signup_with_token_description()
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
label: m.signup_open(),
|
||||||
|
description: m.signup_open_description()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updatedAppConfig = {
|
const updatedAppConfig = {
|
||||||
appName: appConfig.appName,
|
appName: appConfig.appName,
|
||||||
sessionDuration: appConfig.sessionDuration,
|
sessionDuration: appConfig.sessionDuration,
|
||||||
emailsVerified: appConfig.emailsVerified,
|
emailsVerified: appConfig.emailsVerified,
|
||||||
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
|
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
|
||||||
|
allowUserSignups: appConfig.allowUserSignups,
|
||||||
disableAnimations: appConfig.disableAnimations,
|
disableAnimations: appConfig.disableAnimations,
|
||||||
accentColor: appConfig.accentColor
|
accentColor: appConfig.accentColor
|
||||||
};
|
};
|
||||||
@@ -36,6 +53,7 @@
|
|||||||
sessionDuration: z.number().min(1).max(43200),
|
sessionDuration: z.number().min(1).max(43200),
|
||||||
emailsVerified: z.boolean(),
|
emailsVerified: z.boolean(),
|
||||||
allowOwnAccountEdit: z.boolean(),
|
allowOwnAccountEdit: z.boolean(),
|
||||||
|
allowUserSignups: z.enum(['disabled', 'withToken', 'open']),
|
||||||
disableAnimations: z.boolean(),
|
disableAnimations: z.boolean(),
|
||||||
accentColor: z.string()
|
accentColor: z.string()
|
||||||
});
|
});
|
||||||
@@ -62,13 +80,60 @@
|
|||||||
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
|
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
|
||||||
bind:input={$inputs.sessionDuration}
|
bind:input={$inputs.sessionDuration}
|
||||||
/>
|
/>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label class="mb-0" for="enable-user-signup">{m.enable_user_signups()}</Label>
|
||||||
|
<p class="text-muted-foreground text-[0.8rem]">
|
||||||
|
{m.enable_user_signups_description()}
|
||||||
|
</p>
|
||||||
|
<Select.Root
|
||||||
|
disabled={$appConfigStore.uiConfigDisabled}
|
||||||
|
type="single"
|
||||||
|
value={$inputs.allowUserSignups.value}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
($inputs.allowUserSignups.value = v as typeof $inputs.allowUserSignups.value)}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
class="w-full"
|
||||||
|
aria-label={m.enable_user_signups()}
|
||||||
|
placeholder={m.enable_user_signups()}
|
||||||
|
>
|
||||||
|
{signupOptions[$inputs.allowUserSignups.value]?.label}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item value="disabled">
|
||||||
|
<div class="flex flex-col items-start gap-1">
|
||||||
|
<span class="font-medium">{signupOptions.disabled.label}</span>
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
{signupOptions.disabled.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
<Select.Item value="withToken">
|
||||||
|
<div class="flex flex-col items-start gap-1">
|
||||||
|
<span class="font-medium">{signupOptions.withToken.label}</span>
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
{signupOptions.withToken.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
<Select.Item value="open">
|
||||||
|
<div class="flex flex-col items-start gap-1">
|
||||||
|
<span class="font-medium">{signupOptions.open.label}</span>
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
{signupOptions.open.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
<SwitchWithLabel
|
<SwitchWithLabel
|
||||||
id="self-account-editing"
|
id="self-account-editing"
|
||||||
label={m.enable_self_account_editing()}
|
label={m.enable_self_account_editing()}
|
||||||
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
|
<SwitchWithLabel
|
||||||
id="emails-verified"
|
id="emails-verified"
|
||||||
label={m.emails_verified()}
|
label={m.emails_verified()}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import SignupTokenListModal from '$lib/components/signup/signup-token-list-modal.svelte';
|
||||||
|
import SignupTokenModal from '$lib/components/signup/signup-token-modal.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as DropdownButton from '$lib/components/ui/dropdown-button';
|
||||||
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';
|
||||||
@@ -15,8 +18,13 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let users = $state(data.users);
|
let users = $state(data.users);
|
||||||
let usersRequestOptions = $state(data.usersRequestOptions);
|
let usersRequestOptions = $state(data.usersRequestOptions);
|
||||||
|
let signupTokens = $state(data.signupTokens);
|
||||||
|
let signupTokensRequestOptions = $state(data.signupTokensRequestOptions);
|
||||||
|
|
||||||
|
let selectedCreateOptions = $state('Add User');
|
||||||
let expandAddUser = $state(false);
|
let expandAddUser = $state(false);
|
||||||
|
let signupTokenModalOpen = $state(false);
|
||||||
|
let signupTokenListModalOpen = $state(false);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
@@ -33,6 +41,10 @@
|
|||||||
users = await userService.list(usersRequestOptions);
|
users = await userService.list(usersRequestOptions);
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshSignupTokens() {
|
||||||
|
signupTokens = await userService.listSignupTokens(signupTokensRequestOptions);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -55,7 +67,30 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if !expandAddUser}
|
{#if !expandAddUser}
|
||||||
|
{#if $appConfigStore.allowUserSignups !== 'disabled'}
|
||||||
|
<DropdownButton.DropdownRoot>
|
||||||
|
<DropdownButton.Root>
|
||||||
|
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
|
||||||
|
{selectedCreateOptions}
|
||||||
|
</DropdownButton.Main>
|
||||||
|
|
||||||
|
<DropdownButton.DropdownTrigger>
|
||||||
|
<DropdownButton.Trigger class="border-l" />
|
||||||
|
</DropdownButton.DropdownTrigger>
|
||||||
|
</DropdownButton.Root>
|
||||||
|
|
||||||
|
<DropdownButton.Content align="end">
|
||||||
|
<DropdownButton.Item onclick={() => (signupTokenModalOpen = true)}>
|
||||||
|
{m.create_signup_token()}
|
||||||
|
</DropdownButton.Item>
|
||||||
|
<DropdownButton.Item onclick={() => (signupTokenListModalOpen = true)}>
|
||||||
|
{m.view_active_signup_tokens()}
|
||||||
|
</DropdownButton.Item>
|
||||||
|
</DropdownButton.Content>
|
||||||
|
</DropdownButton.DropdownRoot>
|
||||||
|
{:else}
|
||||||
<Button onclick={() => (expandAddUser = true)}>{m.add_user()}</Button>
|
<Button onclick={() => (expandAddUser = true)}>{m.add_user()}</Button>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddUser = false)}>
|
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddUser = false)}>
|
||||||
<LucideMinus class="size-5" />
|
<LucideMinus class="size-5" />
|
||||||
@@ -86,3 +121,11 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SignupTokenModal bind:open={signupTokenModalOpen} onTokenCreated={refreshSignupTokens} />
|
||||||
|
<SignupTokenListModal
|
||||||
|
bind:open={signupTokenListModalOpen}
|
||||||
|
bind:signupTokens
|
||||||
|
{signupTokensRequestOptions}
|
||||||
|
onTokenDeleted={refreshSignupTokens}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ export const load: PageLoad = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const users = await userService.list(usersRequestOptions);
|
const signupTokensRequestOptions: SearchPaginationSortRequest = {
|
||||||
return { users, usersRequestOptions };
|
sort: {
|
||||||
|
column: 'createdAt',
|
||||||
|
direction: 'desc'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [users, signupTokens] = await Promise.all([
|
||||||
|
userService.list(usersRequestOptions),
|
||||||
|
userService.listSignupTokens(signupTokensRequestOptions)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
usersRequestOptions,
|
||||||
|
signupTokens,
|
||||||
|
signupTokensRequestOptions
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
SIGN_IN: m.sign_in(),
|
SIGN_IN: m.sign_in(),
|
||||||
TOKEN_SIGN_IN: m.token_sign_in(),
|
TOKEN_SIGN_IN: m.token_sign_in(),
|
||||||
CLIENT_AUTHORIZATION: m.client_authorization(),
|
CLIENT_AUTHORIZATION: m.client_authorization(),
|
||||||
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization()
|
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(),
|
||||||
|
ACCOUNT_CREATED: m.account_created()
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
90
frontend/src/routes/signup/+page.svelte
Normal file
90
frontend/src/routes/signup/+page.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
|
import SignupForm from '$lib/components/signup/signup-form.svelte';
|
||||||
|
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 type { UserSignUp } from '$lib/types/user.type';
|
||||||
|
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||||
|
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||||
|
import { LucideChevronLeft } from '@lucide/svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import LoginLogoErrorSuccessIndicator from '../login/components/login-logo-error-success-indicator.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let error: string | undefined = $state();
|
||||||
|
|
||||||
|
async function handleSignup(userData: UserSignUp) {
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
const result = await tryCatch(userService.signup({ ...userData, token: data.token }));
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
error = getAxiosErrorMessage(result.error);
|
||||||
|
isLoading = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
userStore.setUser(result.data);
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
|
goto('/signup/add-passkey');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!$appConfigStore.allowUserSignups || $appConfigStore.allowUserSignups === 'disabled') {
|
||||||
|
error = m.user_signups_are_disabled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For token-based signups, check if we have a valid token
|
||||||
|
if ($appConfigStore.allowUserSignups === 'withToken' && !data.token) {
|
||||||
|
error = m.signup_requires_valid_token();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{m.signup()}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
||||||
|
{m.signup_to_appname({ appName: $appConfigStore.appName })}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if !error}
|
||||||
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
|
{m.create_your_account_to_get_started()}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
|
{error}.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if $appConfigStore.allowUserSignups === 'open' || data.token}
|
||||||
|
<SignupForm callback={handleSignup} {isLoading} />
|
||||||
|
<div class="mt-10 flex w-full items-center justify-between gap-2">
|
||||||
|
<a class="text-muted-foreground mt-5 flex text-sm" href="/login"
|
||||||
|
><LucideChevronLeft class="size-5" /> {m.back()}</a
|
||||||
|
>
|
||||||
|
<Button type="submit" form="sign-up-form" onclick={() => (error = undefined)}
|
||||||
|
>{m.signup()}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Button class="mt-10" href="/login">{m.go_to_login()}</Button>
|
||||||
|
{/if}
|
||||||
|
</SignInWrapper>
|
||||||
7
frontend/src/routes/signup/+page.ts
Normal file
7
frontend/src/routes/signup/+page.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ url }) => {
|
||||||
|
return {
|
||||||
|
token: url.searchParams.get('token') || undefined
|
||||||
|
};
|
||||||
|
};
|
||||||
82
frontend/src/routes/signup/add-passkey/+page.svelte
Normal file
82
frontend/src/routes/signup/add-passkey/+page.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
|
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
|
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import LoginLogoErrorSuccessIndicator from '../../login/components/login-logo-error-success-indicator.svelte';
|
||||||
|
|
||||||
|
const webauthnService = new WebAuthnService();
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let error: string | undefined = $state();
|
||||||
|
|
||||||
|
async function createPasskeyAndContinue() {
|
||||||
|
isLoading = true;
|
||||||
|
error = undefined;
|
||||||
|
|
||||||
|
const optsResult = await tryCatch(webauthnService.getRegistrationOptions());
|
||||||
|
if (optsResult.error) {
|
||||||
|
error = getWebauthnErrorMessage(optsResult.error);
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attRespResult = await tryCatch(startRegistration({ optionsJSON: optsResult.data }));
|
||||||
|
if (attRespResult.error) {
|
||||||
|
error = getWebauthnErrorMessage(attRespResult.error);
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishResult = await tryCatch(webauthnService.finishRegistration(attRespResult.data));
|
||||||
|
if (finishResult.error) {
|
||||||
|
error = getWebauthnErrorMessage(finishResult.error);
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
goto('/settings/account');
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{m.add_passkey()}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
|
||||||
|
<div class="w-full text-center">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||||
|
</div>
|
||||||
|
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
||||||
|
{m.setup_your_passkey()}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
|
{#if !error}
|
||||||
|
{m.create_a_passkey_to_securely_access_your_account()}
|
||||||
|
{:else}
|
||||||
|
{error}. {m.please_try_again()}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="mt-10 flex w-full justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={() => goto('/settings/account')}
|
||||||
|
disabled={isLoading}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
{m.skip_for_now()}
|
||||||
|
</Button>
|
||||||
|
<Button onclick={createPasskeyAndContinue} {isLoading} class="flex-1">
|
||||||
|
{m.add_passkey()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SignInWrapper>
|
||||||
16
frontend/src/routes/st/[token]/+page.ts
Normal file
16
frontend/src/routes/st/[token]/+page.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
// Alias for /signup?token=...
|
||||||
|
export const load: PageLoad = async ({ url, params }) => {
|
||||||
|
const targetPath = '/signup';
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set('token', params.token);
|
||||||
|
|
||||||
|
if (url.searchParams.has('redirect')) {
|
||||||
|
searchParams.set('redirect', url.searchParams.get('redirect')!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(307, `${targetPath}?${searchParams.toString()}`);
|
||||||
|
};
|
||||||
@@ -4,21 +4,21 @@ export const users = {
|
|||||||
firstname: 'Tim',
|
firstname: 'Tim',
|
||||||
lastname: 'Cook',
|
lastname: 'Cook',
|
||||||
email: 'tim.cook@test.com',
|
email: 'tim.cook@test.com',
|
||||||
username: 'tim'
|
username: 'tim',
|
||||||
},
|
},
|
||||||
craig: {
|
craig: {
|
||||||
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
|
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
|
||||||
firstname: 'Craig',
|
firstname: 'Craig',
|
||||||
lastname: 'Federighi',
|
lastname: 'Federighi',
|
||||||
email: 'craig.federighi@test.com',
|
email: 'craig.federighi@test.com',
|
||||||
username: 'craig'
|
username: 'craig',
|
||||||
},
|
},
|
||||||
steve: {
|
steve: {
|
||||||
firstname: 'Steve',
|
firstname: 'Steve',
|
||||||
lastname: 'Jobs',
|
lastname: 'Jobs',
|
||||||
email: 'steve.jobs@test.com',
|
email: 'steve.jobs@test.com',
|
||||||
username: 'steve'
|
username: 'steve',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const oidcClients = {
|
export const oidcClients = {
|
||||||
@@ -27,16 +27,16 @@ export const oidcClients = {
|
|||||||
name: 'Nextcloud',
|
name: 'Nextcloud',
|
||||||
callbackUrl: 'http://nextcloud/auth/callback',
|
callbackUrl: 'http://nextcloud/auth/callback',
|
||||||
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
|
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
|
||||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
|
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY',
|
||||||
},
|
},
|
||||||
immich: {
|
immich: {
|
||||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||||
name: 'Immich',
|
name: 'Immich',
|
||||||
callbackUrl: 'http://immich/auth/callback',
|
callbackUrl: 'http://immich/auth/callback',
|
||||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
|
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x',
|
||||||
},
|
},
|
||||||
federated: {
|
federated: {
|
||||||
id: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
||||||
name: 'Federated',
|
name: 'Federated',
|
||||||
callbackUrl: 'http://federated/auth/callback',
|
callbackUrl: 'http://federated/auth/callback',
|
||||||
federatedJWT: {
|
federatedJWT: {
|
||||||
@@ -44,43 +44,43 @@ export const oidcClients = {
|
|||||||
audience: 'api://PocketID',
|
audience: 'api://PocketID',
|
||||||
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
||||||
},
|
},
|
||||||
accessCodes: ['federated']
|
accessCodes: ['federated'],
|
||||||
},
|
},
|
||||||
pingvinShare: {
|
pingvinShare: {
|
||||||
name: 'Pingvin Share',
|
name: 'Pingvin Share',
|
||||||
callbackUrl: 'http://pingvin.share/auth/callback',
|
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
|
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userGroups = {
|
export const userGroups = {
|
||||||
developers: {
|
developers: {
|
||||||
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
|
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
|
||||||
friendlyName: 'Developers',
|
friendlyName: 'Developers',
|
||||||
name: 'developers'
|
name: 'developers',
|
||||||
},
|
},
|
||||||
designers: {
|
designers: {
|
||||||
id: 'adab18bf-f89d-4087-9ee1-70ff15b48211',
|
id: 'adab18bf-f89d-4087-9ee1-70ff15b48211',
|
||||||
friendlyName: 'Designers',
|
friendlyName: 'Designers',
|
||||||
name: 'designers'
|
name: 'designers',
|
||||||
},
|
},
|
||||||
humanResources: {
|
humanResources: {
|
||||||
friendlyName: 'Human Resources',
|
friendlyName: 'Human Resources',
|
||||||
name: 'human_resources'
|
name: 'human_resources',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const oneTimeAccessTokens = [
|
export const oneTimeAccessTokens = [
|
||||||
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
|
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
|
||||||
{ token: 'YCGDtftvsvYWiXd0', expired: true }
|
{ token: 'YCGDtftvsvYWiXd0', expired: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const apiKeys = [
|
export const apiKeys = [
|
||||||
{
|
{
|
||||||
id: '5f1fa856-c164-4295-961e-175a0d22d725',
|
id: '5f1fa856-c164-4295-961e-175a0d22d725',
|
||||||
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
|
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
|
||||||
name: 'Test API Key'
|
name: 'Test API Key',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const refreshTokens = [
|
export const refreshTokens = [
|
||||||
@@ -88,12 +88,47 @@ export const refreshTokens = [
|
|||||||
token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo',
|
token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo',
|
||||||
clientId: oidcClients.nextcloud.id,
|
clientId: oidcClients.nextcloud.id,
|
||||||
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
||||||
expired: false
|
expired: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4',
|
token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4',
|
||||||
clientId: oidcClients.nextcloud.id,
|
clientId: oidcClients.nextcloud.id,
|
||||||
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
||||||
expired: true
|
expired: true,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const signupTokens = {
|
||||||
|
valid: {
|
||||||
|
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||||
|
token: 'VALID1234567890A',
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
usageLimit: 1,
|
||||||
|
usageCount: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
partiallyUsed: {
|
||||||
|
id: 'b2c3d4e5-f6g7-8901-bcde-f12345678901',
|
||||||
|
token: 'PARTIAL567890ABC',
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
usageLimit: 5,
|
||||||
|
usageCount: 2,
|
||||||
|
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
id: 'c3d4e5f6-g7h8-9012-cdef-123456789012',
|
||||||
|
token: 'EXPIRED34567890B',
|
||||||
|
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
usageLimit: 3,
|
||||||
|
usageCount: 1,
|
||||||
|
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
fullyUsed: {
|
||||||
|
id: 'd4e5f6g7-h8i9-0123-def0-234567890123',
|
||||||
|
token: 'FULLYUSED567890C',
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
usageLimit: 1,
|
||||||
|
usageCount: 1,
|
||||||
|
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { cleanupBackend } from '../utils/cleanup.util';
|
|||||||
test.beforeEach(cleanupBackend);
|
test.beforeEach(cleanupBackend);
|
||||||
|
|
||||||
test.describe('LDAP Integration', () => {
|
test.describe('LDAP Integration', () => {
|
||||||
test.skip(process.env.SKIP_LDAP_TESTS === "true", 'Skipping LDAP tests due to SKIP_LDAP_TESTS environment variable');
|
test.skip(process.env.SKIP_LDAP_TESTS === 'true', 'Skipping LDAP tests due to SKIP_LDAP_TESTS environment variable');
|
||||||
|
|
||||||
test('LDAP configuration is working properly', async ({ page }) => {
|
test('LDAP configuration is working properly', async ({ page }) => {
|
||||||
await page.goto('/settings/admin/application-configuration');
|
await page.goto('/settings/admin/application-configuration');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
|
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeVisible();
|
||||||
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
|
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
|
||||||
await expect(page.getByLabel('LDAP Base DN')).not.toBeEmpty();
|
await expect(page.getByLabel('LDAP Base DN')).not.toBeEmpty();
|
||||||
|
|
||||||
@@ -53,10 +53,7 @@ test.describe('LDAP Integration', () => {
|
|||||||
await expect(page.getByRole('cell', { name: 'test_group' }).first()).toBeVisible();
|
await expect(page.getByRole('cell', { name: 'test_group' }).first()).toBeVisible();
|
||||||
await expect(page.getByRole('cell', { name: 'admin_group' }).first()).toBeVisible();
|
await expect(page.getByRole('cell', { name: 'admin_group' }).first()).toBeVisible();
|
||||||
|
|
||||||
await page
|
await page.getByRole('row', { name: 'test_group' }).getByRole('button', { name: 'Toggle menu' }).click();
|
||||||
.getByRole('row', { name: 'test_group' })
|
|
||||||
.getByRole('button', { name: 'Toggle menu' })
|
|
||||||
.click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
// Verify group source is LDAP
|
// Verify group source is LDAP
|
||||||
@@ -81,10 +78,7 @@ test.describe('LDAP Integration', () => {
|
|||||||
await page.goto('/settings/admin/user-groups');
|
await page.goto('/settings/admin/user-groups');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await page
|
await page.getByRole('row', { name: 'test_group' }).getByRole('button', { name: 'Toggle menu' }).click();
|
||||||
.getByRole('row', { name: 'test_group' })
|
|
||||||
.getByRole('button', { name: 'Toggle menu' })
|
|
||||||
.click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
// Verify key fields are disabled
|
// Verify key fields are disabled
|
||||||
|
|||||||
175
tests/specs/user-signup.spec.ts
Normal file
175
tests/specs/user-signup.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
import passkeyUtil from '../utils/passkey.util';
|
||||||
|
import { users, signupTokens } from 'data';
|
||||||
|
|
||||||
|
test.beforeEach(cleanupBackend);
|
||||||
|
|
||||||
|
test.describe('User Signup', () => {
|
||||||
|
async function setSignupMode(page: any, mode: 'Disabled' | 'Signup with token' | 'Open Signup') {
|
||||||
|
await page.goto('/settings/admin/application-configuration');
|
||||||
|
|
||||||
|
await page.getByLabel('Enable user signups').click();
|
||||||
|
await page.getByRole('option', { name: mode }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
await expect(page.locator('[data-type="success"]')).toHaveText('Application configuration updated successfully');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Signup is disabled - shows error message', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Disabled');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
|
||||||
|
await expect(page.getByText('User signups are currently disabled')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Signup with token - success flow', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Signup with token');
|
||||||
|
|
||||||
|
await page.goto(`/st/${signupTokens.valid.token}`);
|
||||||
|
|
||||||
|
await page.getByLabel('First name').fill('John');
|
||||||
|
await page.getByLabel('Last name').fill('Doe');
|
||||||
|
await page.getByLabel('Username').fill('johndoe');
|
||||||
|
await page.getByLabel('Email').fill('john.doe@test.com');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signup/add-passkey');
|
||||||
|
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Signup with token - invalid token shows error', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Signup with token');
|
||||||
|
|
||||||
|
await page.goto('/st/invalid-token-123');
|
||||||
|
await page.getByLabel('First name').fill('Complete');
|
||||||
|
await page.getByLabel('Last name').fill('User');
|
||||||
|
await page.getByLabel('Username').fill('completeuser');
|
||||||
|
await page.getByLabel('Email').fill('complete.user@test.com');
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Token is invalid or expired.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Signup with token - no token in URL shows error', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Signup with token');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
|
||||||
|
await expect(page.getByText('A valid signup token is required to create an account.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Open signup - success flow', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Open Signup');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
|
||||||
|
await expect(page.getByText('Create your account to get started')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('First name').fill('Jane');
|
||||||
|
await page.getByLabel('Last name').fill('Smith');
|
||||||
|
await page.getByLabel('Username').fill('janesmith');
|
||||||
|
await page.getByLabel('Email').fill('jane.smith@test.com');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signup/add-passkey');
|
||||||
|
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Open signup - validation errors', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Open Signup');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Invalid input').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Open signup - duplicate email shows error', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Open Signup');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
|
||||||
|
await page.getByLabel('First name').fill('Test');
|
||||||
|
await page.getByLabel('Last name').fill('User');
|
||||||
|
await page.getByLabel('Username').fill('testuser123');
|
||||||
|
await page.getByLabel('Email').fill(users.tim.email);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Email is already in use.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Open signup - duplicate username shows error', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Open Signup');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
|
||||||
|
await page.getByLabel('First name').fill('Test');
|
||||||
|
await page.getByLabel('Last name').fill('User');
|
||||||
|
await page.getByLabel('Username').fill(users.tim.username);
|
||||||
|
await page.getByLabel('Email').fill('newuser@test.com');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Username is already in use.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Complete signup flow with passkey creation', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Open Signup');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.getByLabel('First name').fill('Complete');
|
||||||
|
await page.getByLabel('Last name').fill('User');
|
||||||
|
await page.getByLabel('Username').fill('completeuser');
|
||||||
|
await page.getByLabel('Email').fill('complete.user@test.com');
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signup/add-passkey');
|
||||||
|
|
||||||
|
await (await passkeyUtil.init(page)).addPasskey('timNew');
|
||||||
|
await page.getByRole('button', { name: 'Add Passkey' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/settings/account');
|
||||||
|
await expect(page.getByText('Single Passkey Configured')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Skip passkey creation during signup', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Open Signup');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.getByLabel('First name').fill('Skip');
|
||||||
|
await page.getByLabel('Last name').fill('User');
|
||||||
|
await page.getByLabel('Username').fill('skipuser');
|
||||||
|
await page.getByLabel('Email').fill('skip.user@test.com');
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signup/add-passkey');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/settings/account');
|
||||||
|
await expect(page.getByText('Passkey missing')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Token usage limit is enforced', async ({ page }) => {
|
||||||
|
await setSignupMode(page, 'Signup with token');
|
||||||
|
|
||||||
|
await page.goto(`/st/${signupTokens.fullyUsed.token}`);
|
||||||
|
await page.getByLabel('First name').fill('Complete');
|
||||||
|
await page.getByLabel('Last name').fill('User');
|
||||||
|
await page.getByLabel('Username').fill('completeuser');
|
||||||
|
await page.getByLabel('Email').fill('complete.user@test.com');
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Token is invalid or expired.')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user