feat: self-service user signup (#672)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-06-27 15:01:10 -05:00
committed by GitHub
parent 1fdb058386
commit dcd1ae96e0
49 changed files with 7366 additions and 5729 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View 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>

View File

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

View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
};

View File

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

View File

@@ -1,6 +1,7 @@
export type AppConfig = {
appName: string;
allowOwnAccountEdit: boolean;
allowUserSignups: 'disabled' | 'withToken' | 'open';
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
emailOneTimeAccessAsAdminEnabled: boolean;
ldapEnabled: boolean;

View File

@@ -0,0 +1,8 @@
export interface SignupTokenDto {
id: string;
token: string;
expiresAt: string;
usageLimit: number;
usageCount: number;
createdAt: string;
}

View File

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

View File

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

View 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 };
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View 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>

View File

@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
return {
token: url.searchParams.get('token') || undefined
};
};

View 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>

View 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()}`);
};

View File

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

View File

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

View 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();
});
});