feat!: replace custom claims with custom fields

This commit is contained in:
Elias Schneider
2026-05-23 16:07:37 +02:00
parent 5db570bf66
commit 3428fb35d7
68 changed files with 3193 additions and 1016 deletions
+30 -19
View File
@@ -5,9 +5,6 @@
"confirm": "Confirm",
"docs": "Docs",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Remove custom claim",
"add_custom_claim": "Add custom claim",
"add_another": "Add another",
"select_a_date": "Select a date",
"select_file": "Select File",
@@ -35,7 +32,6 @@
"one_week": "1 week",
"one_month": "1 month",
"expiration": "Expiration",
"generate_code": "Generate Code",
"name": "Name",
"browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
@@ -75,7 +71,6 @@
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Authenticate",
"please_try_again": "Please try again.",
"continue": "Continue",
"alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?",
@@ -169,7 +164,6 @@
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images",
"update": "Update",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Save changes?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
@@ -249,12 +243,9 @@
"edit": "Edit",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Back",
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
"custom_claims": "Custom Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
"user_group_created_successfully": "User group created successfully",
"create_user_group": "Create User Group",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
@@ -271,7 +262,6 @@
"users_updated_successfully": "Users updated successfully",
"user_group_details_name": "User Group Details {name}",
"assign_users_to_this_group": "Assign users to this group.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
"oidc_client_created_successfully": "OIDC client created successfully",
"create_oidc_client": "Create OIDC Client",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
@@ -289,9 +279,7 @@
"requires_reauthentication": "Requires Re-Authentication",
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"remove_logo": "Remove Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
"authorization_url": "Authorization URL",
@@ -373,7 +361,6 @@
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
@@ -385,9 +372,7 @@
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name}",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
@@ -409,11 +394,9 @@
"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",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
@@ -436,7 +419,6 @@
"usage": "Usage",
"created": "Created",
"token": "Token",
"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_with_token": "Signup with token",
@@ -526,5 +508,34 @@
"email_verification_sent": "Verification email sent successfully.",
"emails_verified_by_default": "Emails verified by default",
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
"user_has_no_passkeys_yet": "This user has no passkeys yet."
"user_has_no_passkeys_yet": "This user has no passkeys yet.",
"custom_fields": "Custom Fields",
"configure_custom_fields": "Configure custom fields that can be assigned to users and user groups.",
"custom_fields_updated_successfully": "Custom fields updated successfully",
"add_custom_field": "Add custom field",
"delete_custom_field_name": "Delete {name}?",
"delete_custom_field_description": "Are you sure you want to delete the custom field {name}? This will remove the field from all users and groups.",
"custom_field_already_exists": "Custom field already exists",
"available_for": "Available for",
"users_and_groups": "Users and groups",
"field_is_required": "Field is required",
"must_be_a_number": "Must be a number",
"value_must_be_true_or_false": "Value must be true or false",
"default_value": "Default value",
"no_default": "No default",
"invalid_regex": "Invalid regex",
"validation_regex": "Validation regex",
"validation_regex_description": "A regular expression that the value of this field must match.",
"must_be_a_valid_email_address": "Must be a valid email address",
"validation_error_message": "Validation error message",
"validation_error_message_description": "The error message that will be shown when the value does not match the validation regex.",
"value_does_not_match_required_format": "Value does not match the required format",
"user_editable": "User editable",
"user_editable_description": "Allow users to update this field on their own account.",
"text": "Text",
"number": "Number",
"boolean": "Boolean",
"type": "Type",
"required": "Required",
"required_custom_field_description": "Whether this field should be marked as required in the form"
}
@@ -13,6 +13,7 @@
title,
description,
defaultExpanded = false,
noHeaderMargin = false,
forcedExpanded,
button,
icon,
@@ -22,6 +23,7 @@
title: string;
description?: string;
defaultExpanded?: boolean;
noHeaderMargin?: boolean;
forcedExpanded?: boolean;
icon?: typeof IconType;
button?: Snippet;
@@ -60,7 +62,11 @@
});
</script>
<Card.Root>
<Card.Root
class={{
'gap-0': noHeaderMargin
}}
>
<Card.Header class="bg-card cursor-pointer" onclick={toggleExpanded}>
<div class="flex items-center justify-between">
<div>
@@ -1,76 +0,0 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import CustomClaimService from '$lib/services/custom-claim-service';
import type { CustomClaim } from '$lib/types/custom-claim.type';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
import { onMount, type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import AutoCompleteInput from './auto-complete-input.svelte';
import { m } from '$lib/paraglide/messages';
let {
customClaims = $bindable(),
error = $bindable(null),
...restProps
}: HTMLAttributes<HTMLDivElement> & {
customClaims: CustomClaim[];
error?: string | null;
children?: Snippet;
} = $props();
const limit = 20;
const customClaimService = new CustomClaimService();
let suggestions: string[] = $state([]);
let filteredSuggestions: string[] = $derived(
suggestions.filter(
(suggestion) => !customClaims.some((customClaim) => customClaim.key === suggestion)
)
);
onMount(() => {
customClaimService.getSuggestions().then((data) => (suggestions = data));
});
</script>
<div {...restProps}>
<FormInput>
<div class="flex flex-col gap-y-2">
{#each customClaims as _, i}
<div class="flex gap-x-2">
<AutoCompleteInput
placeholder={m.key()}
suggestions={filteredSuggestions}
bind:value={customClaims[i].key}
/>
<Input placeholder={m.value()} bind:value={customClaims[i].value} />
<Button
variant="outline"
size="sm"
aria-label={m.remove_custom_claim()}
onclick={() => (customClaims = customClaims.filter((_, index) => index !== i))}
>
<LucideMinus class="size-4" />
</Button>
</div>
{/each}
</div>
</FormInput>
{#if error}
<p class="text-destructive mt-1 text-xs">{error}</p>
{/if}
{#if customClaims.length < limit}
<Button
class="mt-2"
variant="secondary"
size="sm"
onclick={() => (customClaims = [...customClaims, { key: '', value: '' }])}
>
<LucidePlus class="mr-1 size-4" />
{customClaims.length === 0 ? m.add_custom_claim() : m.add_another()}
</Button>
{/if}
</div>
@@ -0,0 +1,110 @@
<script lang="ts">
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch/index.js';
import { m } from '$lib/paraglide/messages';
import type { CustomField, CustomFieldValue } from '$lib/types/custom-field.type';
import type { HTMLAttributes } from 'svelte/elements';
let {
customFieldValues = $bindable(),
customFields = []
}: HTMLAttributes<HTMLDivElement> & {
customFieldValues: CustomFieldValue[];
customFields?: CustomField[];
} = $props();
let errors = $state<Record<string, string>>({});
function getValue(field: CustomField) {
const customFieldValue = customFieldValues.find(
(customFieldValue) => customFieldValue.customFieldId === field.id
);
if (customFieldValue) return customFieldValue.value;
return field.defaultValue ?? '';
}
function setValue(field: CustomField, value: string) {
errors = { ...errors, [field.key]: '' };
const nextCustomFieldValues = customFieldValues.filter(
(customFieldValue) => customFieldValue.customFieldId !== field.id
);
if (field.type !== 'boolean' && !field.required && value === '') {
customFieldValues = nextCustomFieldValues;
return;
}
customFieldValues = [...nextCustomFieldValues, { customFieldId: field.id, value }];
}
function normalizeCustomFieldValues() {
const normalizedCustomFieldValues: CustomFieldValue[] = [];
for (const field of customFields) {
const value = field.type === 'boolean' ? getValue(field) || 'false' : getValue(field);
if (field.type !== 'boolean' && !field.required && value === '') continue;
normalizedCustomFieldValues.push({ customFieldId: field.id, value });
}
customFieldValues = normalizedCustomFieldValues;
}
export function validate() {
normalizeCustomFieldValues();
const nextErrors: Record<string, string> = {};
for (const field of customFields) {
const value = getValue(field);
if (field.required && field.type !== 'boolean' && value === '') {
nextErrors[field.key] = m.field_is_required();
continue;
}
if (field.type === 'number' && value !== '' && Number.isNaN(Number(value))) {
nextErrors[field.key] = m.must_be_a_number();
continue;
}
if (field.type === 'string' && field.validationRegex && value !== '') {
let regex: RegExp;
try {
regex = new RegExp(field.validationRegex);
} catch {
nextErrors[field.key] = m.invalid_regex();
continue;
}
if (!regex.test(value)) {
nextErrors[field.key] =
field.validationErrorMessage || m.value_does_not_match_required_format();
}
}
}
errors = nextErrors;
return Object.keys(nextErrors).length === 0;
}
</script>
{#each customFields as field (field.key)}
<Field.Field>
<Field.Label required={field.required} for={`custom-field-${field.key}`}>
{field.displayName || field.key}
</Field.Label>
{#if field.type === 'boolean'}
<div class="flex h-9 items-center">
<Switch
id={`custom-field-${field.key}`}
checked={getValue(field) === 'true'}
onCheckedChange={(checked) => setValue(field, checked ? 'true' : 'false')}
/>
</div>
{:else}
<Input
id={`custom-field-${field.key}`}
type={field.type === 'number' ? 'number' : 'text'}
value={getValue(field)}
aria-invalid={!!errors[field.key]}
oninput={(e) => setValue(field, e.currentTarget.value)}
/>
{/if}
{#if errors[field.key]}
<Field.Error class="text-start">{errors[field.key]}</Field.Error>
{/if}
</Field.Field>
{/each}
@@ -1,9 +1,11 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch/index.js';
import { cn } from '$lib/utils/style';
let {
id,
class: className,
checked = $bindable(),
label,
description,
@@ -11,6 +13,7 @@
onCheckedChange
}: {
id: string;
class?: string;
checked: boolean;
label: string;
description?: string;
@@ -19,7 +22,7 @@
} = $props();
</script>
<div class="items-top flex space-x-2">
<div class={cn("items-top flex space-x-2", className)}>
<Switch
{id}
{disabled}
@@ -1,7 +1,9 @@
<script lang="ts">
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { customFieldAppliesTo, type CustomFieldValue } from '$lib/types/custom-field.type';
import type { UserSignUp } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
@@ -35,16 +37,23 @@
const { inputs, ...form } = createForm<FormSchema>(formSchema, initialData);
let userData: UserSignUp | null = $state(null);
let customFieldValues = $state<CustomFieldValue[]>([]);
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
let customFields = $derived(
$appConfigStore.customFields.filter(
(field) => customFieldAppliesTo(field, 'user') && (field.required && field.userEditable)
)
);
async function onSubmit() {
const data = form.validate();
if (!data) return;
if (customFieldValuesInputRef && !customFieldValuesInputRef.validate()) return;
isLoading = true;
const result = await tryCatch(callback(data));
const result = await tryCatch(callback({ ...data, customFieldValues }));
if (result.data) {
userData = data;
userData = { ...data, customFieldValues };
isLoading = false;
}
}
@@ -58,6 +67,11 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
<CustomFieldValuesInput
bind:this={customFieldValuesInputRef}
bind:customFieldValues
{customFields}
/>
</div>
</div>
</form>
@@ -25,6 +25,7 @@
selectedIds = $bindable(),
withoutSearch = false,
selectionDisabled = false,
paginationDisabled = false,
rowSelectionDisabled,
fetchCallback,
defaultSort,
@@ -35,6 +36,7 @@
selectedIds?: string[];
withoutSearch?: boolean;
selectionDisabled?: boolean;
paginationDisabled?: boolean;
rowSelectionDisabled?: (item: T) => boolean;
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
defaultSort?: SortRequest;
@@ -178,8 +180,10 @@
export async function refresh() {
items = await fetchCallback(requestOptions);
changePageState(items.pagination.currentPage);
updateListLength(items.pagination.totalItems);
if (!paginationDisabled) {
changePageState(items.pagination.currentPage);
updateListLength(items.pagination.totalItems);
}
}
</script>
@@ -331,51 +335,52 @@
</Table.Root>
</div>
{/if}
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={items?.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
{#if !paginationDisabled}
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={items?.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="w-20">
{items?.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items?.pagination.totalItems || 0}
perPage={items?.pagination.itemsPerPage}
{onPageChange}
page={items?.pagination.currentPage}
>
<Select.Trigger class="w-20">
{items?.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items?.pagination.totalItems || 0}
perPage={items?.pagination.itemsPerPage}
{onPageChange}
page={items?.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
{/if}
@@ -1,18 +1,36 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { page } from '$app/state';
import { cn } from '$lib/utils/style.js';
import { Tabs as TabsPrimitive } from 'bits-ui';
import { onMount } from 'svelte';
let {
ref = $bindable(null),
value = $bindable(''),
useHash = false,
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
}: TabsPrimitive.RootProps & {
useHash?: boolean;
} = $props();
onMount(() => {
if (useHash && page.url.hash) {
value = page.url.hash.substring(1);
}
});
function onTabChange(newValue: string) {
if (useHash && page.url.hash !== newValue) {
window.location.hash = newValue;
}
}
</script>
<TabsPrimitive.Root
bind:ref
bind:value
onValueChange={onTabChange}
data-slot="tabs"
class={cn('flex flex-col gap-2', className)}
{...restProps}
@@ -1,19 +0,0 @@
import type { CustomClaim } from '$lib/types/custom-claim.type';
import APIService from './api-service';
export default class CustomClaimService extends APIService {
getSuggestions = async () => {
const res = await this.api.get('/custom-claims/suggestions');
return res.data as string[];
};
updateUserCustomClaims = async (userId: string, claims: CustomClaim[]) => {
const res = await this.api.put(`/custom-claims/user/${userId}`, claims);
return res.data as CustomClaim[];
};
updateUserGroupCustomClaims = async (userGroupId: string, claims: CustomClaim[]) => {
const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims);
return res.data as CustomClaim[];
};
}
@@ -1,4 +1,4 @@
import type { CustomClaim } from './custom-claim.type';
import type { CustomFieldValue, CustomField } from './custom-field.type';
export type AppConfig = {
appName: string;
@@ -13,6 +13,7 @@ export type AppConfig = {
uiConfigDisabled: boolean;
accentColor: string;
requireUserEmail: boolean;
customFields: CustomField[];
};
export type AllAppConfig = AppConfig & {
@@ -20,7 +21,6 @@ export type AllAppConfig = AppConfig & {
sessionDuration: number;
emailsVerified: boolean;
signupDefaultUserGroupIDs: string[];
signupDefaultCustomClaims: CustomClaim[];
// Email
smtpHost: string;
smtpPort: string;
@@ -1,4 +0,0 @@
export type CustomClaim = {
key: string;
value: string;
};
@@ -0,0 +1,24 @@
export type CustomFieldValue = {
customFieldId: string;
value: string;
};
export type CustomFieldType = 'string' | 'number' | 'boolean';
export type CustomFieldTarget = 'user' | 'group' | 'both';
export type CustomField = {
id: string;
key: string;
displayName: string;
type: CustomFieldType;
target: CustomFieldTarget;
required: boolean;
userEditable: boolean;
defaultValue?: string;
validationRegex?: string;
validationErrorMessage?: string;
};
export function customFieldAppliesTo(field: CustomField, target: 'user' | 'group') {
return field.target === 'both' || field.target === target;
}
+5 -3
View File
@@ -1,4 +1,4 @@
import type { CustomClaim } from './custom-claim.type';
import type { CustomFieldValue } from './custom-field.type';
import type { OidcClientMetaData } from './oidc.type';
import type { User } from './user.type';
@@ -7,7 +7,7 @@ export type UserGroup = {
friendlyName: string;
name: string;
createdAt: string;
customClaims: CustomClaim[];
customFieldValues: CustomFieldValue[];
ldapId?: string;
users: User[];
allowedOidcClients: OidcClientMetaData[];
@@ -17,4 +17,6 @@ export type UserGroupMinimal = Omit<UserGroup, 'users' | 'allowedOidcClients'> &
userCount: number;
};
export type UserGroupCreate = Pick<UserGroup, 'friendlyName' | 'name' | 'ldapId'>;
export type UserGroupCreate = Pick<UserGroup, 'friendlyName' | 'name' | 'ldapId'> & {
customFieldValues?: CustomFieldValue[];
};
+9 -4
View File
@@ -1,5 +1,5 @@
import type { Locale } from '$lib/paraglide/runtime';
import type { CustomClaim } from './custom-claim.type';
import type { CustomFieldValue } from './custom-field.type';
import type { UserGroup } from './user-group.type';
export type User = {
@@ -12,15 +12,20 @@ export type User = {
displayName: string;
isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[];
customFieldValues: CustomFieldValue[];
locale?: Locale;
ldapId?: string;
disabled?: boolean;
};
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
export type UserCreate = Omit<User, 'id' | 'customFieldValues' | 'ldapId' | 'userGroups'> & {
customFieldValues?: CustomFieldValue[];
};
export type AccountUpdate = Omit<UserCreate, 'isAdmin' | 'disabled' | 'emailVerified'>;
export type AccountUpdate = Omit<
UserCreate,
'isAdmin' | 'disabled' | 'emailVerified'
>;
export type UserSignUp = Omit<
UserCreate,
@@ -1,4 +1,5 @@
<script lang="ts">
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import { Button } from '$lib/components/ui/button';
@@ -6,6 +7,7 @@
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import { customFieldAppliesTo } from '$lib/types/custom-field.type';
import type { AccountUpdate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
@@ -31,6 +33,13 @@
let isLoading = $state(false);
let hasManualDisplayNameEdit = $state(!!account.displayName);
let customFieldValues = $state(account.customFieldValues || []);
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
let customFields = $derived(
$appConfigStore.customFields.filter(
(field) => customFieldAppliesTo(field, 'user') && field.userEditable
)
);
const userService = new UserService();
@@ -59,8 +68,13 @@
async function onSubmit() {
const data = form.validate();
if (!data) return;
if (!customFieldValuesInputRef?.validate()) return;
isLoading = true;
await callback(data);
await callback({
...data,
customFieldValues
});
isLoading = false;
}
@@ -100,10 +114,15 @@
bind:input={$inputs.displayName}
onInput={() => (hasManualDisplayNameEdit = true)}
/>
<CustomFieldValuesInput
bind:this={customFieldValuesInputRef}
bind:customFieldValues
{customFields}
/>
</Field.Group>
<div class="flex justify-end pt-4">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
<div class="flex justify-end pt-4">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>
@@ -1,12 +1,14 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import * as Alert from '$lib/components/ui/alert';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import {
ListChecks,
LucideImage,
LucideInfo,
Mail,
@@ -15,6 +17,7 @@
Users
} from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import CustomFieldsTable from './custom-fields-table.svelte';
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';
@@ -101,57 +104,67 @@
</Alert.Description>
</Alert.Root>
{/if}
<div>
<CollapsibleCard
id="application-configuration-general"
icon={SlidersHorizontal}
title={m.general()}
defaultExpanded
>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</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>
<Tabs.Root value="general" useHash>
<Tabs.List class="mb-3">
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
<Tabs.Trigger value="user-and-groups">{m.users_and_groups()}</Tabs.Trigger>
<Tabs.Trigger value="email">{m.email()}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general" class="flex flex-col gap-5">
<CollapsibleCard
id="application-configuration-general"
icon={SlidersHorizontal}
title={m.general()}
defaultExpanded
>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard
id="application-configuration-images"
icon={LucideImage}
title={m.images()}
description={m.configure_application_images()}
>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>
</Tabs.Content>
<Tabs.Content value="user-and-groups" class="flex flex-col gap-5">
<CollapsibleCard
id="application-configuration-signup-defaults"
icon={Users}
title={m.user_creation()}
description={m.configure_user_creation()}
>
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<div>
<CollapsibleCard
id="application-configuration-email"
icon={Mail}
title={m.email()}
description={m.configure_smtp_to_send_emails()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-ldap"
icon={UserSearch}
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-images"
icon={LucideImage}
title={m.images()}
description={m.configure_application_images()}
>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>
</div>
<CollapsibleCard
id="application-configuration-custom-fields"
icon={ListChecks}
title={m.custom_fields()}
description={m.configure_custom_fields()}
noHeaderMargin
>
<CustomFieldsTable {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard
id="application-configuration-ldap"
icon={UserSearch}
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</Tabs.Content>
<Tabs.Content value="email">
<CollapsibleCard
id="application-configuration-email"
icon={Mail}
title={m.email()}
description={m.configure_smtp_to_send_emails()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</Tabs.Content>
</Tabs.Root>
@@ -0,0 +1,213 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} from '$lib/types/advanced-table.type';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import type { CustomField } from '$lib/types/custom-field.type';
import type { ListRequestOptions } from '$lib/types/list-request.type';
import {
LucideCaseSensitive,
LucideCheck,
LucideHash,
LucidePencil,
LucidePlus,
LucideToggleLeft,
LucideTrash,
LucideX
} from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import AppConfigCustomFieldsDialog from './forms/app-config-custom-fields-dialog.svelte';
let {
appConfig,
callback
}: {
appConfig: AllAppConfig;
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let customFields = $state<CustomField[]>(appConfig.customFields);
let selectedCustomField = $state<CustomField>();
let showCustomFieldsDialog = $state(false);
let table = $state<any>();
const columns: AdvancedTableColumn<CustomField>[] = [
{
label: m.display_name(),
column: 'displayName',
sortable: true
},
{
label: m.key(),
column: 'key',
sortable: true,
},
{
label: m.type(),
column: 'type',
hidden: true,
sortable: true,
cell: TypeCell
},
{
label: m.available_for(),
column: 'target',
sortable: true,
cell: TargetCell
},
{
label: m.user_editable(),
column: 'userEditable',
sortable: true,
cell: BoolCell
},
{
label: m.required(),
column: 'required',
sortable: true,
hidden: true,
cell: BoolCell
}
];
const actions: CreateAdvancedTableActions<CustomField> = (_) => [
{
label: m.edit(),
primary: true,
icon: LucidePencil,
onClick: (field) => {
selectedCustomField = field;
showCustomFieldsDialog = true;
}
},
{
label: m.delete(),
icon: LucideTrash,
variant: 'danger',
onClick: (field) =>
openConfirmDialog({
title: m.delete_custom_field_name({ name: field.displayName }),
message: m.delete_custom_field_description({ name: field.displayName }),
confirm: {
label: m.delete(),
destructive: true,
action: () => deleteCustomField(field)
}
})
}
];
function openCreateDialog() {
selectedCustomField = undefined;
showCustomFieldsDialog = true;
}
async function updateCustomField(customField: CustomField) {
const existingIndex = customFields.findIndex((f) => f.id === customField.id);
const nextCustomFields = [...customFields];
if (existingIndex !== -1) {
nextCustomFields[existingIndex] = customField;
} else {
nextCustomFields.push(customField);
}
await callback({ customFields: nextCustomFields });
customFields = nextCustomFields;
await table?.refresh();
}
async function deleteCustomField(customField: CustomField) {
const nextCustomFields = customFields.filter((field) => field.id !== customField.id);
await callback({ customFields: nextCustomFields });
customFields = nextCustomFields;
toast.success(m.custom_fields_updated_successfully());
await table?.refresh();
}
function fetchCallback(requestOptions: ListRequestOptions) {
const data = [...customFields].sort((a, b) => {
const column = requestOptions.sort?.column || 'displayName';
const direction = requestOptions.sort?.direction === 'asc' ? 1 : -1;
const aValue = String((a as Record<string, unknown>)[column] ?? '');
const bValue = String((b as Record<string, unknown>)[column] ?? '');
if (aValue < bValue) return -1 * direction;
if (aValue > bValue) return 1 * direction;
return 0;
});
return Promise.resolve({
data,
pagination: {
currentPage: 1,
totalPages: 1,
itemsPerPage: data.length,
totalItems: data.length
}
});
}
$effect(() => {
customFields = appConfig.customFields;
});
</script>
{#snippet TypeCell({ item }: { item: CustomField })}
<span class="flex gap-1">
{#if item.type === 'boolean'}
<LucideToggleLeft class="size-4 my-auto" />
{:else if item.type == 'number'}
<LucideHash class="size-3 my-auto" />
{:else}
<LucideCaseSensitive class="size-4 my-auto" />
{/if}
</span>
{/snippet}
{#snippet TargetCell({ item }: { item: CustomField })}
{#if item.target === 'both'}
<Badge variant="secondary">{m.users_and_groups()}</Badge>
{:else if item.target === 'group'}
<Badge variant="outline">{m.groups()}</Badge>
{:else}
<Badge variant="outline">{m.users()}</Badge>
{/if}
{/snippet}
{#snippet BoolCell({ item }: { item: CustomField })}
{#if item.userEditable}
<LucideCheck class="size-4" />
{:else}
<LucideX class="size-4 text-muted-foreground" />
{/if}
{/snippet}
<div class="flex flex-col gap-3">
<AdvancedTable
bind:this={table}
id="custom-fields-table"
{fetchCallback}
{actions}
defaultSort={{ column: 'displayName', direction: 'desc' }}
paginationDisabled
withoutSearch
{columns}
/>
<div class="flex justify-end mt-5">
<Button onclick={openCreateDialog}>
<LucidePlus class="mr-1 size-4" />
{m.add_custom_field()}
</Button>
</div>
</div>
<AppConfigCustomFieldsDialog
bind:show={showCustomFieldsDialog}
existingCustomField={selectedCustomField}
callback={updateCustomField}
/>
@@ -0,0 +1,281 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import type {
CustomField,
CustomFieldTarget,
CustomFieldType
} from '$lib/types/custom-field.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import z from 'zod';
let {
show = $bindable(),
existingCustomField,
callback
}: {
show?: boolean;
existingCustomField?: CustomField;
callback: (updatedField: CustomField) => Promise<void>;
} = $props();
let isLoading = $state(false);
type CustomFieldForm = CustomField & {
defaultValue: string;
validationRegex: string;
validationErrorMessage: string;
};
const customField: CustomFieldForm = {
id: '',
key: '',
displayName: '',
type: 'string',
target: 'user',
required: false,
userEditable: false,
defaultValue: '',
validationRegex: '',
validationErrorMessage: ''
};
const fieldTypes: { value: CustomFieldType; label: string }[] = [
{ value: 'string', label: m.text() },
{ value: 'number', label: m.number() },
{ value: 'boolean', label: m.boolean() }
];
const fieldTargets: { value: CustomFieldTarget; label: string }[] = [
{ value: 'user', label: m.users() },
{ value: 'group', label: m.groups() },
{ value: 'both', label: m.users_and_groups() }
];
const formSchema = z
.object({
id: z.string().min(1),
key: z.string().trim().min(1, { message: m.field_is_required() }),
displayName: z.string().trim().min(1, { message: m.field_is_required() }),
type: z.enum(['string', 'number', 'boolean']),
target: z.enum(['user', 'group', 'both']),
required: z.boolean(),
userEditable: z.boolean(),
defaultValue: z.preprocess(
(value) => (value === null || value === undefined ? '' : String(value)),
z.string()
),
validationRegex: z.string().refine(
(value) => {
if (!value) return true;
try {
new RegExp(value);
return true;
} catch {
return false;
}
},
{ message: m.invalid_regex() }
),
validationErrorMessage: z.string()
})
.superRefine((data, ctx) => {
if (data.required && !data.defaultValue) {
ctx.addIssue({
code: 'custom',
path: ['defaultValue'],
message: m.field_is_required()
});
return;
}
if (!data.defaultValue) return;
if (data.type === 'number' && Number.isNaN(Number(data.defaultValue))) {
ctx.addIssue({
code: 'custom',
path: ['defaultValue'],
message: m.must_be_a_number()
});
}
if (data.type === 'string' && data.validationRegex) {
try {
const regex = new RegExp(data.validationRegex);
if (!regex.test(data.defaultValue)) {
ctx.addIssue({
code: 'custom',
path: ['defaultValue'],
message: data.validationErrorMessage || m.value_does_not_match_required_format()
});
}
} catch {}
}
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, customField);
$effect(() => {
if (!show) return;
const field = existingCustomField ?? {
...customField,
id: crypto.randomUUID()
};
form.setValue('id', field.id);
form.setValue('key', field.key);
form.setValue('displayName', field.displayName);
form.setValue('type', field.type);
form.setValue('target', field.target || 'both');
form.setValue('required', field.required);
form.setValue('userEditable', !!field.userEditable);
form.setValue('defaultValue', field.defaultValue || '');
form.setValue('validationRegex', field.validationRegex || '');
form.setValue('validationErrorMessage', field.validationErrorMessage || '');
});
async function onSubmit() {
isLoading = true;
try {
const data = form.validate();
if (!data) return;
await callback(data);
toast.success(m.custom_fields_updated_successfully());
show = false;
} finally {
isLoading = false;
}
}
</script>
<Dialog.Root open={show} onOpenChange={(open) => (show = open)}>
<Dialog.Content class="lg:min-w-3xl md:min-w-2xl" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{existingCustomField ? m.edit() : m.add_custom_field()}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 items-center gap-5 md:grid-cols-2">
<FormInput label={m.key()} bind:input={$inputs.key} />
<FormInput label={m.display_name()} bind:input={$inputs.displayName} />
<Field.Field>
<Field.Label>{m.type()}</Field.Label>
<Select.Root
type="single"
disabled={!!existingCustomField}
value={$inputs.type.value}
onValueChange={(value) => {
$inputs.type.value = value as any;
if (value !== 'string') {
$inputs.validationRegex.value = '';
$inputs.validationErrorMessage.value = '';
}
}}
>
<Select.Trigger class="w-full" aria-label={m.type()}>
{fieldTypes.find((option) => option.value === $inputs.type.value)?.label}
</Select.Trigger>
<Select.Content>
{#each fieldTypes as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label>{m.available_for()}</Field.Label>
<Select.Root
type="single"
value={$inputs.target.value}
onValueChange={(value) => ($inputs.target.value = value as CustomFieldTarget)}
>
<Select.Trigger class="w-full" aria-label={m.available_for()}>
{fieldTargets.find((option) => option.value === $inputs.target.value)?.label}
</Select.Trigger>
<Select.Content>
{#each fieldTargets as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<SwitchWithLabel
id="required"
class="items-center"
label={m.required()}
description={m.required_custom_field_description()}
bind:checked={$inputs.required.value}
/>
{#if $inputs.target.value != 'group'}
<SwitchWithLabel
id="user-editable"
class="items-center"
label={m.user_editable()}
description={m.user_editable_description()}
bind:checked={$inputs.userEditable.value}
/>
{:else}
<div class="hidden md:flex"></div>
{/if}
{#if $inputs.type.value === 'boolean'}
<Field.Field>
<Field.Label required={$inputs.required.value}>{m.default_value()}</Field.Label>
<Select.Root
type="single"
value={$inputs.defaultValue.value || ''}
onValueChange={(value) => ($inputs.defaultValue.value = value)}
>
<Select.Trigger class="w-full" aria-label={m.default_value()}>
{#if $inputs.defaultValue.value === 'true'}
{m.yes()}
{:else if $inputs.defaultValue.value === 'false'}
{m.no()}
{:else}
{m.no_default()}
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="">{m.no_default()}</Select.Item>
<Select.Item value="true">{m.yes()}</Select.Item>
<Select.Item value="false">{m.no()}</Select.Item>
</Select.Content>
</Select.Root>
{#if $inputs.defaultValue.error}
<Field.Error class="text-start">{$inputs.defaultValue.error}</Field.Error>
{/if}
</Field.Field>
{:else}
<FormInput
label={m.default_value()}
type={$inputs.type.value === 'number' ? 'number' : 'text'}
bind:input={$inputs.defaultValue}
/>
{/if}
{#if $inputs.type.value === 'string'}
<FormInput
label={m.validation_regex()}
placeholder="^\S+@\S+\.\S+$"
bind:input={$inputs.validationRegex}
description={m.validation_regex_description()}
/>
<FormInput
label={m.validation_error_message()}
placeholder={m.must_be_a_valid_email_address()}
bind:input={$inputs.validationErrorMessage}
description={m.validation_error_message_description()}
/>
{/if}
</div>
<Dialog.Footer class="mt-10 md:mt-3">
<Button onclick={() => (show = false)} variant="secondary">{m.cancel()}</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -1,5 +1,4 @@
<script lang="ts">
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
@@ -19,7 +18,6 @@
} = $props();
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
let allowUserSignups = $state(appConfig.allowUserSignups);
let isLoading = $state(false);
@@ -42,15 +40,13 @@
isLoading = true;
await callback({
allowUserSignups: allowUserSignups,
signupDefaultUserGroupIDs: selectedGroupIds,
signupDefaultCustomClaims: customClaims
signupDefaultUserGroupIDs: selectedGroupIds
});
toast.success(m.user_creation_updated_successfully());
isLoading = false;
}
$effect(() => {
customClaims = appConfig.signupDefaultCustomClaims || [];
allowUserSignups = appConfig.allowUserSignups;
});
</script>
@@ -113,13 +109,6 @@
</Field.Description>
<UserGroupInput bind:selectedGroupIds />
</Field.Field>
<Field.Field>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
<CustomClaimsInput bind:customClaims />
</Field.Field>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
@@ -1,11 +1,9 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type';
@@ -27,7 +25,6 @@
let oidcClientSelectionRef: OidcClientSelection;
const userGroupService = new UserGroupService();
const customClaimService = new CustomClaimService();
const backNavigation = backNavigate('/settings/admin/user-groups');
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
@@ -52,15 +49,6 @@
});
}
async function updateCustomClaims() {
await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateAllowedOidcClients(allowedClients: string[]) {
await userGroupService
.updateAllowedOidcClients(userGroup.id, allowedClients)
@@ -116,17 +104,6 @@
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-group-custom-claims"
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized()}
>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>
<CollapsibleCard
id="user-group-oidc-clients"
title={m.allowed_oidc_clients()}
@@ -1,8 +1,10 @@
<script lang="ts">
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { customFieldAppliesTo } from '$lib/types/custom-field.type';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
@@ -19,6 +21,11 @@
let isLoading = $state(false);
let inputDisabled = $derived(!!existingUserGroup?.ldapId && $appConfigStore.ldapEnabled);
let hasManualNameEdit = $state(!!existingUserGroup?.friendlyName);
let customFieldValues = $state(existingUserGroup?.customFieldValues || []);
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
let customFields = $derived(
$appConfigStore.customFields.filter((field) => customFieldAppliesTo(field, 'group'))
);
const userGroup = {
name: existingUserGroup?.name || '',
@@ -46,12 +53,18 @@
async function onSubmit() {
const data = form.validate();
if (!data) return;
if (!customFieldValuesInputRef?.validate()) return;
isLoading = true;
const success = await callback(data);
const success = await callback({
...data,
customFieldValues
});
// Reset form if user group was successfully created
if (success && !existingUserGroup) {
form.reset();
hasManualNameEdit = false;
customFieldValues = [];
}
isLoading = false;
}
@@ -59,24 +72,26 @@
<form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={inputDisabled}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput
label={m.friendly_name()}
description={m.name_that_will_be_displayed_in_the_ui()}
bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput}
/>
</div>
<div class="w-full">
<FormInput
label={m.name()}
description={m.name_that_will_be_in_the_groups_claim()}
bind:input={$inputs.name}
onInput={onNameInput}
/>
</div>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput
label={m.friendly_name()}
description={m.name_that_will_be_displayed_in_the_ui()}
bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput}
/>
<FormInput
label={m.name()}
description={m.name_that_will_be_in_the_groups_claim()}
bind:input={$inputs.name}
onInput={onNameInput}
/>
<CustomFieldValuesInput
bind:this={customFieldValuesInputRef}
bind:customFieldValues
{customFields}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
@@ -1,6 +1,5 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button';
@@ -8,7 +7,6 @@
import * as Item from '$lib/components/ui/item/index.js';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import { m } from '$lib/paraglide/messages';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { Passkey } from '$lib/types/passkey.type';
@@ -28,7 +26,6 @@
let passkeys: Passkey[] = $state(data.passkeys);
const userService = new UserService();
const customClaimService = new CustomClaimService();
const backNavigation = backNavigate('/settings/admin/users');
async function updateUserGroups(userIds: string[]) {
@@ -53,15 +50,6 @@
return success;
}
async function updateCustomClaims() {
await customClaimService
.updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateProfilePicture(image: File) {
await userService
.updateProfilePicture(user.id, image)
@@ -150,14 +138,3 @@
<AdminPasskeyList userId={user.id} bind:passkeys />
{/if}
</Item.Group>
<CollapsibleCard
id="user-custom-claims"
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>
@@ -1,4 +1,5 @@
<script lang="ts">
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
@@ -7,6 +8,7 @@
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { customFieldAppliesTo } from '$lib/types/custom-field.type';
import type { User, UserCreate } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
@@ -28,6 +30,11 @@
let isLoading = $state(false);
let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled);
let hasManualDisplayNameEdit = $state(!!existingUser?.displayName);
let customFieldValues = $state(existingUser?.customFieldValues || []);
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
let customFields = $derived(
$appConfigStore.customFields.filter((field) => customFieldAppliesTo(field, 'user'))
);
const user = {
firstName: existingUser?.firstName || '',
@@ -58,10 +65,17 @@
async function onSubmit() {
const data = form.validate();
if (!data) return;
if (!customFieldValuesInputRef?.validate()) return;
isLoading = true;
const success = await callback(data);
const success = await callback({
...data,
customFieldValues
});
// Reset form if user was successfully created
if (success && !existingUser) form.reset();
if (success && !existingUser) {
form.reset();
customFieldValues = [];
}
isLoading = false;
}
function onNameInput() {
@@ -129,6 +143,11 @@
description={m.disabled_users_cannot_log_in_or_use_services()}
bind:checked={$inputs.disabled.value}
/>
<CustomFieldValuesInput
bind:this={customFieldValuesInputRef}
bind:customFieldValues
{customFields}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>