1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-15 08:45:14 +00:00

feat(signup): add default user groups and claims for new users (#812)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Zeedif
2025-08-22 06:25:02 -06:00
committed by GitHub
parent c51265dafb
commit 182d809028
18 changed files with 735 additions and 308 deletions

View File

@@ -387,6 +387,12 @@
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"user_creation": "User Creation",
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
"user_creation_groups_description": "Assign these groups automatically to new users upon signup.",
"user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.",
"user_creation_updated_successfully": "User creation settings updated successfully.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
@@ -398,7 +404,7 @@
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"enable_user_signups_description": "Decide how users can sign up for new accounts in Pocket ID.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
@@ -414,7 +420,6 @@
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",

View File

@@ -385,6 +385,12 @@
"number_of_times_token_can_be_used": "Número de veces que se puede utilizar el token de registro.",
"expires": "Caduca",
"signup": "Regístrate",
"user_creation": "Registro",
"configure_user_creation": "Gestiona la configuración de registro de usuarios, incluyendo los métodos de registro y los permisos por defecto para nuevos usuarios.",
"user_creation_groups_description": "Asigna estos grupos automáticamente a los nuevos usuarios al registrarse.",
"user_creation_claims_description": "Asigna estas reclamaciones personalizadas automáticamente a los nuevos usuarios al registrarse.",
"user_creation_updated_successfully": "Configuración de registro actualizada correctamente.",
"signup_disabled_description": "El registro de usuarios está completamente desactivado. Solo los administradores pueden crear nuevas cuentas de usuario.",
"signup_requires_valid_token": "Se requiere un token de registro válido para crear una cuenta.",
"validating_signup_token": "Validación del token de registro",
"go_to_login": "Ir al inicio de sesión",
@@ -412,7 +418,6 @@
"loading": "Cargando",
"delete_signup_token": "Eliminar token de registro",
"are_you_sure_you_want_to_delete_this_signup_token": "¿Estás seguro de que deseas eliminar este token de registro? Esta acción no se puede deshacer.",
"signup_disabled_description": "El registro de usuarios está completamente desactivado. Solo los administradores pueden crear nuevas cuentas de usuario.",
"signup_with_token": "Regístrate con token",
"signup_with_token_description": "Los usuarios solo pueden registrarse utilizando un token de registro válido creado por un administrador.",
"signup_open": "Inscripción abierta",

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/utils/style';
import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
import type { FormEventHandler } from 'svelte/elements';
type Item = {
value: string;
label: string;
};
let {
items,
selectedItems = $bindable(),
onSelect,
oninput,
isLoading = false,
placeholder = 'Select items...',
searchText = 'Search...',
noItemsText = 'No items found.',
disableInternalSearch = false,
id
}: {
items: Item[];
selectedItems: string[];
onSelect?: (value: string[]) => void;
oninput?: FormEventHandler<HTMLInputElement>;
isLoading?: boolean;
placeholder?: string;
searchText?: string;
noItemsText?: string;
disableInternalSearch?: boolean;
id?: string;
} = $props();
let open = $state(false);
let searchValue = $state('');
let filteredItems = $state(items);
const selectedLabels = $derived(
items.filter((item) => selectedItems.includes(item.value)).map((item) => item.label)
);
function handleItemSelect(value: string) {
let newSelectedItems: string[];
if (selectedItems.includes(value)) {
newSelectedItems = selectedItems.filter((item) => item !== value);
} else {
newSelectedItems = [...selectedItems, value];
}
selectedItems = newSelectedItems;
onSelect?.(newSelectedItems);
}
function filterItems(search: string) {
if (disableInternalSearch) return;
searchValue = search;
if (!search) {
filteredItems = items;
} else {
filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
}
}
// Reset search value when the popover is closed
$effect(() => {
if (!open) {
filterItems('');
}
filteredItems = items;
});
</script>
<Popover.Root bind:open>
<Popover.Trigger {id}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
role="combobox"
aria-expanded={open}
class="h-auto min-h-10 w-full justify-between"
>
<div class="flex flex-wrap items-center gap-1">
{#if selectedItems.length > 0}
{#each selectedLabels as label}
<Badge variant="secondary">{label}</Badge>
{/each}
{:else}
<span class="text-muted-foreground font-normal">{placeholder}</span>
{/if}
</div>
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="p-0" sameWidth>
<Command.Root shouldFilter={false}>
<Command.Input
placeholder={searchText}
value={searchValue}
oninput={(e) => {
filterItems(e.currentTarget.value);
oninput?.(e);
}}
/>
<Command.Empty>
{#if isLoading}
<div class="flex w-full items-center justify-center py-2">
<LoaderCircle class="size-4 animate-spin" />
</div>
{:else}
{noItemsText}
{/if}
</Command.Empty>
<Command.Group class="max-h-60 overflow-y-auto">
{#each filteredItems as item}
<Command.Item
aria-checked={selectedItems.includes(item.value)}
value={item.value}
onSelect={() => {
handleItemSelect(item.value);
}}
>
<LucideCheck
class={cn('mr-2 size-4', !selectedItems.includes(item.value) && 'text-transparent')}
/>
{item.label}
</Command.Item>
{/each}
</Command.Group>
</Command.Root>
</Popover.Content>
</Popover.Root>

View File

@@ -14,10 +14,15 @@ export default class AppConfigService extends APIService {
}
async update(appConfig: AllAppConfig) {
// Convert all values to string
const appConfigConvertedToString = {};
// Convert all values to string, stringifying JSON where needed
const appConfigConvertedToString: Record<string, string> = {};
for (const key in appConfig) {
(appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString();
const value = (appConfig as any)[key];
if (typeof value === 'object' && value !== null) {
appConfigConvertedToString[key] = JSON.stringify(value);
} else {
appConfigConvertedToString[key] = String(value);
}
}
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
return this.parseConfigList(res.data);
@@ -66,6 +71,16 @@ export default class AppConfigService extends APIService {
}
private parseValue(value: string) {
// Try to parse JSON first
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
value = String(parsed);
} catch {}
// Handle rest of the types
if (value === 'true') {
return true;
} else if (value === 'false') {

View File

@@ -1,3 +1,5 @@
import type { CustomClaim } from './custom-claim.type';
export type AppConfig = {
appName: string;
allowOwnAccountEdit: boolean;
@@ -14,6 +16,8 @@ export type AllAppConfig = AppConfig & {
// General
sessionDuration: number;
emailsVerified: boolean;
signupDefaultUserGroupIDs: string[];
signupDefaultCustomClaims: CustomClaim[];
// Email
smtpHost: string;
smtpPort: number;

View File

@@ -5,11 +5,12 @@
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideImage, Mail, SlidersHorizontal, UserSearch } from '@lucide/svelte';
import { LucideImage, Mail, SlidersHorizontal, UserSearch, Users } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
import AppConfigSignupDefaultsForm from './forms/app-config-signup-defaults-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
let { data } = $props();
@@ -68,6 +69,17 @@
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-signup-defaults"
icon={Users}
title={m.user_creation()}
description={m.configure_user_creation()}
>
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-email"

View File

@@ -23,27 +23,11 @@
let isLoading = $state(false);
const signupOptions = {
disabled: {
label: m.disabled(),
description: m.signup_disabled_description()
},
withToken: {
label: m.signup_with_token(),
description: m.signup_with_token_description()
},
open: {
label: m.signup_open(),
description: m.signup_open_description()
}
};
const updatedAppConfig = {
appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
allowUserSignups: appConfig.allowUserSignups,
disableAnimations: appConfig.disableAnimations,
accentColor: appConfig.accentColor
};
@@ -53,7 +37,6 @@
sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean(),
allowUserSignups: z.enum(['disabled', 'withToken', 'open']),
disableAnimations: z.boolean(),
accentColor: z.string()
});
@@ -80,55 +63,6 @@
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<div class="grid gap-2">
<div>
<Label class="mb-0" for="enable-user-signup">{m.enable_user_signups()}</Label>
<p class="text-muted-foreground text-[0.8rem]">
{m.enable_user_signups_description()}
</p>
</div>
<Select.Root
disabled={$appConfigStore.uiConfigDisabled}
type="single"
value={$inputs.allowUserSignups.value}
onValueChange={(v) =>
($inputs.allowUserSignups.value = v as typeof $inputs.allowUserSignups.value)}
>
<Select.Trigger
class="w-full"
aria-label={m.enable_user_signups()}
placeholder={m.enable_user_signups()}
>
{signupOptions[$inputs.allowUserSignups.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Item value="disabled">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.disabled.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.disabled.description}
</span>
</div>
</Select.Item>
<Select.Item value="withToken">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.withToken.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.withToken.description}
</span>
</div>
</Select.Item>
<Select.Item value="open">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.open.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.open.description}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</div>
<SwitchWithLabel
id="self-account-editing"
label={m.enable_self_account_editing()}

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import SearchableMultiSelect from '$lib/components/form/searchable-multi-select.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 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 {
appConfig,
callback
}: {
appConfig: AllAppConfig;
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 customClaims = $state(appConfig.signupDefaultCustomClaims || []);
let allowUserSignups = $state(appConfig.allowUserSignups);
let isLoading = $state(false);
let isUserSearchLoading = $state(false);
const signupOptions = {
disabled: {
label: m.disabled(),
description: m.signup_disabled_description()
},
withToken: {
label: m.signup_with_token(),
description: m.signup_with_token_description()
},
open: {
label: m.signup_open(),
description: m.signup_open_description()
}
};
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),
signupDefaultCustomClaims: customClaims
});
toast.success(m.user_creation_updated_successfully());
isLoading = false;
}
$effect(() => {
loadSelectedGroups();
customClaims = appConfig.signupDefaultCustomClaims || [];
allowUserSignups = appConfig.allowUserSignups;
});
onMount(() => loadUserGroups());
</script>
<form class="space-y-6" onsubmit={preventDefault(onSubmit)}>
<div class="grid gap-2">
<div>
<Label class="mb-0" for="enable-user-signup">{m.enable_user_signups()}</Label>
<p class="text-muted-foreground text-[0.8rem]">
{m.enable_user_signups_description()}
</p>
</div>
<Select.Root
type="single"
value={allowUserSignups}
onValueChange={(v) => (allowUserSignups = v as typeof allowUserSignups)}
>
<Select.Trigger
id="enable-user-signup"
class="w-full"
aria-label={m.enable_user_signups()}
placeholder={m.enable_user_signups()}
>
{signupOptions[allowUserSignups]?.label}
</Select.Trigger>
<Select.Content>
<Select.Item value="disabled">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.disabled.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.disabled.description}
</span>
</div>
</Select.Item>
<Select.Item value="withToken">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.withToken.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.withToken.description}
</span>
</div>
</Select.Item>
<Select.Item value="open">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.open.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.open.description}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div>
<Label for="default-groups" class="mb-0">{m.user_groups()}</Label>
<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
/>
</div>
<div>
<Label class="mb-0">{m.custom_claims()}</Label>
<p class="text-muted-foreground mt-1 mb-2 text-xs">
{m.user_creation_claims_description()}
</p>
<CustomClaimsInput bind:customClaims />
</div>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>