feat: add ability define user groups for sign up tokens (#1155)

This commit is contained in:
Elias Schneider
2025-12-21 18:26:52 +01:00
committed by GitHub
parent f5da11b99b
commit 59ca6b26ac
23 changed files with 391 additions and 162 deletions

View File

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

View File

@@ -8,6 +8,7 @@ import (
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"`
}
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"`
}

View File

@@ -31,6 +31,7 @@ type UserCreateDto struct {
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
}

View File

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

View File

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

View File

@@ -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,6 +806,10 @@ 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{
@@ -780,6 +818,7 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
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

View File

@@ -0,0 +1 @@
DROP TABLE signup_tokens_user_groups;

View File

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

View File

@@ -1 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE one_time_access_tokens DROP COLUMN device_token;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP TABLE signup_tokens_user_groups;
COMMIT;
PRAGMA foreign_keys=ON;

View File

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

View File

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

View File

@@ -8,6 +8,17 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type WithoutChildren = {
children?: undefined;
input?: FormInput<string | boolean | number | Date | undefined>;
labelFor?: never;
};
type WithChildren = {
children: Snippet;
input?: any;
labelFor?: string;
};
let {
input = $bindable(),
label,
@@ -18,9 +29,10 @@
type = 'text',
children,
onInput,
labelFor,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
input?: FormInput<string | boolean | number | Date | undefined>;
}: HTMLAttributes<HTMLDivElement> &
(WithChildren | WithoutChildren) & {
label?: string;
description?: string;
docsLink?: string;
@@ -28,7 +40,6 @@
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
onInput?: (e: FormInputEvent) => void;
children?: Snippet;
} = $props();
const id = label?.toLowerCase().replace(/ /g, '-');
@@ -36,7 +47,7 @@
<div {...restProps}>
{#if label}
<Label required={input?.required} class="mb-0" for={id}>{label}</Label>
<Label required={input?.required} class="mb-0" for={labelFor ?? id}>{label}</Label>
{/if}
{#if description}
<p class="text-muted-foreground mt-1 text-xs">

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import SearchableMultiSelect from '$lib/components/form/searchable-multi-select.svelte';
import UserGroupService from '$lib/services/user-group-service';
import { debounced } from '$lib/utils/debounce-util';
import { onMount } from 'svelte';
let {
selectedGroupIds = $bindable()
}: {
selectedGroupIds: string[];
} = $props();
const userGroupService = new UserGroupService();
let userGroups = $state<{ value: string; label: string }[]>([]);
let isLoading = $state(false);
async function loadUserGroups(search?: string) {
userGroups = (await userGroupService.list({ search })).data.map((group) => ({
value: group.id,
label: group.name
}));
// Ensure selected groups are still in the list
for (const selectedGroupId of selectedGroupIds) {
if (!userGroups.some((g) => g.value === selectedGroupId)) {
const group = await userGroupService.get(selectedGroupId);
userGroups.push({ value: group.id, label: group.name });
}
}
}
const onUserGroupSearch = debounced(
async (search: string) => await loadUserGroups(search),
300,
(loading) => (isLoading = loading)
);
onMount(() => loadUserGroups());
</script>
<SearchableMultiSelect
id="default-groups"
items={userGroups}
oninput={(e) => onUserGroupSearch(e.currentTarget.value)}
selectedItems={selectedGroupIds}
onSelect={(selected) => (selectedGroupIds = selected)}
{isLoading}
disableInternalSearch
/>

View File

@@ -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<SignupTokenDto>;
let tableRef: AdvancedTable<SignupToken>;
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<SignupTokenDto>[] = [
const columns: AdvancedTableColumn<SignupToken>[] = [
{ 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<SignupTokenDto> = (_) => [
const actions: CreateAdvancedTableActions<SignupToken> = (_) => [
{
label: m.copy(),
icon: Copy,
@@ -131,13 +136,13 @@
];
</script>
{#snippet TokenCell({ item }: { item: SignupTokenDto })}
{#snippet TokenCell({ item }: { item: SignupToken })}
<span class="font-mono text-xs">
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
</span>
{/snippet}
{#snippet StatusCell({ item }: { item: SignupTokenDto })}
{#snippet StatusCell({ item }: { item: SignupToken })}
{@const status = getTokenStatus(item)}
{@const statusBadge = getStatusBadge(status)}
<Badge class="rounded-full" variant={statusBadge.variant}>
@@ -145,7 +150,7 @@
</Badge>
{/snippet}
{#snippet UsageCell({ item }: { item: SignupTokenDto })}
{#snippet UsageCell({ item }: { item: SignupToken })}
<div class="flex items-center gap-1">
{item.usageCount}
{m.of()}

View File

@@ -1,16 +1,22 @@
<script lang="ts">
import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import UserGroupInput from '$lib/components/form/user-group-input.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 AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { mode } from 'mode-watcher';
import { onMount } from 'svelte';
import { z } from 'zod/v4';
let {
open = $bindable()
@@ -19,29 +25,74 @@
} = $props();
const userService = new UserService();
const appConfigService = new AppConfigService();
const DEFAULT_TTL_SECONDS = 60 * 60 * 24;
const availableExpirations = [
{ label: m.one_hour(), value: 60 * 60 },
{ label: m.twelve_hours(), value: 60 * 60 * 12 },
{ label: m.one_day(), value: DEFAULT_TTL_SECONDS },
{ label: m.one_week(), value: DEFAULT_TTL_SECONDS * 7 },
{ label: m.one_month(), value: DEFAULT_TTL_SECONDS * 30 }
] as const;
const defaultExpiration =
availableExpirations.find((exp) => exp.value === DEFAULT_TTL_SECONDS)?.value ??
availableExpirations[0].value;
type SignupTokenForm = {
ttl: number;
usageLimit: number;
userGroupIds: string[];
};
const initialFormValues: SignupTokenForm = {
ttl: defaultExpiration,
usageLimit: 1,
userGroupIds: []
};
const formSchema = z.object({
ttl: z.number(),
usageLimit: z.number().min(1).max(100),
userGroupIds: z.array(z.string()).default([])
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, initialFormValues);
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 createdSignupData: SignupTokenForm | null = $state(null);
let isLoading = $state(false);
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
};
let defaultUserGroupIds: string[] = [];
function getExpirationLabel(ttl: number) {
return availableExpirations.find((exp) => exp.value === ttl)?.label ?? '';
}
function resetForm() {
form.reset();
form.setValue('userGroupIds', defaultUserGroupIds);
}
async function createSignupToken() {
const data = form.validate();
if (!data) return;
isLoading = true;
try {
signupToken = await userService.createSignupToken(
availableExpirations[selectedExpiration],
usageLimit
data.ttl,
data.usageLimit,
data.userGroupIds
);
signupLink = `${page.url.origin}/st/${signupToken}`;
createdSignupData = data;
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
@@ -50,10 +101,22 @@
if (!isOpen) {
signupToken = null;
signupLink = null;
selectedExpiration = m.one_day();
usageLimit = 1;
createdSignupData = null;
resetForm();
}
}
onMount(() => {
appConfigService
.list(true)
.then((response) => {
const responseGroupIds = response.signupDefaultUserGroupIDs || [];
defaultUserGroupIds = responseGroupIds;
initialFormValues.userGroupIds = responseGroupIds;
form.setValue('userGroupIds', responseGroupIds);
})
.catch(axiosErrorToast);
});
</script>
<Dialog.Root {open} {onOpenChange}>
@@ -66,49 +129,57 @@
</Dialog.Header>
{#if signupToken === null}
<div class="space-y-4">
<div>
<Label for="expiration">{m.expiration()}</Label>
<form class="space-y-4" onsubmit={preventDefault(createSignupToken)}>
<FormInput labelFor="expiration" label={m.expiration()} input={$inputs.ttl}>
<Select.Root
type="single"
value={Object.keys(availableExpirations)[0]}
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
value={$inputs.ttl.value.toString()}
onValueChange={(v) => v && form.setValue('ttl', Number(v))}
>
<Select.Trigger id="expiration" class="h-9 w-full">
{selectedExpiration}
{getExpirationLabel($inputs.ttl.value)}
</Select.Trigger>
<Select.Content>
{#each Object.keys(availableExpirations) as key}
<Select.Item value={key}>{key}</Select.Item>
{#each availableExpirations as expiration}
<Select.Item value={expiration.value.toString()}>
{expiration.label}
</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>
{#if $inputs.ttl.error}
<p class="text-destructive mt-1 text-xs">{$inputs.ttl.error}</p>
{/if}
</FormInput>
<FormInput
labelFor="usage-limit"
label={m.usage_limit()}
description={m.number_of_times_token_can_be_used()}
input={$inputs.usageLimit}
>
<Input
id="usage-limit"
type="number"
min="1"
max="100"
bind:value={usageLimit}
bind:value={$inputs.usageLimit.value}
aria-invalid={$inputs.usageLimit.error ? 'true' : undefined}
class="h-9"
/>
</div>
</div>
</FormInput>
<FormInput
labelFor="default-groups"
label={m.user_groups()}
description={m.signup_token_user_groups_description()}
input={$inputs.userGroupIds}
>
<UserGroupInput bind:selectedGroupIds={$inputs.userGroupIds.value} />
</FormInput>
<Dialog.Footer class="mt-4">
<Button
onclick={() => createSignupToken()}
disabled={!selectedExpiration || usageLimit < 1}
>
<Button type="submit" {isLoading}>
{m.create()}
</Button>
</Dialog.Footer>
</form>
{:else}
<div class="flex flex-col items-center gap-2">
<Qrcode
@@ -125,8 +196,8 @@
</CopyToClipboard>
<div class="text-muted-foreground mt-2 text-center text-sm">
<p>{m.usage_limit()}: {usageLimit}</p>
<p>{m.expiration()}: {selectedExpiration}</p>
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
</div>
</div>
{/if}

View File

@@ -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<SignupTokenDto>;
return res.data as Paginated<SignupToken>;
};
deleteSignupToken = async (tokenId: string) => {

View File

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

View File

@@ -1,16 +1,13 @@
<script lang="ts">
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import SearchableMultiSelect from '$lib/components/form/searchable-multi-select.svelte';
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { debounced } from '$lib/utils/debounce-util';
import { preventDefault } from '$lib/utils/event-util';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
let {
@@ -21,14 +18,10 @@
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
const userGroupService = new UserGroupService();
let userGroups = $state<{ value: string; label: string }[]>([]);
let selectedGroups = $state<{ value: string; label: string }[]>([]);
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
let allowUserSignups = $state(appConfig.allowUserSignups);
let isLoading = $state(false);
let isUserSearchLoading = $state(false);
const signupOptions = {
disabled: {
@@ -45,42 +38,11 @@
}
};
async function loadUserGroups(search?: string) {
userGroups = (await userGroupService.list({ search })).data.map((group) => ({
value: group.id,
label: group.name
}));
// Ensure selected groups are still in the list
for (const selectedGroup of selectedGroups) {
if (!userGroups.some((g) => g.value === selectedGroup.value)) {
userGroups.push(selectedGroup);
}
}
}
async function loadSelectedGroups() {
selectedGroups = (
await Promise.all(
appConfig.signupDefaultUserGroupIDs.map((groupId) => userGroupService.get(groupId))
)
).map((group) => ({
value: group.id,
label: group.name
}));
}
const onUserGroupSearch = debounced(
async (search: string) => await loadUserGroups(search),
300,
(loading) => (isUserSearchLoading = loading)
);
async function onSubmit() {
isLoading = true;
await callback({
allowUserSignups: allowUserSignups,
signupDefaultUserGroupIDs: selectedGroups.map((g) => g.value),
signupDefaultUserGroupIDs: selectedGroupIds,
signupDefaultCustomClaims: customClaims
});
toast.success(m.user_creation_updated_successfully());
@@ -88,12 +50,9 @@
}
$effect(() => {
loadSelectedGroups();
customClaims = appConfig.signupDefaultCustomClaims || [];
allowUserSignups = appConfig.allowUserSignups;
});
onMount(() => loadUserGroups());
</script>
<form onsubmit={preventDefault(onSubmit)}>
@@ -152,17 +111,7 @@
<p class="text-muted-foreground mt-1 mb-2 text-xs">
{m.user_creation_groups_description()}
</p>
<SearchableMultiSelect
id="default-groups"
items={userGroups}
oninput={(e) => onUserGroupSearch(e.currentTarget.value)}
selectedItems={selectedGroups.map((g) => g.value)}
onSelect={(selected) => {
selectedGroups = userGroups.filter((g) => selected.includes(g.value));
}}
isLoading={isUserSearchLoading}
disableInternalSearch
/>
<UserGroupInput bind:selectedGroupIds />
</div>
<div>
<Label class="mb-0">{m.custom_claims()}</Label>

View File

@@ -64,8 +64,7 @@
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
{selectedCreateOptions}
</DropdownButton.Main>
<DropdownButton.DropdownTrigger>
<DropdownButton.DropdownTrigger aria-label="Create options">
<DropdownButton.Trigger class="border-l" />
</DropdownButton.DropdownTrigger>
</DropdownButton.Root>

View File

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

View File

@@ -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.'
);
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 }) => {