From 59ca6b26acfa1de8d374a88559223ed5f363e837 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sun, 21 Dec 2025 18:26:52 +0100 Subject: [PATCH] feat: add ability define user groups for sign up tokens (#1155) --- .../internal/controller/user_controller.go | 2 +- backend/internal/dto/signup_token_dto.go | 6 +- backend/internal/dto/user_dto.go | 19 ++- backend/internal/model/signup_token.go | 1 + backend/internal/service/e2etest_service.go | 3 + backend/internal/service/user_service.go | 63 +++++-- ...0251217000000_signup_token_groups.down.sql | 1 + .../20251217000000_signup_token_groups.up.sql | 8 + ...0000_one_time_access_device_token.down.sql | 8 +- ...000000_one_time_access_device_token.up.sql | 8 +- ...0251217000000_signup_token_groups.down.sql | 7 + .../20251217000000_signup_token_groups.up.sql | 14 ++ frontend/messages/en.json | 3 +- .../src/lib/components/form/form-input.svelte | 35 ++-- .../components/form/user-group-input.svelte | 50 ++++++ .../signup/signup-token-list-modal.svelte | 29 ++-- .../signup/signup-token-modal.svelte | 159 +++++++++++++----- frontend/src/lib/services/user-service.ts | 12 +- frontend/src/lib/types/signup-token.type.ts | 5 +- .../app-config-signup-defaults-form.svelte | 59 +------ .../routes/settings/admin/users/+page.svelte | 3 +- tests/data.ts | 2 +- tests/specs/user-signup.spec.ts | 56 +++++- 23 files changed, 391 insertions(+), 162 deletions(-) create mode 100644 backend/resources/migrations/postgres/20251217000000_signup_token_groups.down.sql create mode 100644 backend/resources/migrations/postgres/20251217000000_signup_token_groups.up.sql create mode 100644 backend/resources/migrations/sqlite/20251217000000_signup_token_groups.down.sql create mode 100644 backend/resources/migrations/sqlite/20251217000000_signup_token_groups.up.sql create mode 100644 frontend/src/lib/components/form/user-group-input.svelte diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index cf87720e..6a294a61 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -545,7 +545,7 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) { ttl = defaultSignupTokenDuration } - signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit) + signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/dto/signup_token_dto.go b/backend/internal/dto/signup_token_dto.go index 92bb374a..b6495de1 100644 --- a/backend/internal/dto/signup_token_dto.go +++ b/backend/internal/dto/signup_token_dto.go @@ -6,8 +6,9 @@ import ( ) type SignupTokenCreateDto struct { - TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` - UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` + TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"` + UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` + UserGroupIDs []string `json:"userGroupIds"` } type SignupTokenDto struct { @@ -16,5 +17,6 @@ type SignupTokenDto struct { ExpiresAt datatype.DateTime `json:"expiresAt"` UsageLimit int `json:"usageLimit"` UsageCount int `json:"usageCount"` + UserGroups []UserGroupDto `json:"userGroups"` CreatedAt datatype.DateTime `json:"createdAt"` } diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index 985b12d7..42781a35 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -23,15 +23,16 @@ type UserDto struct { } type UserCreateDto struct { - Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` - Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` - FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` - LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` - DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"` - IsAdmin bool `json:"isAdmin"` - Locale *string `json:"locale"` - Disabled bool `json:"disabled"` - LdapID string `json:"-"` + Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` + Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` + FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` + LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` + DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"` + IsAdmin bool `json:"isAdmin"` + Locale *string `json:"locale"` + Disabled bool `json:"disabled"` + UserGroupIds []string `json:"userGroupIds"` + LdapID string `json:"-"` } func (u UserCreateDto) Validate() error { diff --git a/backend/internal/model/signup_token.go b/backend/internal/model/signup_token.go index af1599aa..6ebbe9c1 100644 --- a/backend/internal/model/signup_token.go +++ b/backend/internal/model/signup_token.go @@ -13,6 +13,7 @@ type SignupToken struct { ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"` UsageLimit int `json:"usageLimit" sortable:"true"` UsageCount int `json:"usageCount" sortable:"true"` + UserGroups []UserGroup `gorm:"many2many:signup_tokens_user_groups;"` } func (st *SignupToken) IsExpired() bool { diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 5c5aee45..cc442251 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -344,6 +344,9 @@ func (s *TestService) SeedDatabase(baseURL string) error { ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)), UsageLimit: 1, UsageCount: 0, + UserGroups: []model.UserGroup{ + userGroups[0], + }, }, { Base: model.Base{ diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 260e9778..315de043 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -253,6 +253,18 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea return model.User{}, &common.UserEmailNotSetError{} } + var userGroups []model.UserGroup + if len(input.UserGroupIds) > 0 { + err := tx. + WithContext(ctx). + Where("id IN ?", input.UserGroupIds). + Find(&userGroups). + Error + if err != nil { + return model.User{}, err + } + } + user := model.User{ FirstName: input.FirstName, LastName: input.LastName, @@ -262,6 +274,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea IsAdmin: input.IsAdmin, Locale: input.Locale, Disabled: input.Disabled, + UserGroups: userGroups, } if input.LdapID != "" { user.LdapID = &input.LdapID @@ -285,7 +298,13 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea // Apply default groups and claims for new non-LDAP users if !isLdapSync { - if err := s.applySignupDefaults(ctx, &user, tx); err != nil { + if len(input.UserGroupIds) == 0 { + if err := s.applyDefaultGroups(ctx, &user, tx); err != nil { + return model.User{}, err + } + } + + if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil { return model.User{}, err } } @@ -293,10 +312,9 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea return user, nil } -func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error { +func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error { config := s.appConfigService.GetDbConfig() - // Apply default user groups var groupIDs []string v := config.SignupDefaultUserGroupIDs.Value if v != "" && v != "[]" { @@ -323,10 +341,14 @@ func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, } } } + return nil +} + +func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error { + config := s.appConfigService.GetDbConfig() - // Apply default custom claims var claims []dto.CustomClaimCreateDto - v = config.SignupDefaultCustomClaims.Value + v := config.SignupDefaultCustomClaims.Value if v != "" && v != "[]" { err := json.Unmarshal([]byte(v), &claims) if err != nil { @@ -727,12 +749,22 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user Error } -func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int) (model.SignupToken, error) { +func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) { signupToken, err := NewSignupToken(ttl, usageLimit) if err != nil { return model.SignupToken{}, err } + var userGroups []model.UserGroup + err = s.db.WithContext(ctx). + Where("id IN ?", userGroupIDs). + Find(&userGroups). + Error + if err != nil { + return model.SignupToken{}, err + } + signupToken.UserGroups = userGroups + err = s.db.WithContext(ctx).Create(signupToken).Error if err != nil { return model.SignupToken{}, err @@ -755,9 +787,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd } var signupToken model.SignupToken + var userGroupIDs []string if tokenProvided { err := tx. WithContext(ctx). + Preload("UserGroups"). Where("token = ?", signupData.Token). Clauses(clause.Locking{Strength: "UPDATE"}). First(&signupToken). @@ -772,14 +806,19 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd if !signupToken.IsValid() { return model.User{}, "", &common.TokenInvalidOrExpiredError{} } + + for _, group := range signupToken.UserGroups { + userGroupIDs = append(userGroupIDs, group.ID) + } } userToCreate := dto.UserCreateDto{ - Username: signupData.Username, - Email: signupData.Email, - FirstName: signupData.FirstName, - LastName: signupData.LastName, - DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName), + Username: signupData.Username, + Email: signupData.Email, + FirstName: signupData.FirstName, + LastName: signupData.LastName, + DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName), + UserGroupIds: userGroupIDs, } user, err := s.createUserInternal(ctx, userToCreate, false, tx) @@ -820,7 +859,7 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) { var tokens []model.SignupToken - query := s.db.WithContext(ctx).Model(&model.SignupToken{}) + query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{}) pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens) return tokens, pagination, err diff --git a/backend/resources/migrations/postgres/20251217000000_signup_token_groups.down.sql b/backend/resources/migrations/postgres/20251217000000_signup_token_groups.down.sql new file mode 100644 index 00000000..9db5aae4 --- /dev/null +++ b/backend/resources/migrations/postgres/20251217000000_signup_token_groups.down.sql @@ -0,0 +1 @@ +DROP TABLE signup_tokens_user_groups; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20251217000000_signup_token_groups.up.sql b/backend/resources/migrations/postgres/20251217000000_signup_token_groups.up.sql new file mode 100644 index 00000000..9d7b4175 --- /dev/null +++ b/backend/resources/migrations/postgres/20251217000000_signup_token_groups.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE signup_tokens_user_groups +( + signup_token_id UUID NOT NULL, + user_group_id UUID NOT NULL, + PRIMARY KEY (signup_token_id, user_group_id), + FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.down.sql b/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.down.sql index e8dba6e4..1c556747 100644 --- a/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.down.sql +++ b/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.down.sql @@ -1 +1,7 @@ -ALTER TABLE one_time_access_tokens DROP COLUMN device_token; \ No newline at end of file +PRAGMA foreign_keys=OFF; +BEGIN; + +ALTER TABLE one_time_access_tokens DROP COLUMN device_token; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.up.sql b/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.up.sql index 1aabc368..f563e91f 100644 --- a/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.up.sql +++ b/backend/resources/migrations/sqlite/20251210000000_one_time_access_device_token.up.sql @@ -1 +1,7 @@ -ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT; \ No newline at end of file +PRAGMA foreign_keys=OFF; +BEGIN; + +ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.down.sql b/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.down.sql new file mode 100644 index 00000000..9bf54307 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.down.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +DROP TABLE signup_tokens_user_groups; + +COMMIT; +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.up.sql b/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.up.sql new file mode 100644 index 00000000..b411a4a2 --- /dev/null +++ b/backend/resources/migrations/sqlite/20251217000000_signup_token_groups.up.sql @@ -0,0 +1,14 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +CREATE TABLE signup_tokens_user_groups +( + signup_token_id TEXT NOT NULL, + user_group_id TEXT NOT NULL, + PRIMARY KEY (signup_token_id, user_group_id), + FOREIGN KEY (signup_token_id) REFERENCES signup_tokens (id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE +); + +COMMIT; +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 12f285e6..1bf2716e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -470,5 +470,6 @@ "default_profile_picture": "Default Profile Picture", "light": "Light", "dark": "Dark", - "system": "System" + "system": "System", + "signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token." } diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index 0ba8e81b..5433ff62 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -8,6 +8,17 @@ import type { Snippet } from 'svelte'; import type { HTMLAttributes } from 'svelte/elements'; + type WithoutChildren = { + children?: undefined; + input?: FormInput; + labelFor?: never; + }; + type WithChildren = { + children: Snippet; + input?: any; + labelFor?: string; + }; + let { input = $bindable(), label, @@ -18,25 +29,25 @@ type = 'text', children, onInput, + labelFor, ...restProps - }: HTMLAttributes & { - input?: FormInput; - label?: string; - description?: string; - docsLink?: string; - placeholder?: string; - disabled?: boolean; - type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date'; - onInput?: (e: FormInputEvent) => void; - children?: Snippet; - } = $props(); + }: HTMLAttributes & + (WithChildren | WithoutChildren) & { + label?: string; + description?: string; + docsLink?: string; + placeholder?: string; + disabled?: boolean; + type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date'; + onInput?: (e: FormInputEvent) => void; + } = $props(); const id = label?.toLowerCase().replace(/ /g, '-');
{#if label} - + {/if} {#if description}

diff --git a/frontend/src/lib/components/form/user-group-input.svelte b/frontend/src/lib/components/form/user-group-input.svelte new file mode 100644 index 00000000..74e04eda --- /dev/null +++ b/frontend/src/lib/components/form/user-group-input.svelte @@ -0,0 +1,50 @@ + + + onUserGroupSearch(e.currentTarget.value)} + selectedItems={selectedGroupIds} + onSelect={(selected) => (selectedGroupIds = selected)} + {isLoading} + disableInternalSearch +/> 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 c1581694..98ce1caf 100644 --- a/frontend/src/lib/components/signup/signup-token-list-modal.svelte +++ b/frontend/src/lib/components/signup/signup-token-list-modal.svelte @@ -11,7 +11,7 @@ AdvancedTableColumn, CreateAdvancedTableActions } from '$lib/types/advanced-table.type'; - import type { SignupTokenDto } from '$lib/types/signup-token.type'; + import type { SignupToken } from '$lib/types/signup-token.type'; import { axiosErrorToast } from '$lib/utils/error-util'; import { Copy, Trash2 } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; @@ -23,14 +23,14 @@ } = $props(); const userService = new UserService(); - let tableRef: AdvancedTable; + let tableRef: AdvancedTable; function formatDate(dateStr: string | undefined) { if (!dateStr) return m.never(); return new Date(dateStr).toLocaleString(); } - async function deleteToken(token: SignupTokenDto) { + async function deleteToken(token: SignupToken) { openConfirmDialog({ title: m.delete_signup_token(), message: m.are_you_sure_you_want_to_delete_this_signup_token(), @@ -58,11 +58,11 @@ return new Date(expiresAt) < new Date(); } - function isTokenUsedUp(token: SignupTokenDto) { + function isTokenUsedUp(token: SignupToken) { return token.usageCount >= token.usageLimit; } - function getTokenStatus(token: SignupTokenDto) { + function getTokenStatus(token: SignupToken) { if (isTokenExpired(token.expiresAt)) return 'expired'; if (isTokenUsedUp(token)) return 'used-up'; return 'active'; @@ -79,7 +79,7 @@ } } - function copySignupLink(token: SignupTokenDto) { + function copySignupLink(token: SignupToken) { const signupLink = `${page.url.origin}/st/${token.token}`; navigator.clipboard .writeText(signupLink) @@ -91,7 +91,7 @@ }); } - const columns: AdvancedTableColumn[] = [ + const columns: AdvancedTableColumn[] = [ { label: m.token(), column: 'token', cell: TokenCell }, { label: m.status(), key: 'status', cell: StatusCell }, { @@ -106,7 +106,12 @@ sortable: true, value: (item) => formatDate(item.expiresAt) }, - { label: 'Usage Limit', column: 'usageLimit' }, + { + key: 'userGroups', + label: m.user_groups(), + value: (item) => item.userGroups.map((g) => g.name).join(', '), + hidden: true + }, { label: m.created(), column: 'createdAt', @@ -116,7 +121,7 @@ } ]; - const actions: CreateAdvancedTableActions = (_) => [ + const actions: CreateAdvancedTableActions = (_) => [ { label: m.copy(), icon: Copy, @@ -131,13 +136,13 @@ ]; -{#snippet TokenCell({ item }: { item: SignupTokenDto })} +{#snippet TokenCell({ item }: { item: SignupToken })} {item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))} {/snippet} -{#snippet StatusCell({ item }: { item: SignupTokenDto })} +{#snippet StatusCell({ item }: { item: SignupToken })} {@const status = getTokenStatus(item)} {@const statusBadge = getStatusBadge(status)} @@ -145,7 +150,7 @@ {/snippet} -{#snippet UsageCell({ item }: { item: SignupTokenDto })} +{#snippet UsageCell({ item }: { item: SignupToken })}

{item.usageCount} {m.of()} diff --git a/frontend/src/lib/components/signup/signup-token-modal.svelte b/frontend/src/lib/components/signup/signup-token-modal.svelte index fc948465..33e88e1d 100644 --- a/frontend/src/lib/components/signup/signup-token-modal.svelte +++ b/frontend/src/lib/components/signup/signup-token-modal.svelte @@ -1,16 +1,22 @@ @@ -66,49 +129,57 @@ {#if signupToken === null} -
-
- +
+ (selectedExpiration = v! as keyof typeof availableExpirations)} + value={$inputs.ttl.value.toString()} + onValueChange={(v) => v && form.setValue('ttl', Number(v))} > - {selectedExpiration} + {getExpirationLabel($inputs.ttl.value)} - {#each Object.keys(availableExpirations) as key} - {key} + {#each availableExpirations as expiration} + + {expiration.label} + {/each} -
- -
- -

- {m.number_of_times_token_can_be_used()} -

+ {#if $inputs.ttl.error} +

{$inputs.ttl.error}

+ {/if} + + -
-
- - - - + + + + + + + {:else}
-

{m.usage_limit()}: {usageLimit}

-

{m.expiration()}: {selectedExpiration}

+

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

+

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

{/if} diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index c53f4c4f..9f3163f0 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -1,6 +1,6 @@ import userStore from '$lib/stores/user-store'; import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; -import type { SignupTokenDto } from '$lib/types/signup-token.type'; +import type { SignupToken } from '$lib/types/signup-token.type'; import type { UserGroup } from '$lib/types/user-group.type'; import type { User, UserCreate, UserSignUp } from '$lib/types/user.type'; import { cachedProfilePicture } from '$lib/utils/cached-image-util'; @@ -76,8 +76,12 @@ export default class UserService extends APIService { return res.data.token; }; - createSignupToken = async (ttl: string | number, usageLimit: number) => { - const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit }); + createSignupToken = async ( + ttl: string | number, + usageLimit: number, + userGroupIds: string[] = [] + ) => { + const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds }); return res.data.token; }; @@ -111,7 +115,7 @@ export default class UserService extends APIService { listSignupTokens = async (options?: ListRequestOptions) => { const res = await this.api.get('/signup-tokens', { params: options }); - return res.data as Paginated; + return res.data as Paginated; }; deleteSignupToken = async (tokenId: string) => { diff --git a/frontend/src/lib/types/signup-token.type.ts b/frontend/src/lib/types/signup-token.type.ts index 1212f478..2d7206ff 100644 --- a/frontend/src/lib/types/signup-token.type.ts +++ b/frontend/src/lib/types/signup-token.type.ts @@ -1,8 +1,11 @@ -export interface SignupTokenDto { +import type { UserGroup } from './user-group.type'; + +export interface SignupToken { id: string; token: string; expiresAt: string; usageLimit: number; usageCount: number; + userGroups: UserGroup[]; createdAt: string; } diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte index ad4c0bde..33e81896 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-signup-defaults-form.svelte @@ -1,16 +1,13 @@
@@ -152,17 +111,7 @@

{m.user_creation_groups_description()}

- onUserGroupSearch(e.currentTarget.value)} - selectedItems={selectedGroups.map((g) => g.value)} - onSelect={(selected) => { - selectedGroups = userGroups.filter((g) => selected.includes(g.value)); - }} - isLoading={isUserSearchLoading} - disableInternalSearch - /> +
diff --git a/frontend/src/routes/settings/admin/users/+page.svelte b/frontend/src/routes/settings/admin/users/+page.svelte index d9fa9436..2708bbec 100644 --- a/frontend/src/routes/settings/admin/users/+page.svelte +++ b/frontend/src/routes/settings/admin/users/+page.svelte @@ -64,8 +64,7 @@ (expandAddUser = true)}> {selectedCreateOptions} - - + diff --git a/tests/data.ts b/tests/data.ts index 01b88b6e..236ffe0c 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -66,7 +66,7 @@ export const oidcClients = { export const userGroups = { developers: { - id: '4110f814-56f1-4b28-8998-752b69bc97c0e', + id: 'c7ae7c01-28a3-4f3c-9572-1ee734ea8368', friendlyName: 'Developers', name: 'developers' }, diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts index 9b7fb019..6454bf0d 100644 --- a/tests/specs/user-signup.spec.ts +++ b/tests/specs/user-signup.spec.ts @@ -1,9 +1,13 @@ import test, { expect, type Page } from '@playwright/test'; -import { signupTokens, users } from '../data'; +import { signupTokens, userGroups, users } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; import passkeyUtil from '../utils/passkey.util'; -async function setSignupMode(page: Page, mode: 'Disabled' | 'Signup with token' | 'Open Signup') { +async function setSignupMode( + page: Page, + mode: 'Disabled' | 'Signup with token' | 'Open Signup', + signout = true +) { await page.goto('/settings/admin/application-configuration'); await page.getByRole('button', { name: 'Expand card' }).nth(1).click(); @@ -15,10 +19,51 @@ async function setSignupMode(page: Page, mode: 'Disabled' | 'Signup with token' 'User creation settings updated successfully.' ); - await page.context().clearCookies(); - await page.goto('/login'); + if (signout) { + await page.context().clearCookies(); + await page.goto('/login'); + } } +test.describe('Signup Token Creation', () => { + test.beforeEach(async ({ page }) => { + await cleanupBackend(); + await setSignupMode(page, 'Signup with token', false); + }); + + test('Create signup token', async ({ page }) => { + await page.goto('/settings/admin/users'); + + await page.getByLabel('Create options').getByRole('button').click(); + await page.getByRole('menuitem', { name: 'Create Signup Token' }).click(); + await page.getByLabel('Expiration').click(); + await page.getByRole('option', { name: 'week' }).click(); + + await page.getByLabel('Usage Limit').fill('8'); + + await page.getByLabel('User Groups').click(); + await page.getByRole('option', { name: userGroups.developers.name }).click(); + await page.getByRole('option', { name: userGroups.designers.name }).click(); + await page.getByLabel('User Groups').click(); + + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + await page.getByLabel('Create options').getByRole('button').click(); + await page.getByRole('menuitem', { name: 'View Active Signup Tokens' }).click(); + await page.getByLabel('Manage Signup Tokens').getByRole('button', { name: 'View' }).click(); + + await page.getByRole('menuitemcheckbox', { name: 'User Groups' }).click(); + + const row = page.getByRole('row').last(); + await expect(row.getByRole('cell', { name: '0 of 8' })).toBeVisible(); + const dateInAWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US'); + await expect(row.getByRole('cell', { name: dateInAWeek })).toBeVisible(); + await expect(row.getByRole('cell', { name: userGroups.developers.name })).toBeVisible(); + await expect(row.getByRole('cell', { name: userGroups.designers.name })).toBeVisible(); + }); +}); + test.describe('Initial User Signup', () => { test.beforeEach(async ({ page }) => { await page.context().clearCookies(); @@ -74,6 +119,9 @@ test.describe('User Signup', () => { await page.waitForURL('/signup/add-passkey'); await expect(page.getByText('Set up your passkey')).toBeVisible(); + + const response = await page.request.get('/api/users/me').then((res) => res.json()); + expect(response.userGroups.map((g) => g.id)).toContain(userGroups.developers.id); }); test('Signup with token - invalid token shows error', async ({ page }) => {