mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-18 01:11:26 +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 {
|
||||
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/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 {
|
||||
@@ -495,6 +501,128 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// createSignupTokenHandler godoc
|
||||
// @Summary Create signup token
|
||||
// @Description Create a new signup token that allows user registration
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
|
||||
// @Success 201 {object} dto.SignupTokenDto
|
||||
// @Router /api/signup-tokens [post]
|
||||
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
||||
var input dto.SignupTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var input dto.UserCreateDto
|
||||
|
||||
@@ -17,6 +17,7 @@ type AppConfigUpdateDto struct {
|
||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
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 {
|
||||
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(
|
||||
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||
@@ -60,6 +61,21 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
||||
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
|
||||
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
||||
st := j.db.
|
||||
|
||||
@@ -38,6 +38,7 @@ type AppConfig struct {
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
// Internal
|
||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||
|
||||
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
|
||||
const (
|
||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
|
||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_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"},
|
||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||
// Internal
|
||||
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
||||
|
||||
@@ -310,6 +310,50 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -636,6 +636,110 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
|
||||
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) {
|
||||
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
||||
tokenLength := 16
|
||||
@@ -656,3 +760,20 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
|
||||
|
||||
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>?",
|
||||
"sign_in_to_appname": "Sign in to {appName}",
|
||||
"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",
|
||||
"appname_setup": "{appName} Setup",
|
||||
"please_try_again": "Please try again.",
|
||||
@@ -379,5 +379,43 @@
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"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",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@inlang/plugin-m-function-matcher": "^2.0.10",
|
||||
"@inlang/plugin-message-format": "^4.0.0",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@lucide/svelte": "^0.513.0",
|
||||
"@lucide/svelte": "^0.522.0",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.20.7",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^22.10.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"bits-ui": "^2.5.0",
|
||||
"bits-ui": "^2.8.8",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
@@ -920,9 +920,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "0.513.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.513.0.tgz",
|
||||
"integrity": "sha512-XwBQMQkMlr9qp9yVg+epx5MzhBBrqul8atO00y/ZfhlKRJuQZVmq3ELibApqyBtj9ys0Ai4FH/SZcODTUFYXig==",
|
||||
"version": "0.522.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.522.0.tgz",
|
||||
"integrity": "sha512-Q10WkheTfb0sZ6y+EQVTqW4DN8nD2mym00gVpinbe3dJWRW3/R7SeTGF9pW+Ux4PrzkNV9dUdUTWcbO/AdKwQQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
@@ -2078,23 +2078,21 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/bits-ui": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.5.0.tgz",
|
||||
"integrity": "sha512-PbjylA1UWd4A/c5AYqie/EVxQ1/8uugmJKLg9whLoBBHbfPEBGhK09dCPrahK9kA6DRHhMmij0XXIUGIfrmNow==",
|
||||
"version": "2.8.8",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.8.8.tgz",
|
||||
"integrity": "sha512-SFBFztROZf04e/zUsOlsbm4l6Nn5rBKAXS1nTWYJDxgbcFA+8DtjlZgbr03oFEkTcVZ5GAmLDIjbZ/KNg5GFxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.0",
|
||||
"@floating-ui/dom": "^1.7.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"@floating-ui/core": "^1.7.1",
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"esm-env": "^1.1.2",
|
||||
"runed": "^0.28.0",
|
||||
"svelte-toolbelt": "^0.9.1",
|
||||
"svelte-toolbelt": "^0.9.2",
|
||||
"tabbable": "^6.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=8.7.0"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/huntabyte"
|
||||
@@ -2122,9 +2120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bits-ui/node_modules/svelte-toolbelt": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.1.tgz",
|
||||
"integrity": "sha512-wBX6MtYw/kpht80j5zLpxJyR9soLizXPIAIWEVd9llAi17SR44ZdG291bldjB7r/K5duC0opDFcuhk2cA1hb8g==",
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.2.tgz",
|
||||
"integrity": "sha512-REb1cENGnFbhNSmIdCb1SDIpjEa3n1kXhNVHqGNEesjmPX3bG87gUZiCG8cqOt9AAarqzTzOtI2jEEWr/ZbHwA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte"
|
||||
@@ -2361,13 +2359,6 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"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-message-format": "^4.0.0",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@lucide/svelte": "^0.513.0",
|
||||
"@lucide/svelte": "^0.522.0",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.20.7",
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^22.10.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"bits-ui": "^2.5.0",
|
||||
"bits-ui": "^2.8.8",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
import Logo from '../logo.svelte';
|
||||
import HeaderAvatar from './header-avatar.svelte';
|
||||
|
||||
const authUrls = [/^\/authorize$/, /^\/device$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
|
||||
const authUrls = [
|
||||
/^\/authorize$/,
|
||||
/^\/device$/,
|
||||
/^\/login(?:\/.*)?$/,
|
||||
/^\/logout$/,
|
||||
/^\/signup(?:\/.*)?$/
|
||||
];
|
||||
|
||||
let isAuthPage = $derived(
|
||||
!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 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 { 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 { get } from 'svelte/store';
|
||||
import APIService from './api-service';
|
||||
@@ -82,6 +83,14 @@ export default class UserService extends APIService {
|
||||
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) {
|
||||
const res = await this.api.post(`/one-time-access-token/${token}`);
|
||||
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 });
|
||||
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 = {
|
||||
appName: string;
|
||||
allowOwnAccountEdit: boolean;
|
||||
allowUserSignups: 'disabled' | 'withToken' | 'open';
|
||||
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
|
||||
emailOneTimeAccessAsAdminEnabled: 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 UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled'> & {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,13 @@ export function getAuthRedirectPath(path: string, user: User | null) {
|
||||
const isAdmin = user?.isAdmin;
|
||||
|
||||
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 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 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 userStore from '$lib/stores/user-store';
|
||||
@@ -9,7 +10,6 @@
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
let isLoading = $state(false);
|
||||
@@ -49,10 +49,17 @@
|
||||
</p>
|
||||
{:else}
|
||||
<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>
|
||||
{/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()}
|
||||
</Button>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
@@ -22,11 +23,27 @@
|
||||
|
||||
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 = {
|
||||
appName: appConfig.appName,
|
||||
sessionDuration: appConfig.sessionDuration,
|
||||
emailsVerified: appConfig.emailsVerified,
|
||||
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
|
||||
allowUserSignups: appConfig.allowUserSignups,
|
||||
disableAnimations: appConfig.disableAnimations,
|
||||
accentColor: appConfig.accentColor
|
||||
};
|
||||
@@ -36,6 +53,7 @@
|
||||
sessionDuration: z.number().min(1).max(43200),
|
||||
emailsVerified: z.boolean(),
|
||||
allowOwnAccountEdit: z.boolean(),
|
||||
allowUserSignups: z.enum(['disabled', 'withToken', 'open']),
|
||||
disableAnimations: z.boolean(),
|
||||
accentColor: z.string()
|
||||
});
|
||||
@@ -62,13 +80,60 @@
|
||||
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
|
||||
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
|
||||
id="self-account-editing"
|
||||
label={m.enable_self_account_editing()}
|
||||
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
|
||||
<SwitchWithLabel
|
||||
id="emails-verified"
|
||||
label={m.emails_verified()}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<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 * as Card from '$lib/components/ui/card';
|
||||
import * as DropdownButton from '$lib/components/ui/dropdown-button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
@@ -15,8 +18,13 @@
|
||||
let { data } = $props();
|
||||
let users = $state(data.users);
|
||||
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 signupTokenModalOpen = $state(false);
|
||||
let signupTokenListModalOpen = $state(false);
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -33,6 +41,10 @@
|
||||
users = await userService.list(usersRequestOptions);
|
||||
return success;
|
||||
}
|
||||
|
||||
async function refreshSignupTokens() {
|
||||
signupTokens = await userService.listSignupTokens(signupTokensRequestOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -55,7 +67,30 @@
|
||||
>
|
||||
</div>
|
||||
{#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>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddUser = false)}>
|
||||
<LucideMinus class="size-5" />
|
||||
@@ -86,3 +121,11 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</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);
|
||||
return { users, usersRequestOptions };
|
||||
const signupTokensRequestOptions: SearchPaginationSortRequest = {
|
||||
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(),
|
||||
TOKEN_SIGN_IN: m.token_sign_in(),
|
||||
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(() => {
|
||||
|
||||
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',
|
||||
lastname: 'Cook',
|
||||
email: 'tim.cook@test.com',
|
||||
username: 'tim'
|
||||
username: 'tim',
|
||||
},
|
||||
craig: {
|
||||
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
|
||||
firstname: 'Craig',
|
||||
lastname: 'Federighi',
|
||||
email: 'craig.federighi@test.com',
|
||||
username: 'craig'
|
||||
username: 'craig',
|
||||
},
|
||||
steve: {
|
||||
firstname: 'Steve',
|
||||
lastname: 'Jobs',
|
||||
email: 'steve.jobs@test.com',
|
||||
username: 'steve'
|
||||
}
|
||||
username: 'steve',
|
||||
},
|
||||
};
|
||||
|
||||
export const oidcClients = {
|
||||
@@ -27,16 +27,16 @@ export const oidcClients = {
|
||||
name: 'Nextcloud',
|
||||
callbackUrl: 'http://nextcloud/auth/callback',
|
||||
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
|
||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
|
||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY',
|
||||
},
|
||||
immich: {
|
||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||
name: 'Immich',
|
||||
callbackUrl: 'http://immich/auth/callback',
|
||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
|
||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x',
|
||||
},
|
||||
federated: {
|
||||
id: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
||||
name: 'Federated',
|
||||
callbackUrl: 'http://federated/auth/callback',
|
||||
federatedJWT: {
|
||||
@@ -44,43 +44,43 @@ export const oidcClients = {
|
||||
audience: 'api://PocketID',
|
||||
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
||||
},
|
||||
accessCodes: ['federated']
|
||||
accessCodes: ['federated'],
|
||||
},
|
||||
pingvinShare: {
|
||||
name: 'Pingvin Share',
|
||||
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
|
||||
}
|
||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
|
||||
},
|
||||
};
|
||||
|
||||
export const userGroups = {
|
||||
developers: {
|
||||
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
|
||||
friendlyName: 'Developers',
|
||||
name: 'developers'
|
||||
name: 'developers',
|
||||
},
|
||||
designers: {
|
||||
id: 'adab18bf-f89d-4087-9ee1-70ff15b48211',
|
||||
friendlyName: 'Designers',
|
||||
name: 'designers'
|
||||
name: 'designers',
|
||||
},
|
||||
humanResources: {
|
||||
friendlyName: 'Human Resources',
|
||||
name: 'human_resources'
|
||||
}
|
||||
name: 'human_resources',
|
||||
},
|
||||
};
|
||||
|
||||
export const oneTimeAccessTokens = [
|
||||
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
|
||||
{ token: 'YCGDtftvsvYWiXd0', expired: true }
|
||||
{ token: 'YCGDtftvsvYWiXd0', expired: true },
|
||||
];
|
||||
|
||||
export const apiKeys = [
|
||||
{
|
||||
id: '5f1fa856-c164-4295-961e-175a0d22d725',
|
||||
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
|
||||
name: 'Test API Key'
|
||||
}
|
||||
name: 'Test API Key',
|
||||
},
|
||||
];
|
||||
|
||||
export const refreshTokens = [
|
||||
@@ -88,12 +88,47 @@ export const refreshTokens = [
|
||||
token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo',
|
||||
clientId: oidcClients.nextcloud.id,
|
||||
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
||||
expired: false
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4',
|
||||
clientId: oidcClients.nextcloud.id,
|
||||
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.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 }) => {
|
||||
await page.goto('/settings/admin/application-configuration');
|
||||
|
||||
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 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: 'admin_group' }).first()).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: 'test_group' })
|
||||
.getByRole('button', { name: 'Toggle menu' })
|
||||
.click();
|
||||
await page.getByRole('row', { name: 'test_group' }).getByRole('button', { name: 'Toggle menu' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
// Verify group source is LDAP
|
||||
@@ -81,10 +78,7 @@ test.describe('LDAP Integration', () => {
|
||||
await page.goto('/settings/admin/user-groups');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: 'test_group' })
|
||||
.getByRole('button', { name: 'Toggle menu' })
|
||||
.click();
|
||||
await page.getByRole('row', { name: 'test_group' }).getByRole('button', { name: 'Toggle menu' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
// 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