1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 11:36:46 +00:00

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

@@ -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,25 +29,25 @@
type = 'text',
children,
onInput,
labelFor,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
input?: FormInput<string | boolean | number | Date | undefined>;
label?: string;
description?: string;
docsLink?: string;
placeholder?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
onInput?: (e: FormInputEvent) => void;
children?: Snippet;
} = $props();
}: HTMLAttributes<HTMLDivElement> &
(WithChildren | WithoutChildren) & {
label?: string;
description?: string;
docsLink?: string;
placeholder?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
onInput?: (e: FormInputEvent) => void;
} = $props();
const id = label?.toLowerCase().replace(/ /g, '-');
</script>
<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>
<Dialog.Footer class="mt-4">
<Button
onclick={() => createSignupToken()}
disabled={!selectedExpiration || usageLimit < 1}
</FormInput>
<FormInput
labelFor="default-groups"
label={m.user_groups()}
description={m.signup_token_user_groups_description()}
input={$inputs.userGroupIds}
>
{m.create()}
</Button>
</Dialog.Footer>
<UserGroupInput bind:selectedGroupIds={$inputs.userGroupIds.value} />
</FormInput>
<Dialog.Footer class="mt-4">
<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>