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
|
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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
type SignupTokenCreateDto struct {
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignupTokenDto struct {
|
type SignupTokenDto struct {
|
||||||
@@ -16,5 +17,6 @@ type SignupTokenDto struct {
|
|||||||
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 []UserGroupDto `json:"userGroups"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type UserCreateDto struct {
|
|||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
|
UserGroupIds []string `json:"userGroupIds"`
|
||||||
LdapID string `json:"-"`
|
LdapID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type SignupToken struct {
|
|||||||
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 []UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *SignupToken) IsExpired() bool {
|
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)),
|
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||||
UsageLimit: 1,
|
UsageLimit: 1,
|
||||||
UsageCount: 0,
|
UsageCount: 0,
|
||||||
|
UserGroups: []model.UserGroup{
|
||||||
|
userGroups[0],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
|
|||||||
@@ -253,6 +253,18 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
return model.User{}, &common.UserEmailNotSetError{}
|
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{
|
user := model.User{
|
||||||
FirstName: input.FirstName,
|
FirstName: input.FirstName,
|
||||||
LastName: input.LastName,
|
LastName: input.LastName,
|
||||||
@@ -262,6 +274,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
IsAdmin: input.IsAdmin,
|
IsAdmin: input.IsAdmin,
|
||||||
Locale: input.Locale,
|
Locale: input.Locale,
|
||||||
Disabled: input.Disabled,
|
Disabled: input.Disabled,
|
||||||
|
UserGroups: userGroups,
|
||||||
}
|
}
|
||||||
if input.LdapID != "" {
|
if input.LdapID != "" {
|
||||||
user.LdapID = &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
|
// Apply default groups and claims for new non-LDAP users
|
||||||
if !isLdapSync {
|
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
|
return model.User{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,10 +312,9 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
|||||||
return user, nil
|
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()
|
config := s.appConfigService.GetDbConfig()
|
||||||
|
|
||||||
// Apply default user groups
|
|
||||||
var groupIDs []string
|
var groupIDs []string
|
||||||
v := config.SignupDefaultUserGroupIDs.Value
|
v := config.SignupDefaultUserGroupIDs.Value
|
||||||
if v != "" && v != "[]" {
|
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
|
var claims []dto.CustomClaimCreateDto
|
||||||
v = config.SignupDefaultCustomClaims.Value
|
v := config.SignupDefaultCustomClaims.Value
|
||||||
if v != "" && v != "[]" {
|
if v != "" && v != "[]" {
|
||||||
err := json.Unmarshal([]byte(v), &claims)
|
err := json.Unmarshal([]byte(v), &claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -727,12 +749,22 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
|
|||||||
Error
|
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)
|
signupToken, err := NewSignupToken(ttl, usageLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.SignupToken{}, err
|
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
|
err = s.db.WithContext(ctx).Create(signupToken).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.SignupToken{}, err
|
return model.SignupToken{}, err
|
||||||
@@ -755,9 +787,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
|||||||
}
|
}
|
||||||
|
|
||||||
var signupToken model.SignupToken
|
var signupToken model.SignupToken
|
||||||
|
var userGroupIDs []string
|
||||||
if tokenProvided {
|
if tokenProvided {
|
||||||
err := tx.
|
err := tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
|
Preload("UserGroups").
|
||||||
Where("token = ?", signupData.Token).
|
Where("token = ?", signupData.Token).
|
||||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
First(&signupToken).
|
First(&signupToken).
|
||||||
@@ -772,6 +806,10 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
|||||||
if !signupToken.IsValid() {
|
if !signupToken.IsValid() {
|
||||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, group := range signupToken.UserGroups {
|
||||||
|
userGroupIDs = append(userGroupIDs, group.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userToCreate := dto.UserCreateDto{
|
userToCreate := dto.UserCreateDto{
|
||||||
@@ -780,6 +818,7 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
|||||||
FirstName: signupData.FirstName,
|
FirstName: signupData.FirstName,
|
||||||
LastName: signupData.LastName,
|
LastName: signupData.LastName,
|
||||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||||
|
UserGroupIds: userGroupIDs,
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
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) {
|
func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
|
||||||
var tokens []model.SignupToken
|
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)
|
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
|
||||||
return tokens, pagination, err
|
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;
|
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;
|
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",
|
"default_profile_picture": "Default Profile Picture",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"dark": "Dark",
|
"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 { Snippet } from 'svelte';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
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 {
|
let {
|
||||||
input = $bindable(),
|
input = $bindable(),
|
||||||
label,
|
label,
|
||||||
@@ -18,9 +29,10 @@
|
|||||||
type = 'text',
|
type = 'text',
|
||||||
children,
|
children,
|
||||||
onInput,
|
onInput,
|
||||||
|
labelFor,
|
||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> &
|
||||||
input?: FormInput<string | boolean | number | Date | undefined>;
|
(WithChildren | WithoutChildren) & {
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
docsLink?: string;
|
docsLink?: string;
|
||||||
@@ -28,7 +40,6 @@
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
|
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
|
||||||
onInput?: (e: FormInputEvent) => void;
|
onInput?: (e: FormInputEvent) => void;
|
||||||
children?: Snippet;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const id = label?.toLowerCase().replace(/ /g, '-');
|
const id = label?.toLowerCase().replace(/ /g, '-');
|
||||||
@@ -36,7 +47,7 @@
|
|||||||
|
|
||||||
<div {...restProps}>
|
<div {...restProps}>
|
||||||
{#if label}
|
{#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}
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-muted-foreground mt-1 text-xs">
|
<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,
|
AdvancedTableColumn,
|
||||||
CreateAdvancedTableActions
|
CreateAdvancedTableActions
|
||||||
} from '$lib/types/advanced-table.type';
|
} 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 { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { Copy, Trash2 } from '@lucide/svelte';
|
import { Copy, Trash2 } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -23,14 +23,14 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
let tableRef: AdvancedTable<SignupTokenDto>;
|
let tableRef: AdvancedTable<SignupToken>;
|
||||||
|
|
||||||
function formatDate(dateStr: string | undefined) {
|
function formatDate(dateStr: string | undefined) {
|
||||||
if (!dateStr) return m.never();
|
if (!dateStr) return m.never();
|
||||||
return new Date(dateStr).toLocaleString();
|
return new Date(dateStr).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteToken(token: SignupTokenDto) {
|
async function deleteToken(token: SignupToken) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
title: m.delete_signup_token(),
|
title: m.delete_signup_token(),
|
||||||
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
|
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
return new Date(expiresAt) < new Date();
|
return new Date(expiresAt) < new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTokenUsedUp(token: SignupTokenDto) {
|
function isTokenUsedUp(token: SignupToken) {
|
||||||
return token.usageCount >= token.usageLimit;
|
return token.usageCount >= token.usageLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenStatus(token: SignupTokenDto) {
|
function getTokenStatus(token: SignupToken) {
|
||||||
if (isTokenExpired(token.expiresAt)) return 'expired';
|
if (isTokenExpired(token.expiresAt)) return 'expired';
|
||||||
if (isTokenUsedUp(token)) return 'used-up';
|
if (isTokenUsedUp(token)) return 'used-up';
|
||||||
return 'active';
|
return 'active';
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copySignupLink(token: SignupTokenDto) {
|
function copySignupLink(token: SignupToken) {
|
||||||
const signupLink = `${page.url.origin}/st/${token.token}`;
|
const signupLink = `${page.url.origin}/st/${token.token}`;
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.writeText(signupLink)
|
.writeText(signupLink)
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: AdvancedTableColumn<SignupTokenDto>[] = [
|
const columns: AdvancedTableColumn<SignupToken>[] = [
|
||||||
{ label: m.token(), column: 'token', cell: TokenCell },
|
{ label: m.token(), column: 'token', cell: TokenCell },
|
||||||
{ label: m.status(), key: 'status', cell: StatusCell },
|
{ label: m.status(), key: 'status', cell: StatusCell },
|
||||||
{
|
{
|
||||||
@@ -106,7 +106,12 @@
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
value: (item) => formatDate(item.expiresAt)
|
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(),
|
label: m.created(),
|
||||||
column: 'createdAt',
|
column: 'createdAt',
|
||||||
@@ -116,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const actions: CreateAdvancedTableActions<SignupTokenDto> = (_) => [
|
const actions: CreateAdvancedTableActions<SignupToken> = (_) => [
|
||||||
{
|
{
|
||||||
label: m.copy(),
|
label: m.copy(),
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
@@ -131,13 +136,13 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet TokenCell({ item }: { item: SignupTokenDto })}
|
{#snippet TokenCell({ item }: { item: SignupToken })}
|
||||||
<span class="font-mono text-xs">
|
<span class="font-mono text-xs">
|
||||||
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
|
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet StatusCell({ item }: { item: SignupTokenDto })}
|
{#snippet StatusCell({ item }: { item: SignupToken })}
|
||||||
{@const status = getTokenStatus(item)}
|
{@const status = getTokenStatus(item)}
|
||||||
{@const statusBadge = getStatusBadge(status)}
|
{@const statusBadge = getStatusBadge(status)}
|
||||||
<Badge class="rounded-full" variant={statusBadge.variant}>
|
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||||
@@ -145,7 +150,7 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet UsageCell({ item }: { item: SignupTokenDto })}
|
{#snippet UsageCell({ item }: { item: SignupToken })}
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
{item.usageCount}
|
{item.usageCount}
|
||||||
{m.of()}
|
{m.of()}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
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 Qrcode from '$lib/components/qrcode/qrcode.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import { Input } from '$lib/components/ui/input';
|
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 * as Select from '$lib/components/ui/select/index.js';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import AppConfigService from '$lib/services/app-config-service';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
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 { mode } from 'mode-watcher';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable()
|
open = $bindable()
|
||||||
@@ -19,29 +25,74 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const userService = new UserService();
|
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 signupToken: string | null = $state(null);
|
||||||
let signupLink: string | null = $state(null);
|
let signupLink: string | null = $state(null);
|
||||||
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_day());
|
let createdSignupData: SignupTokenForm | null = $state(null);
|
||||||
let usageLimit: number = $state(1);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
let availableExpirations = {
|
let defaultUserGroupIds: string[] = [];
|
||||||
[m.one_hour()]: 60 * 60,
|
|
||||||
[m.twelve_hours()]: 60 * 60 * 12,
|
function getExpirationLabel(ttl: number) {
|
||||||
[m.one_day()]: 60 * 60 * 24,
|
return availableExpirations.find((exp) => exp.value === ttl)?.label ?? '';
|
||||||
[m.one_week()]: 60 * 60 * 24 * 7,
|
}
|
||||||
[m.one_month()]: 60 * 60 * 24 * 30
|
|
||||||
};
|
function resetForm() {
|
||||||
|
form.reset();
|
||||||
|
form.setValue('userGroupIds', defaultUserGroupIds);
|
||||||
|
}
|
||||||
|
|
||||||
async function createSignupToken() {
|
async function createSignupToken() {
|
||||||
|
const data = form.validate();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
signupToken = await userService.createSignupToken(
|
signupToken = await userService.createSignupToken(
|
||||||
availableExpirations[selectedExpiration],
|
data.ttl,
|
||||||
usageLimit
|
data.usageLimit,
|
||||||
|
data.userGroupIds
|
||||||
);
|
);
|
||||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||||
|
createdSignupData = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +101,22 @@
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
signupToken = null;
|
signupToken = null;
|
||||||
signupLink = null;
|
signupLink = null;
|
||||||
selectedExpiration = m.one_day();
|
createdSignupData = null;
|
||||||
usageLimit = 1;
|
resetForm();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
appConfigService
|
||||||
|
.list(true)
|
||||||
|
.then((response) => {
|
||||||
|
const responseGroupIds = response.signupDefaultUserGroupIDs || [];
|
||||||
|
defaultUserGroupIds = responseGroupIds;
|
||||||
|
initialFormValues.userGroupIds = responseGroupIds;
|
||||||
|
form.setValue('userGroupIds', responseGroupIds);
|
||||||
|
})
|
||||||
|
.catch(axiosErrorToast);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root {open} {onOpenChange}>
|
<Dialog.Root {open} {onOpenChange}>
|
||||||
@@ -66,49 +129,57 @@
|
|||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
{#if signupToken === null}
|
{#if signupToken === null}
|
||||||
<div class="space-y-4">
|
<form class="space-y-4" onsubmit={preventDefault(createSignupToken)}>
|
||||||
<div>
|
<FormInput labelFor="expiration" label={m.expiration()} input={$inputs.ttl}>
|
||||||
<Label for="expiration">{m.expiration()}</Label>
|
|
||||||
<Select.Root
|
<Select.Root
|
||||||
type="single"
|
type="single"
|
||||||
value={Object.keys(availableExpirations)[0]}
|
value={$inputs.ttl.value.toString()}
|
||||||
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
|
onValueChange={(v) => v && form.setValue('ttl', Number(v))}
|
||||||
>
|
>
|
||||||
<Select.Trigger id="expiration" class="h-9 w-full">
|
<Select.Trigger id="expiration" class="h-9 w-full">
|
||||||
{selectedExpiration}
|
{getExpirationLabel($inputs.ttl.value)}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each Object.keys(availableExpirations) as key}
|
{#each availableExpirations as expiration}
|
||||||
<Select.Item value={key}>{key}</Select.Item>
|
<Select.Item value={expiration.value.toString()}>
|
||||||
|
{expiration.label}
|
||||||
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
{#if $inputs.ttl.error}
|
||||||
|
<p class="text-destructive mt-1 text-xs">{$inputs.ttl.error}</p>
|
||||||
<div>
|
{/if}
|
||||||
<Label class="mb-0" for="usage-limit">{m.usage_limit()}</Label>
|
</FormInput>
|
||||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
<FormInput
|
||||||
{m.number_of_times_token_can_be_used()}
|
labelFor="usage-limit"
|
||||||
</p>
|
label={m.usage_limit()}
|
||||||
|
description={m.number_of_times_token_can_be_used()}
|
||||||
|
input={$inputs.usageLimit}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="usage-limit"
|
id="usage-limit"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
bind:value={$inputs.usageLimit.value}
|
||||||
max="100"
|
aria-invalid={$inputs.usageLimit.error ? 'true' : undefined}
|
||||||
bind:value={usageLimit}
|
|
||||||
class="h-9"
|
class="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormInput>
|
||||||
</div>
|
<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">
|
<Dialog.Footer class="mt-4">
|
||||||
<Button
|
<Button type="submit" {isLoading}>
|
||||||
onclick={() => createSignupToken()}
|
|
||||||
disabled={!selectedExpiration || usageLimit < 1}
|
|
||||||
>
|
|
||||||
{m.create()}
|
{m.create()}
|
||||||
</Button>
|
</Button>
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<Qrcode
|
<Qrcode
|
||||||
@@ -125,8 +196,8 @@
|
|||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
|
|
||||||
<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()}: {usageLimit}</p>
|
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
|
||||||
<p>{m.expiration()}: {selectedExpiration}</p>
|
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 { 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 { UserGroup } from '$lib/types/user-group.type';
|
||||||
import type { User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
import type { User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
||||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||||
@@ -76,8 +76,12 @@ export default class UserService extends APIService {
|
|||||||
return res.data.token;
|
return res.data.token;
|
||||||
};
|
};
|
||||||
|
|
||||||
createSignupToken = async (ttl: string | number, usageLimit: number) => {
|
createSignupToken = async (
|
||||||
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit });
|
ttl: string | number,
|
||||||
|
usageLimit: number,
|
||||||
|
userGroupIds: string[] = []
|
||||||
|
) => {
|
||||||
|
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds });
|
||||||
return res.data.token;
|
return res.data.token;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,7 +115,7 @@ export default class UserService extends APIService {
|
|||||||
|
|
||||||
listSignupTokens = async (options?: ListRequestOptions) => {
|
listSignupTokens = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/signup-tokens', { params: options });
|
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) => {
|
deleteSignupToken = async (tokenId: string) => {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export interface SignupTokenDto {
|
import type { UserGroup } from './user-group.type';
|
||||||
|
|
||||||
|
export interface SignupToken {
|
||||||
id: string;
|
id: string;
|
||||||
token: string;
|
token: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
usageLimit: number;
|
usageLimit: number;
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
|
userGroups: UserGroup[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
|
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 { Button } from '$lib/components/ui/button';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||||
import { debounced } from '$lib/utils/debounce-util';
|
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,14 +18,10 @@
|
|||||||
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
|
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
|
||||||
|
|
||||||
let userGroups = $state<{ value: string; label: string }[]>([]);
|
|
||||||
let selectedGroups = $state<{ value: string; label: string }[]>([]);
|
|
||||||
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
|
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
|
||||||
let allowUserSignups = $state(appConfig.allowUserSignups);
|
let allowUserSignups = $state(appConfig.allowUserSignups);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isUserSearchLoading = $state(false);
|
|
||||||
|
|
||||||
const signupOptions = {
|
const signupOptions = {
|
||||||
disabled: {
|
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() {
|
async function onSubmit() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
await callback({
|
await callback({
|
||||||
allowUserSignups: allowUserSignups,
|
allowUserSignups: allowUserSignups,
|
||||||
signupDefaultUserGroupIDs: selectedGroups.map((g) => g.value),
|
signupDefaultUserGroupIDs: selectedGroupIds,
|
||||||
signupDefaultCustomClaims: customClaims
|
signupDefaultCustomClaims: customClaims
|
||||||
});
|
});
|
||||||
toast.success(m.user_creation_updated_successfully());
|
toast.success(m.user_creation_updated_successfully());
|
||||||
@@ -88,12 +50,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadSelectedGroups();
|
|
||||||
customClaims = appConfig.signupDefaultCustomClaims || [];
|
customClaims = appConfig.signupDefaultCustomClaims || [];
|
||||||
allowUserSignups = appConfig.allowUserSignups;
|
allowUserSignups = appConfig.allowUserSignups;
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => loadUserGroups());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={preventDefault(onSubmit)}>
|
<form onsubmit={preventDefault(onSubmit)}>
|
||||||
@@ -152,17 +111,7 @@
|
|||||||
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
<p class="text-muted-foreground mt-1 mb-2 text-xs">
|
||||||
{m.user_creation_groups_description()}
|
{m.user_creation_groups_description()}
|
||||||
</p>
|
</p>
|
||||||
<SearchableMultiSelect
|
<UserGroupInput bind:selectedGroupIds />
|
||||||
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
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label class="mb-0">{m.custom_claims()}</Label>
|
<Label class="mb-0">{m.custom_claims()}</Label>
|
||||||
|
|||||||
@@ -64,8 +64,7 @@
|
|||||||
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
|
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
|
||||||
{selectedCreateOptions}
|
{selectedCreateOptions}
|
||||||
</DropdownButton.Main>
|
</DropdownButton.Main>
|
||||||
|
<DropdownButton.DropdownTrigger aria-label="Create options">
|
||||||
<DropdownButton.DropdownTrigger>
|
|
||||||
<DropdownButton.Trigger class="border-l" />
|
<DropdownButton.Trigger class="border-l" />
|
||||||
</DropdownButton.DropdownTrigger>
|
</DropdownButton.DropdownTrigger>
|
||||||
</DropdownButton.Root>
|
</DropdownButton.Root>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const oidcClients = {
|
|||||||
|
|
||||||
export const userGroups = {
|
export const userGroups = {
|
||||||
developers: {
|
developers: {
|
||||||
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
|
id: 'c7ae7c01-28a3-4f3c-9572-1ee734ea8368',
|
||||||
friendlyName: 'Developers',
|
friendlyName: 'Developers',
|
||||||
name: 'developers'
|
name: 'developers'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import test, { expect, type Page } from '@playwright/test';
|
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 { cleanupBackend } from '../utils/cleanup.util';
|
||||||
import passkeyUtil from '../utils/passkey.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.goto('/settings/admin/application-configuration');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
|
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.'
|
'User creation settings updated successfully.'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (signout) {
|
||||||
await page.context().clearCookies();
|
await page.context().clearCookies();
|
||||||
await page.goto('/login');
|
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.describe('Initial User Signup', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.context().clearCookies();
|
await page.context().clearCookies();
|
||||||
@@ -74,6 +119,9 @@ test.describe('User Signup', () => {
|
|||||||
|
|
||||||
await page.waitForURL('/signup/add-passkey');
|
await page.waitForURL('/signup/add-passkey');
|
||||||
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
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 }) => {
|
test('Signup with token - invalid token shows error', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user