feat: restrict signup invite links to a specific email domain

This commit is contained in:
ItalyPaleAle
2026-07-16 06:44:59 -07:00
parent 525946e94e
commit fdfaac8a35
25 changed files with 442 additions and 35 deletions
+10
View File
@@ -277,3 +277,13 @@ type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" } func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest } 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 }
+25
View File
@@ -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._-]+$") 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() { func init() {
engine := binding.Validator.Engine().(*validator.Validate) engine := binding.Validator.Engine().(*validator.Validate)
@@ -34,6 +37,9 @@ func init() {
"client_id": func(fl validator.FieldLevel) bool { "client_id": func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String()) return ValidateClientID(fl.Field().String())
}, },
"email_domain": func(fl validator.FieldLevel) bool {
return ValidateEmailDomain(fl.Field().String())
},
"ttl": func(fl validator.FieldLevel) bool { "ttl": func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration) ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok { if !ok {
@@ -106,3 +112,22 @@ func ValidateCallbackURL(str string) bool {
func ValidateCallbackURLPattern(raw string) bool { func ValidateCallbackURLPattern(raw string) bool {
return utils.ValidateCallbackURLPattern(raw) == nil 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
}
+36
View File
@@ -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) { func TestValidateCallbackURLPattern(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -486,6 +486,16 @@ func (s *TestService) SeedDatabase(baseURL string) error {
UsageLimit: 1, UsageLimit: 1,
UsageCount: 1, // Usage limit reached 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 { for _, token := range signupTokens {
if err := tx.Create(&token).Error; err != nil { if err := tx.Create(&token).Error; err != nil {
+14 -7
View File
@@ -18,14 +18,21 @@ type signupTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
UserGroupIDs []string `json:"userGroupIds"` UserGroupIDs []string `json:"userGroupIds"`
EmailDomain *string `json:"emailDomain"`
} }
type signupTokenDto struct { type signupTokenDto struct {
ID string `json:"id"` ID string `json:"id"`
Token string `json:"token"` Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt"` ExpiresAt datatype.DateTime `json:"expiresAt"`
UsageLimit int `json:"usageLimit"` UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"` UsageCount int `json:"usageCount"`
UserGroups []dto.UserGroupMinimalDto `json:"userGroups"` EmailDomain *string `json:"emailDomain" binding:"omitempty,email_domain"`
CreatedAt datatype.DateTime `json:"createdAt"` 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"`
} }
+23 -1
View File
@@ -93,7 +93,7 @@ func (h *handler) createSignupToken(c *gin.Context) {
ttl = defaultSignupTokenDuration 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 { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -159,6 +159,28 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
c.Status(http.StatusNoContent) 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 // signupHandler godoc
// @Summary Sign up // @Summary Sign up
// @Description Create a new user account // @Description Create a new user account
+28 -5
View File
@@ -1,6 +1,7 @@
package usersignup package usersignup
import ( import (
"strings"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
@@ -11,11 +12,12 @@ import (
type SignupToken struct { type SignupToken struct {
model.Base model.Base
Token string `json:"token"` Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"` ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
UsageLimit int `json:"usageLimit" sortable:"true"` UsageLimit int `json:"usageLimit" sortable:"true"`
UsageCount int `json:"usageCount" sortable:"true"` UsageCount int `json:"usageCount" sortable:"true"`
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"` EmailDomain *string `json:"emailDomain"`
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
} }
func (st *SignupToken) IsExpired() bool { func (st *SignupToken) IsExpired() bool {
@@ -29,3 +31,24 @@ func (st *SignupToken) IsUsageLimitReached() bool {
func (st *SignupToken) IsValid() bool { func (st *SignupToken) IsValid() bool {
return !st.IsExpired() && !st.IsUsageLimitReached() 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)
}
@@ -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)
}
})
}
}
+1
View File
@@ -55,6 +55,7 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRate
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens) apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken) apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
apiGroup.POST("/signup", signupRateLimit, m.handler.signup) apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
apiGroup.GET("/signup/token/:token", signupRateLimit, m.handler.signupTokenInfo)
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable) apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin) apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
} }
+36 -7
View File
@@ -72,6 +72,16 @@ func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, u
return model.User{}, "", &common.TokenInvalidOrExpiredError{} 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 { for _, group := range signupToken.UserGroups {
userGroupIDs = append(userGroupIDs, group.ID) 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 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) { // GetSignupTokenInfo returns a signup token by its token string.
signupToken, err := newSignupToken(ttl, usageLimit) // 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 { if err != nil {
return SignupToken{}, err return SignupToken{}, err
} }
@@ -214,7 +242,7 @@ func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usag
return *signupToken, nil 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 // Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16) randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil { if err != nil {
@@ -223,10 +251,11 @@ func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
now := time.Now().Round(time.Second) now := time.Now().Round(time.Second)
token := &SignupToken{ token := &SignupToken{
Token: randomString, Token: randomString,
ExpiresAt: datatype.DateTime(now.Add(ttl)), ExpiresAt: datatype.DateTime(now.Add(ttl)),
UsageLimit: usageLimit, UsageLimit: usageLimit,
UsageCount: 0, UsageCount: 0,
EmailDomain: emailDomain,
} }
return token, nil return token, nil
@@ -0,0 +1 @@
ALTER TABLE signup_tokens DROP COLUMN email_domain;
@@ -0,0 +1 @@
ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE signup_tokens DROP COLUMN email_domain;
COMMIT;
PRAGMA foreign_keys=ON;
@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
COMMIT;
PRAGMA foreign_keys=ON;
+5
View File
@@ -406,6 +406,11 @@
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit", "usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "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", "expires": "Expires",
"signup": "Sign Up", "signup": "Sign Up",
"user_creation": "User Creation", "user_creation": "User Creation",
@@ -12,10 +12,12 @@
let { let {
callback, callback,
isLoading isLoading,
requiredEmailDomain
}: { }: {
callback: (user: UserSignUp) => Promise<boolean>; callback: (user: UserSignUp) => Promise<boolean>;
isLoading: boolean; isLoading: boolean;
requiredEmailDomain?: string | null;
} = $props(); } = $props();
const initialData: UserSignUp = { const initialData: UserSignUp = {
@@ -25,11 +27,19 @@
username: '' 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({ const formSchema = z.object({
firstName: z.string().max(50), firstName: z.string().max(50),
lastName: emptyToUndefined(z.string().max(50).optional()), lastName: emptyToUndefined(z.string().max(50).optional()),
username: usernameSchema, username: usernameSchema,
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional()) email: emailSchema
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;
@@ -53,7 +63,15 @@
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full"> <form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
<div class="mt-7 space-y-4"> <div class="mt-7 space-y-4">
<FormInput label={m.username()} bind:input={$inputs.username} /> <FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label={m.email()} bind:input={$inputs.email} type="email" /> <FormInput
label={m.email()}
bind:input={$inputs.email}
type="email"
placeholder={requiredEmailDomain ? `you@${requiredEmailDomain}` : undefined}
description={requiredEmailDomain
? m.email_domain_required_hint({ domain: `@${requiredEmailDomain}` })
: undefined}
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} /> <FormInput label={m.first_name()} bind:input={$inputs.firstName} />
@@ -106,6 +106,11 @@
sortable: true, sortable: true,
value: (item) => formatDate(item.expiresAt) value: (item) => formatDate(item.expiresAt)
}, },
{
key: 'emailDomain',
label: m.email_domain(),
value: (item) => (item.emailDomain ? `@${item.emailDomain}` : '—')
},
{ {
key: 'userGroups', key: 'userGroups',
label: m.user_groups(), label: m.user_groups(),
@@ -44,18 +44,28 @@
ttl: number; ttl: number;
usageLimit: number; usageLimit: number;
userGroupIds: string[]; userGroupIds: string[];
emailDomain: string;
}; };
const initialFormValues: SignupTokenForm = { const initialFormValues: SignupTokenForm = {
ttl: defaultExpiration, ttl: defaultExpiration,
usageLimit: 1, usageLimit: 1,
userGroupIds: [] userGroupIds: [],
emailDomain: ''
}; };
const formSchema = z.object({ const formSchema = z.object({
ttl: z.number(), ttl: z.number(),
usageLimit: z.number().min(1).max(100), 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<typeof formSchema>(formSchema, initialFormValues); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, initialFormValues);
@@ -85,7 +95,8 @@
signupToken = await userService.createSignupToken( signupToken = await userService.createSignupToken(
data.ttl, data.ttl,
data.usageLimit, data.usageLimit,
data.userGroupIds data.userGroupIds,
data.emailDomain
); );
signupLink = `${page.url.origin}/st/${signupToken}`; signupLink = `${page.url.origin}/st/${signupToken}`;
createdSignupData = data; createdSignupData = data;
@@ -165,6 +176,21 @@
class="h-9" class="h-9"
/> />
</FormInput> </FormInput>
<FormInput
labelFor="email-domain"
label={m.email_domain()}
description={m.signup_token_email_domain_description()}
input={$inputs.emailDomain}
>
<Input
id="email-domain"
type="text"
placeholder="example.com"
bind:value={$inputs.emailDomain.value}
aria-invalid={$inputs.emailDomain.error ? 'true' : undefined}
class="h-9"
/>
</FormInput>
<FormInput <FormInput
labelFor="default-groups" labelFor="default-groups"
label={m.user_groups()} label={m.user_groups()}
@@ -197,6 +223,9 @@
<div class="text-muted-foreground mt-2 text-center text-sm"> <div class="text-muted-foreground mt-2 text-center text-sm">
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p> <p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p> <p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
{#if createdSignupData?.emailDomain}
<p>{m.email_domain()}: @{createdSignupData.emailDomain}</p>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
+14 -3
View File
@@ -1,7 +1,7 @@
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type { Passkey } from '$lib/types/passkey.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 { UserGroup } from '$lib/types/user-group.type';
import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type'; import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type';
import { cachedProfilePicture } from '$lib/utils/cached-image-util'; import { cachedProfilePicture } from '$lib/utils/cached-image-util';
@@ -89,12 +89,23 @@ export default class UserService extends APIService {
createSignupToken = async ( createSignupToken = async (
ttl: string | number, ttl: string | number,
usageLimit: 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; 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) => { exchangeOneTimeAccessToken = async (token: string) => {
const res = await this.api.post(`/one-time-access-token/${token}`); const res = await this.api.post(`/one-time-access-token/${token}`);
return res.data as User; return res.data as User;
@@ -6,6 +6,11 @@ export interface SignupToken {
expiresAt: string; expiresAt: string;
usageLimit: number; usageLimit: number;
usageCount: number; usageCount: number;
emailDomain?: string | null;
userGroups: UserGroup[]; userGroups: UserGroup[];
createdAt: string; createdAt: string;
} }
export interface SignupTokenInfo {
emailDomain?: string | null;
}
+5 -1
View File
@@ -75,7 +75,11 @@
</p> </p>
{/if} {/if}
{#if $appConfigStore.allowUserSignups === 'open' || data.token} {#if $appConfigStore.allowUserSignups === 'open' || data.token}
<SignupForm callback={handleSignup} {isLoading} /> <SignupForm
callback={handleSignup}
{isLoading}
requiredEmailDomain={data.requiredEmailDomain}
/>
<div class="mt-10 flex w-full items-center justify-between gap-2"> <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" <a class="text-muted-foreground mt-5 flex text-sm" href="/login"
><LucideChevronLeft class="size-5" /> {m.back()}</a ><LucideChevronLeft class="size-5" /> {m.back()}</a
+18 -1
View File
@@ -1,7 +1,24 @@
import UserService from '$lib/services/user-service';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => { export const load: PageLoad = async ({ url }) => {
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 { return {
token: url.searchParams.get('token') || undefined token,
requiredEmailDomain
}; };
}; };
+9
View File
@@ -192,5 +192,14 @@ export const signupTokens = {
usageLimit: 1, usageLimit: 1,
usageCount: 1, usageCount: 1,
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString() 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()
} }
}; };
+17 -4
View File
@@ -271,7 +271,8 @@
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"token": "VALID1234567890A", "token": "VALID1234567890A",
"usage_count": 0, "usage_count": 0,
"usage_limit": 1 "usage_limit": 1,
"email_domain": null
}, },
{ {
"created_at": "2025-11-25T12:39:02Z", "created_at": "2025-11-25T12:39:02Z",
@@ -279,7 +280,8 @@
"id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf", "id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
"token": "PARTIAL567890ABC", "token": "PARTIAL567890ABC",
"usage_count": 2, "usage_count": 2,
"usage_limit": 5 "usage_limit": 5,
"email_domain": null
}, },
{ {
"created_at": "2025-11-25T12:39:02Z", "created_at": "2025-11-25T12:39:02Z",
@@ -287,7 +289,8 @@
"id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f", "id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
"token": "EXPIRED34567890B", "token": "EXPIRED34567890B",
"usage_count": 1, "usage_count": 1,
"usage_limit": 3 "usage_limit": 3,
"email_domain": null
}, },
{ {
"created_at": "2025-11-25T12:39:02Z", "created_at": "2025-11-25T12:39:02Z",
@@ -295,7 +298,17 @@
"id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b", "id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
"token": "FULLYUSED567890C", "token": "FULLYUSED567890C",
"usage_count": 1, "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": [ "signup_tokens_user_groups": [
+77
View File
@@ -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.developers.name })).toBeVisible();
await expect(row.getByRole('cell', { name: userGroups.designers.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', () => { test.describe('Initial User Signup', () => {
@@ -241,6 +255,69 @@ test.describe('User Signup', () => {
await expect(page.getByText('Passkey missing')).toBeVisible(); 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 }) => { test('Token usage limit is enforced', async ({ page }) => {
await setSignupMode(page, 'Signup with token'); await setSignupMode(page, 'Signup with token');