mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-20 18:09:05 +02:00
feat!: replace custom claims with custom fields
This commit is contained in:
+30
-19
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
+213
@@ -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}
|
||||
/>
|
||||
+281
@@ -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
-12
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user