diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 14536541..e913733e 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -277,3 +277,13 @@ type InvalidEmailVerificationTokenError struct{} func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" } func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest } + +type EmailDomainNotAllowedError struct { + Domain string +} + +func (e EmailDomainNotAllowedError) Error() string { + return fmt.Sprintf("The email address must use the domain @%s", e.Domain) +} + +func (e EmailDomainNotAllowedError) HttpStatusCode() int { return http.StatusBadRequest } diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 0f4ab9f6..ece81a03 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -21,6 +21,9 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a- var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$") +// emailDomainRegex validates the domain part of an email address (e.g. "example.com" or "mail.example.co.uk") +var emailDomainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`) + func init() { engine := binding.Validator.Engine().(*validator.Validate) @@ -34,6 +37,9 @@ func init() { "client_id": func(fl validator.FieldLevel) bool { return ValidateClientID(fl.Field().String()) }, + "email_domain": func(fl validator.FieldLevel) bool { + return ValidateEmailDomain(fl.Field().String()) + }, "ttl": func(fl validator.FieldLevel) bool { ttl, ok := fl.Field().Interface().(utils.JSONDuration) if !ok { @@ -106,3 +112,22 @@ func ValidateCallbackURL(str string) bool { func ValidateCallbackURLPattern(raw string) bool { return utils.ValidateCallbackURLPattern(raw) == nil } + +// ValidateEmailDomain validates an optional email domain. +// It accepts inputs with or without a leading "@" +func ValidateEmailDomain(domain string) bool { + if domain == "" { + return false + } + + domain = strings.TrimPrefix(strings.ToLower(domain), "@") + if domain == "" { + return false + } + + if !emailDomainRegex.MatchString(domain) { + return false + } + + return true +} diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go index c752743e..291fc982 100644 --- a/backend/internal/dto/validations_test.go +++ b/backend/internal/dto/validations_test.go @@ -112,6 +112,42 @@ func TestValidateCallbackURL(t *testing.T) { } } +func TestValidateEmailDomain(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid simple", "example.com", true}, + {"valid with leading at", "@example.com", true}, + {"valid subdomain", "mail.example.co.uk", true}, + {"valid uppercase", "EXAMPLE.COM", true}, + {"valid with at and uppercase", "@Example.Com", true}, + {"valid with hyphen", "my-domain.com", true}, + {"valid with digits", "example123.com", true}, + {"valid single char label", "a.com", true}, + {"empty", "", false}, + {"only at", "@", false}, + {"no tld", "example", false}, + {"tld too short", "example.c", false}, + {"trailing dot", "example.com.", false}, + {"leading dot", ".example.com", false}, + {"double dot", "example..com", false}, + {"label starts with hyphen", "-example.com", false}, + {"label ends with hyphen", "example-.com", false}, + {"numeric tld", "example.123", false}, + {"contains space", "exa mple.com", false}, + {"double at", "@@example.com", false}, + {"trailing space", "example.com ", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, ValidateEmailDomain(tt.input)) + }) + } +} + func TestValidateCallbackURLPattern(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index bd32bc38..7c0937e0 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -486,6 +486,16 @@ func (s *TestService) SeedDatabase(baseURL string) error { UsageLimit: 1, UsageCount: 1, // Usage limit reached }, + { + Base: model.Base{ + ID: "b2c3d4e5-f6a7-4890-bcde-f01234567891", + }, + Token: "DOMAIN1234567890", + ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)), + UsageLimit: 5, + UsageCount: 0, + EmailDomain: new("example.com"), + }, } for _, token := range signupTokens { if err := tx.Create(&token).Error; err != nil { diff --git a/backend/internal/usersignup/dto.go b/backend/internal/usersignup/dto.go index b7a4aad4..772f7c88 100644 --- a/backend/internal/usersignup/dto.go +++ b/backend/internal/usersignup/dto.go @@ -18,14 +18,21 @@ type signupTokenCreateDto struct { TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` UserGroupIDs []string `json:"userGroupIds"` + EmailDomain *string `json:"emailDomain"` } type signupTokenDto struct { - ID string `json:"id"` - Token string `json:"token"` - ExpiresAt datatype.DateTime `json:"expiresAt"` - UsageLimit int `json:"usageLimit"` - UsageCount int `json:"usageCount"` - UserGroups []dto.UserGroupMinimalDto `json:"userGroups"` - CreatedAt datatype.DateTime `json:"createdAt"` + ID string `json:"id"` + Token string `json:"token"` + ExpiresAt datatype.DateTime `json:"expiresAt"` + UsageLimit int `json:"usageLimit"` + UsageCount int `json:"usageCount"` + EmailDomain *string `json:"emailDomain" binding:"omitempty,email_domain"` + UserGroups []dto.UserGroupMinimalDto `json:"userGroups"` + CreatedAt datatype.DateTime `json:"createdAt"` +} + +// signupTokenInfoDto exposes the limited, publicly readable metadata of a signup token +type signupTokenInfoDto struct { + EmailDomain *string `json:"emailDomain"` } diff --git a/backend/internal/usersignup/handler.go b/backend/internal/usersignup/handler.go index 5abdbcd7..848789ee 100644 --- a/backend/internal/usersignup/handler.go +++ b/backend/internal/usersignup/handler.go @@ -93,7 +93,7 @@ func (h *handler) createSignupToken(c *gin.Context) { ttl = defaultSignupTokenDuration } - signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) + signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs, input.EmailDomain) if err != nil { _ = c.Error(err) return @@ -159,6 +159,28 @@ func (h *handler) deleteSignupToken(c *gin.Context) { c.Status(http.StatusNoContent) } +// signupTokenInfoHandler godoc +// @Summary Get signup token info +// @Description Get the public metadata (such as the required email domain) of a signup token +// @Tags Users +// @Produce json +// @Param token path string true "Signup token" +// @Success 200 {object} signupTokenInfoDto +// @Router /api/signup/token/{token} [get] +func (h *handler) signupTokenInfo(c *gin.Context) { + token := c.Param("token") + + signupToken, err := h.service.GetSignupTokenInfo(c.Request.Context(), token) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, signupTokenInfoDto{ + EmailDomain: signupToken.EmailDomain, + }) +} + // signupHandler godoc // @Summary Sign up // @Description Create a new user account diff --git a/backend/internal/usersignup/models.go b/backend/internal/usersignup/models.go index ddbf5b3e..5dd708f0 100644 --- a/backend/internal/usersignup/models.go +++ b/backend/internal/usersignup/models.go @@ -1,6 +1,7 @@ package usersignup import ( + "strings" "time" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -11,11 +12,12 @@ import ( type SignupToken struct { model.Base - Token string `json:"token"` - ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"` - UsageLimit int `json:"usageLimit" sortable:"true"` - UsageCount int `json:"usageCount" sortable:"true"` - UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"` + Token string `json:"token"` + ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"` + UsageLimit int `json:"usageLimit" sortable:"true"` + UsageCount int `json:"usageCount" sortable:"true"` + EmailDomain *string `json:"emailDomain"` + UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"` } func (st *SignupToken) IsExpired() bool { @@ -29,3 +31,24 @@ func (st *SignupToken) IsUsageLimitReached() bool { func (st *SignupToken) IsValid() bool { return !st.IsExpired() && !st.IsUsageLimitReached() } + +// HasEmailDomainRestriction reports whether the token limits sign-ups to a specific email domain +func (st *SignupToken) HasEmailDomainRestriction() bool { + return st.EmailDomain != nil && *st.EmailDomain != "" +} + +// EmailMatchesDomain reports whether the given email address is allowed by the token's domain restriction +// It returns true when the token has no restriction +// The comparison is case-insensitive +func (st *SignupToken) EmailMatchesDomain(email string) bool { + if !st.HasEmailDomainRestriction() { + return true + } + + at := strings.LastIndexByte(email, '@') + if at < 0 { + return false + } + + return strings.EqualFold(email[at+1:], *st.EmailDomain) +} diff --git a/backend/internal/usersignup/models_test.go b/backend/internal/usersignup/models_test.go new file mode 100644 index 00000000..ee866efb --- /dev/null +++ b/backend/internal/usersignup/models_test.go @@ -0,0 +1,39 @@ +package usersignup + +import ( + "testing" +) + +func strPtr(s string) *string { + return &s +} + +func TestSignupTokenEmailMatchesDomain(t *testing.T) { + tests := []struct { + name string + emailDomain *string + email string + want bool + }{ + {name: "no restriction allows any email", emailDomain: nil, email: "user@anything.com", want: true}, + {name: "empty restriction allows any email", emailDomain: strPtr(""), email: "user@anything.com", want: true}, + {name: "matching domain", emailDomain: strPtr("example.com"), email: "user@example.com", want: true}, + {name: "matching domain case-insensitive", emailDomain: strPtr("example.com"), email: "User@Example.COM", want: true}, + {name: "non-matching domain", emailDomain: strPtr("example.com"), email: "user@other.com", want: false}, + {name: "subdomain does not match", emailDomain: strPtr("example.com"), email: "user@mail.example.com", want: false}, + {name: "domain suffix does not match", emailDomain: strPtr("example.com"), email: "user@notexample.com", want: false}, + {name: "missing @ with restriction", emailDomain: strPtr("example.com"), email: "userexample.com", want: false}, + {name: "empty email with restriction", emailDomain: strPtr("example.com"), email: "", want: false}, + {name: "plus addressing still matches", emailDomain: strPtr("example.com"), email: "user+tag@example.com", want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + st := &SignupToken{EmailDomain: tc.emailDomain} + got := st.EmailMatchesDomain(tc.email) + if got != tc.want { + t.Errorf("EmailMatchesDomain(%q) with domain %v = %v, want %v", tc.email, tc.emailDomain, got, tc.want) + } + }) + } +} diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go index 54f4b0b8..d2941868 100644 --- a/backend/internal/usersignup/module.go +++ b/backend/internal/usersignup/module.go @@ -55,6 +55,7 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRate apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens) apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken) apiGroup.POST("/signup", signupRateLimit, m.handler.signup) + apiGroup.GET("/signup/token/:token", signupRateLimit, m.handler.signupTokenInfo) apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable) apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin) } diff --git a/backend/internal/usersignup/service.go b/backend/internal/usersignup/service.go index 0ec3a76d..0b2fbabc 100644 --- a/backend/internal/usersignup/service.go +++ b/backend/internal/usersignup/service.go @@ -72,6 +72,16 @@ func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, u return model.User{}, "", &common.TokenInvalidOrExpiredError{} } + if signupToken.HasEmailDomainRestriction() { + email := "" + if signupData.Email != nil { + email = *signupData.Email + } + if !signupToken.EmailMatchesDomain(email) { + return model.User{}, "", &common.EmailDomainNotAllowedError{Domain: *signupToken.EmailDomain} + } + } + for _, group := range signupToken.UserGroups { userGroupIDs = append(userGroupIDs, group.ID) } @@ -190,8 +200,26 @@ func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error { return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error } -func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) { - signupToken, err := newSignupToken(ttl, usageLimit) +// GetSignupTokenInfo returns a signup token by its token string. +// It's used to expose the limited, public metadata (such as the required email domain) needed to render the signup form. +func (s *Service) GetSignupTokenInfo(ctx context.Context, token string) (SignupToken, error) { + var signupToken SignupToken + err := s.db. + WithContext(ctx). + Where("token = ?", token). + First(&signupToken). + Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return SignupToken{}, &common.TokenInvalidOrExpiredError{} + } else if err != nil { + return SignupToken{}, err + } + + return signupToken, nil +} + +func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string, emailDomain *string) (SignupToken, error) { + signupToken, err := newSignupToken(ttl, usageLimit, emailDomain) if err != nil { return SignupToken{}, err } @@ -214,7 +242,7 @@ func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usag return *signupToken, nil } -func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) { +func newSignupToken(ttl time.Duration, usageLimit int, emailDomain *string) (*SignupToken, error) { // Generate a random token randomString, err := utils.GenerateRandomAlphanumericString(16) if err != nil { @@ -223,10 +251,11 @@ func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) { now := time.Now().Round(time.Second) token := &SignupToken{ - Token: randomString, - ExpiresAt: datatype.DateTime(now.Add(ttl)), - UsageLimit: usageLimit, - UsageCount: 0, + Token: randomString, + ExpiresAt: datatype.DateTime(now.Add(ttl)), + UsageLimit: usageLimit, + UsageCount: 0, + EmailDomain: emailDomain, } return token, nil diff --git a/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.down.sql b/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.down.sql new file mode 100644 index 00000000..63fec39f --- /dev/null +++ b/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.down.sql @@ -0,0 +1 @@ +ALTER TABLE signup_tokens DROP COLUMN email_domain; diff --git a/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.up.sql b/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.up.sql new file mode 100644 index 00000000..7a76b28c --- /dev/null +++ b/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.up.sql @@ -0,0 +1 @@ +ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT; diff --git a/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.down.sql b/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.down.sql new file mode 100644 index 00000000..ee1b7fa8 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.down.sql @@ -0,0 +1,5 @@ +PRAGMA foreign_keys=OFF; +BEGIN; +ALTER TABLE signup_tokens DROP COLUMN email_domain; +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.up.sql b/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.up.sql new file mode 100644 index 00000000..58e63871 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.up.sql @@ -0,0 +1,5 @@ +PRAGMA foreign_keys=OFF; +BEGIN; +ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT; +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 92c7515e..9f3a78ef 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -406,6 +406,11 @@ "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.", + "email_domain": "Email Domain", + "signup_token_email_domain_description": "Optionally restrict signups with this token to email addresses of a specific domain (e.g. example.com). Leave empty to allow any domain.", + "invalid_email_domain": "Enter a valid domain, e.g. example.com", + "email_must_use_domain": "The email address must use the domain {domain}", + "email_domain_required_hint": "Must be an email address with the domain {domain}", "expires": "Expires", "signup": "Sign Up", "user_creation": "User Creation", diff --git a/frontend/src/lib/components/signup/signup-form.svelte b/frontend/src/lib/components/signup/signup-form.svelte index ea57f85c..10d62feb 100644 --- a/frontend/src/lib/components/signup/signup-form.svelte +++ b/frontend/src/lib/components/signup/signup-form.svelte @@ -12,10 +12,12 @@ let { callback, - isLoading + isLoading, + requiredEmailDomain }: { callback: (user: UserSignUp) => Promise; isLoading: boolean; + requiredEmailDomain?: string | null; } = $props(); const initialData: UserSignUp = { @@ -25,11 +27,19 @@ username: '' }; + const emailSchema = requiredEmailDomain + ? z.email().refine((v) => v.toLowerCase().endsWith(`@${requiredEmailDomain.toLowerCase()}`), { + message: m.email_must_use_domain({ domain: `@${requiredEmailDomain}` }) + }) + : get(appConfigStore).requireUserEmail + ? z.email() + : emptyToUndefined(z.email().optional()); + const formSchema = z.object({ firstName: z.string().max(50), lastName: emptyToUndefined(z.string().max(50).optional()), username: usernameSchema, - email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional()) + email: emailSchema }); type FormSchema = typeof formSchema; @@ -53,7 +63,15 @@
- +
diff --git a/frontend/src/lib/components/signup/signup-token-list-modal.svelte b/frontend/src/lib/components/signup/signup-token-list-modal.svelte index b5286bb6..34a8c26c 100644 --- a/frontend/src/lib/components/signup/signup-token-list-modal.svelte +++ b/frontend/src/lib/components/signup/signup-token-list-modal.svelte @@ -106,6 +106,11 @@ sortable: true, value: (item) => formatDate(item.expiresAt) }, + { + key: 'emailDomain', + label: m.email_domain(), + value: (item) => (item.emailDomain ? `@${item.emailDomain}` : '—') + }, { key: 'userGroups', label: m.user_groups(), diff --git a/frontend/src/lib/components/signup/signup-token-modal.svelte b/frontend/src/lib/components/signup/signup-token-modal.svelte index ab1f79fe..6483d8f0 100644 --- a/frontend/src/lib/components/signup/signup-token-modal.svelte +++ b/frontend/src/lib/components/signup/signup-token-modal.svelte @@ -44,18 +44,28 @@ ttl: number; usageLimit: number; userGroupIds: string[]; + emailDomain: string; }; const initialFormValues: SignupTokenForm = { ttl: defaultExpiration, usageLimit: 1, - userGroupIds: [] + userGroupIds: [], + emailDomain: '' }; const formSchema = z.object({ ttl: z.number(), usageLimit: z.number().min(1).max(100), - userGroupIds: z.array(z.string()).default([]) + userGroupIds: z.array(z.string()).default([]), + emailDomain: z + .string() + .trim() + .transform((v) => v.replace(/^@/, '')) + .refine((v) => v === '' || /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(v), { + message: m.invalid_email_domain() + }) + .default('') }); const { inputs, ...form } = createForm(formSchema, initialFormValues); @@ -85,7 +95,8 @@ signupToken = await userService.createSignupToken( data.ttl, data.usageLimit, - data.userGroupIds + data.userGroupIds, + data.emailDomain ); signupLink = `${page.url.origin}/st/${signupToken}`; createdSignupData = data; @@ -165,6 +176,21 @@ class="h-9" /> + + +

{m.usage_limit()}: {createdSignupData?.usageLimit}

{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}

+ {#if createdSignupData?.emailDomain} +

{m.email_domain()}: @{createdSignupData.emailDomain}

+ {/if}
{/if} diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index 230ecdd3..af9ecc9b 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -1,7 +1,7 @@ import userStore from '$lib/stores/user-store'; import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { Passkey } from '$lib/types/passkey.type'; -import type { SignupToken } from '$lib/types/signup-token.type'; +import type { SignupToken, SignupTokenInfo } from '$lib/types/signup-token.type'; import type { UserGroup } from '$lib/types/user-group.type'; import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type'; import { cachedProfilePicture } from '$lib/utils/cached-image-util'; @@ -89,12 +89,23 @@ export default class UserService extends APIService { createSignupToken = async ( ttl: string | number, usageLimit: number, - userGroupIds: string[] = [] + userGroupIds: string[] = [], + emailDomain?: string | null ) => { - const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds }); + const res = await this.api.post(`/signup-tokens`, { + ttl, + usageLimit, + userGroupIds, + emailDomain: emailDomain || undefined + }); return res.data.token; }; + getSignupTokenInfo = async (token: string) => { + const res = await this.api.get(`/signup/token/${token}`); + return res.data as SignupTokenInfo; + }; + exchangeOneTimeAccessToken = async (token: string) => { const res = await this.api.post(`/one-time-access-token/${token}`); return res.data as User; diff --git a/frontend/src/lib/types/signup-token.type.ts b/frontend/src/lib/types/signup-token.type.ts index 2d7206ff..a23b0980 100644 --- a/frontend/src/lib/types/signup-token.type.ts +++ b/frontend/src/lib/types/signup-token.type.ts @@ -6,6 +6,11 @@ export interface SignupToken { expiresAt: string; usageLimit: number; usageCount: number; + emailDomain?: string | null; userGroups: UserGroup[]; createdAt: string; } + +export interface SignupTokenInfo { + emailDomain?: string | null; +} diff --git a/frontend/src/routes/signup/+page.svelte b/frontend/src/routes/signup/+page.svelte index 9bdcf375..0218a7b1 100644 --- a/frontend/src/routes/signup/+page.svelte +++ b/frontend/src/routes/signup/+page.svelte @@ -75,7 +75,11 @@

{/if} {#if $appConfigStore.allowUserSignups === 'open' || data.token} - +
{m.back()} { + const token = url.searchParams.get('token') || undefined; + + let requiredEmailDomain: string | null = null; + if (token) { + // Best-effort lookup of the token's public metadata to hint the required email domain. + // Failures (e.g. an invalid or expired token) are ignored here; the signup request itself enforces validity. + const userService = new UserService(); + try { + const info = await userService.getSignupTokenInfo(token); + requiredEmailDomain = info.emailDomain ?? null; + } catch { + requiredEmailDomain = null; + } + } + return { - token: url.searchParams.get('token') || undefined + token, + requiredEmailDomain }; }; diff --git a/tests/data.ts b/tests/data.ts index 1371f438..a815b793 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -192,5 +192,14 @@ export const signupTokens = { usageLimit: 1, usageCount: 1, createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString() + }, + domainRestricted: { + id: 'b2c3d4e5-f6a7-4890-bcde-f01234567891', + token: 'DOMAIN1234567890', + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + usageLimit: 5, + usageCount: 0, + emailDomain: 'example.com', + createdAt: new Date().toISOString() } }; diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index 37ebf554..99af0235 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -271,7 +271,8 @@ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "token": "VALID1234567890A", "usage_count": 0, - "usage_limit": 1 + "usage_limit": 1, + "email_domain": null }, { "created_at": "2025-11-25T12:39:02Z", @@ -279,7 +280,8 @@ "id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf", "token": "PARTIAL567890ABC", "usage_count": 2, - "usage_limit": 5 + "usage_limit": 5, + "email_domain": null }, { "created_at": "2025-11-25T12:39:02Z", @@ -287,7 +289,8 @@ "id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f", "token": "EXPIRED34567890B", "usage_count": 1, - "usage_limit": 3 + "usage_limit": 3, + "email_domain": null }, { "created_at": "2025-11-25T12:39:02Z", @@ -295,7 +298,17 @@ "id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b", "token": "FULLYUSED567890C", "usage_count": 1, - "usage_limit": 1 + "usage_limit": 1, + "email_domain": null + }, + { + "created_at": "2025-11-25T12:39:02Z", + "expires_at": "2025-11-26T12:39:02Z", + "id": "b2c3d4e5-f6a7-4890-bcde-f01234567891", + "token": "DOMAIN1234567890", + "usage_count": 0, + "usage_limit": 5, + "email_domain": "example.com" } ], "signup_tokens_user_groups": [ diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts index 2871bfc1..cd1e725f 100644 --- a/tests/specs/user-signup.spec.ts +++ b/tests/specs/user-signup.spec.ts @@ -62,6 +62,20 @@ test.describe('Signup Token Creation', () => { await expect(row.getByRole('cell', { name: userGroups.developers.name })).toBeVisible(); await expect(row.getByRole('cell', { name: userGroups.designers.name })).toBeVisible(); }); + + test('Create signup token with email domain restriction', async ({ page }) => { + await page.goto('/settings/admin/users'); + + await page.getByRole('button', { name: 'Create options' }).click(); + await page.getByRole('menuitem', { name: 'Create Signup Token' }).click(); + + await page.getByLabel('Email Domain').fill('@example.com'); + + await page.getByRole('button', { name: 'Create', exact: true }).click(); + + // The success view shows the configured domain restriction + await expect(page.getByText('@example.com').first()).toBeVisible(); + }); }); test.describe('Initial User Signup', () => { @@ -241,6 +255,69 @@ test.describe('User Signup', () => { await expect(page.getByText('Passkey missing')).toBeVisible(); }); + test('Signup with token - email domain hint and placeholder are shown', async ({ page }) => { + await setSignupMode(page, 'Signup with token'); + + await page.goto(`/st/${signupTokens.domainRestricted.token}`); + + await expect( + page.getByText('Must be an email address with the domain @example.com') + ).toBeVisible(); + await expect(page.getByPlaceholder('you@example.com')).toBeVisible(); + }); + + test('Signup with token - wrong email domain shows validation error', async ({ page }) => { + await setSignupMode(page, 'Signup with token'); + + await page.goto(`/st/${signupTokens.domainRestricted.token}`); + + await page.getByLabel('First name').fill('Domain'); + await page.getByLabel('Last name').fill('User'); + await page.getByLabel('Username').fill('domainuser'); + await page.getByLabel('Email').fill('domain.user@wrong.com'); + + await page.getByRole('button', { name: 'Sign Up' }).click(); + + await expect( + page.getByText('The email address must use the domain @example.com') + ).toBeVisible(); + }); + + test('Signup with token - matching email domain succeeds', async ({ page }) => { + await setSignupMode(page, 'Signup with token'); + + await page.goto(`/st/${signupTokens.domainRestricted.token}`); + + await page.getByLabel('First name').fill('Domain'); + await page.getByLabel('Last name').fill('User'); + await page.getByLabel('Username').fill('domainuser'); + await page.getByLabel('Email').fill('domain.user@example.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 - server enforces email domain restriction', async ({ page }) => { + await setSignupMode(page, 'Signup with token'); + + // Bypass the client-side validation to verify the domain is enforced server-side + const res = await page.request.post('/api/signup', { + data: { + username: 'apidomainuser', + email: 'api.user@wrong.com', + firstName: 'Api', + lastName: 'User', + token: signupTokens.domainRestricted.token + } + }); + + expect(res.status()).toBe(400); + const body = await res.json(); + expect(body.error).toContain('example.com'); + }); + test('Token usage limit is enforced', async ({ page }) => { await setSignupMode(page, 'Signup with token');