mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-23 17:25:22 +03:00
feat: add ability define user groups for sign up tokens (#1155)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE signup_tokens_user_groups;
|
||||
@@ -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
|
||||
);
|
||||
@@ -1 +1,7 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE one_time_access_tokens DROP COLUMN device_token;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE one_time_access_tokens ADD COLUMN device_token TEXT;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE signup_tokens_user_groups;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -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;
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
50
frontend/src/lib/components/form/user-group-input.svelte
Normal file
50
frontend/src/lib/components/form/user-group-input.svelte
Normal 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
|
||||
/>
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user