feat(ux): replace individual save buttons with single "unsaved changes bar" (#1757)

This commit is contained in:
Elias Schneider
2026-09-19 15:23:06 +00:00
committed by GitHub
parent 818333fb38
commit 63050add4e
58 changed files with 1916 additions and 1184 deletions
@@ -6,7 +6,6 @@
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { ApiCreate } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus, LucidePlus, LucideServer } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
@@ -18,18 +17,9 @@
const apisService = new ApisService();
async function createApi(api: ApiCreate) {
let success = true;
await apisService
.create(api)
.then((createdApi) => {
toast.success(m.api_created_successfully());
goto(`/settings/admin/apis/${createdApi.id}`);
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
const createdApi = await apisService.create(api);
toast.success(m.api_created_successfully());
goto(`/settings/admin/apis/${createdApi.id}`);
}
</script>
@@ -1,13 +1,11 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { ApiCimdAccessUpdate, ApiCreate, ApiPermissionInput } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../../users/navigate-back-util';
import ApiForm from '../api-form.svelte';
import ApiAccessCard from './api-access-card.svelte';
@@ -17,7 +15,9 @@
let api = $state(data.api);
let permissions = $state<ApiPermissionInput[]>(toPermissionInputs(data.api.permissions));
function toPermissionInputs(apiPermissions: typeof data.api.permissions): ApiPermissionInput[] {
function toPermissionInputs(
apiPermissions: { key: string; name: string; description?: string }[]
): ApiPermissionInput[] {
const inputs = apiPermissions.map((p) => ({
key: p.key,
name: p.name,
@@ -37,47 +37,32 @@
let accessCard = $state<ApiAccessCard>();
async function updateApi(updated: ApiCreate) {
let success = true;
await apisService
.update(api.id, { name: updated.name })
.then((res) => {
api = { ...api, ...res };
toast.success(m.api_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
api = { ...api, ...(await apisService.update(api.id, { name: updated.name })) };
}
async function updatePermissions() {
await apisService
.updatePermissions(
api.id,
permissions.filter((p) => !isEmptyPermission(p))
)
.then((res) => {
api = res;
permissions = toPermissionInputs(res.permissions);
toast.success(m.api_permissions_updated_successfully());
})
.catch(axiosErrorToast);
// A removed permission takes the client grants that referenced it with it
await accessCard?.refresh();
async function updatePermissions(updatedPermissions: ApiPermissionInput[]) {
try {
const res = await apisService.updatePermissions(api.id, updatedPermissions);
api = res;
permissions = toPermissionInputs(res.permissions);
} finally {
// A removed permission takes the client grants that referenced it with it
await accessCard?.refresh();
}
}
trackUnsavedValue(
() => permissions.filter((p) => !isEmptyPermission(p)),
(savedPermissions) => (permissions = toPermissionInputs(savedPermissions)),
updatePermissions
);
async function updateCimdAccess(update: ApiCimdAccessUpdate) {
await apisService
.updateCimdAccess(api.id, update)
.then((res) => {
api = res;
toast.success(m.api_access_updated_successfully());
})
.catch(axiosErrorToast);
await accessCard?.refresh();
try {
api = await apisService.updateCimdAccess(api.id, update);
} finally {
await accessCard?.refresh();
}
}
</script>
@@ -108,9 +93,6 @@
defaultExpanded={true}
>
<ApiPermissionsInput bind:permissions />
<div class="mt-5 flex justify-end">
<Button usePromiseLoading onclick={updatePermissions}>{m.save()}</Button>
</div>
</CollapsibleCard>
<ApiAccessCard bind:this={accessCard} {api} onCimdAccessSave={updateCimdAccess} />
@@ -10,7 +10,10 @@
let {
api,
onCimdAccessSave
}: { api: Api; onCimdAccessSave: (update: ApiCimdAccessUpdate) => Promise<void> } = $props();
}: {
api: Api;
onCimdAccessSave: (update: ApiCimdAccessUpdate) => Promise<void>;
} = $props();
let clientsTab = $state<ApiClientsTab>();
let tab = $state('clients');
@@ -1,31 +1,28 @@
<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';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import type { Api, ApiCimdAccessUpdate } from '$lib/types/api.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let { api, onSave }: { api: Api; onSave: (update: ApiCimdAccessUpdate) => Promise<void> } =
$props();
const formSchema = z.object({ enabled: z.boolean(), permissionIds: z.array(z.string()) });
const { inputs, ...form } = createForm(formSchema, {
const formStore = createForm(formSchema, {
enabled: api.allowCimdClients,
permissionIds: api.permissions.filter((p) => p.allowedForCimdClients).map((p) => p.id)
});
const { inputs } = formStore;
async function save() {
const data = form.validate();
if (data) await onSave(data);
}
trackFormChanges(() => formStore, onSave);
</script>
<form novalidate onsubmit={preventDefault(save)}>
<div>
<FormInput bind:input={$inputs.enabled} class="my-5">
<SwitchWithLabel
id="allow-cimd-clients"
@@ -48,7 +45,7 @@
class="mt-0.5"
checked={$inputs.permissionIds.value.includes(permission.id)}
onCheckedChange={(checked: boolean) =>
form.setValue(
formStore.setValue(
'permissionIds',
checked
? [...$inputs.permissionIds.value, permission.id]
@@ -71,8 +68,4 @@
{/if}
</div>
{/if}
<div class="mt-5 flex justify-end">
<Button type="submit" usePromiseLoading>{m.save()}</Button>
</div>
</form>
</div>
@@ -3,8 +3,10 @@
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { Api, ApiCreate } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let {
@@ -12,7 +14,7 @@
existingApi
}: {
existingApi?: Api;
callback: (api: ApiCreate) => Promise<boolean>;
callback: (api: ApiCreate) => Promise<void>;
} = $props();
let isLoading = $state(false);
@@ -35,17 +37,30 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, api);
const formStore = createForm<FormSchema>(formSchema, api);
const { inputs } = formStore;
async function saveApi(data: z.infer<FormSchema>) {
await callback(data);
if (!existingApi) formStore.reset();
}
// Create mode has its own Save button rather than going through the unsaved-changes bar.
async function onSubmit() {
const data = form.validate();
const data = formStore.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
if (success && !existingApi) {
form.reset();
try {
await saveApi(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
isLoading = false;
}
if (isEdit) {
trackFormChanges(() => formStore, saveApi);
}
</script>
@@ -59,7 +74,9 @@
readonly={isEdit}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{#if !isEdit}
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{/if}
</form>
@@ -6,9 +6,7 @@
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 { LucideInfo } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import AppConfigDynamicClientsForm from './forms/app-config-dynamic-clients-form.svelte';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
@@ -19,20 +17,17 @@
let { data } = $props();
let appConfig = $state(data.appConfig);
let persistedAppConfig = { ...data.appConfig };
const appConfigService = new AppConfigService();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
appConfig = await appConfigService
.update({
...appConfig,
...updatedAppConfig
})
.catch((e) => {
axiosErrorToast(e);
throw e;
});
await appConfigStore.reload();
persistedAppConfig = await appConfigService.update({
...persistedAppConfig,
...updatedAppConfig
});
appConfigStore.set({ ...$appConfigStore, ...persistedAppConfig });
}
async function updateImages(
@@ -84,9 +79,7 @@
defaultProfilePicturePromise,
backgroundImagePromise,
faviconPromise
])
.then(() => toast.success(m.images_updated_successfully()))
.catch(axiosErrorToast);
]);
}
</script>
@@ -27,9 +27,14 @@
let isCustomColor = $derived(!accentColors.some((c) => c.color === selectedColor));
let isPreviousColorCustom = $derived(!accentColors.some((c) => c.color === previousColor));
// Applies the selection as a live preview. Because it re-runs whenever the bound value
// changes, it also restores the saved colour when the selection is discarded.
$effect(() => {
applyAccentColor(selectedColor);
});
function handleAccentColorChange(accentValue: string) {
selectedColor = accentValue;
applyAccentColor(accentValue);
}
</script>
@@ -1,13 +1,11 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import UrlListInput from '$lib/components/form/url-list-input.svelte';
import { Button } from '$lib/components/ui/button';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { toast } from 'svelte-sonner';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
let {
appConfig,
@@ -17,35 +15,25 @@
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let cimdUrlAllowlist: string[] = $derived(
appConfig.cimdUrlAllowlist?.length ? appConfig.cimdUrlAllowlist : ['']
let cimdUrlAllowlist: string[] = $state(appConfig.cimdUrlAllowlist ?? []);
if (cimdUrlAllowlist.length === 0) cimdUrlAllowlist.push('');
trackUnsavedValue(
() => cimdUrlAllowlist.filter((u) => u.trim() !== ''),
(value) => {
cimdUrlAllowlist = value;
if (cimdUrlAllowlist.length === 0) cimdUrlAllowlist.push('');
},
(value) => callback({ cimdUrlAllowlist: value })
);
let isLoading = $state(false);
async function onSubmit() {
isLoading = true;
const update: Partial<AllAppConfig> = {
cimdUrlAllowlist: cimdUrlAllowlist.filter((u) => u.trim() !== '')
};
await callback(update).finally(() => (isLoading = false));
toast.success(m.application_configuration_updated_successfully());
}
</script>
{#snippet cimdUrlAllowlistDescription()}
<FormattedMessage message={m.cimd_url_allowlist_description} />
{/snippet}
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<FormInput label={m.cimd_url_allowlist()} description={cimdUrlAllowlistDescription}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" keepAtLeastOne />
</FormInput>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<FormInput label={m.cimd_url_allowlist()} description={cimdUrlAllowlistDescription}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" keepAtLeastOne />
</FormInput>
</fieldset>
@@ -9,8 +9,9 @@
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 { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { createForm, pickSchemaValues } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
@@ -87,23 +88,32 @@
requireFieldsWhen(emailEnabled, m.smtp_field_required_when_email_enabled());
});
let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
const formStore = createForm(formSchema, pickSchemaValues(formSchema, appConfig));
const inputs = formStore.inputs;
async function onSubmit() {
const data = form.validate();
if (!data) return false;
async function saveEmailConfig(data: z.infer<typeof formSchema>) {
await callback(data);
// Update the app config to don't display the unsaved changes warning
Object.assign(appConfig, data);
toast.success(m.email_configuration_updated_successfully());
return true;
}
// Saving from the "send test email" prompt is outside the unsaved-changes bar, so it
// reports failures itself rather than letting them reach the bar.
async function saveForTestEmail() {
const data = formStore.validate();
if (!data) return false;
try {
await saveEmailConfig(data);
formStore.commit(data);
return true;
} catch (e) {
axiosErrorToast(e);
return false;
}
}
trackFormChanges(() => formStore, saveEmailConfig);
async function onTestEmail() {
const hasChanges = Object.entries($inputs).some(
([key, input]) => input.value !== appConfig[key as keyof AllAppConfig]
);
const hasChanges = formStore.isDirty();
if (hasChanges) {
openConfirmDialog({
@@ -113,8 +123,7 @@
confirm: {
label: m.save_and_send(),
action: async () => {
const saved = await onSubmit();
if (saved) {
if (await saveForTestEmail()) {
sendTestEmail();
}
}
@@ -135,7 +144,7 @@
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<div>
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<h4 class="mb-4 text-lg font-semibold">{m.general()}</h4>
<div class="flex flex-col gap-5">
@@ -221,6 +230,5 @@
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>{m.send_test_email()}</Button
>
<Button type="submit" disabled={$appConfigStore.uiConfigDisabled}>{m.save()}</Button>
</div>
</form>
</div>
@@ -1,15 +1,13 @@
<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';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
import AccentColorPicker from './accent-color-picker.svelte';
@@ -21,8 +19,6 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
const homePageUrlOptions = [
{ label: m.my_account(), value: '/settings/account' },
{ label: m.my_apps(), value: '/settings/apps' }
@@ -46,82 +42,71 @@
accentColor: z.string()
});
let { inputs, ...form } = $derived(createForm(formSchema, updatedAppConfig));
let formStore = $derived(createForm(formSchema, updatedAppConfig));
let inputs = $derived(formStore.inputs);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data).finally(() => (isLoading = false));
toast.success(m.application_configuration_updated_successfully());
}
trackFormChanges(() => formStore, callback);
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="flex flex-col gap-5">
<FormInput label={m.application_name()} bind:input={$inputs.appName} />
<FormInput
label={m.session_duration()}
type="number"
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<Field.Field>
<Field.Label>{m.app_config_home_page()}</Field.Label>
<Field.Description>
{m.app_config_home_page_description()}
</Field.Description>
<Select.Root
type="single"
value={$inputs.homePageUrl.value}
onValueChange={(v) => ($inputs.homePageUrl.value = v as string)}
>
<Select.Trigger class="w-full" aria-label={m.app_config_home_page()}>
{homePageUrlOptions.find((option) => option.value === $inputs.homePageUrl.value)
?.label ?? $inputs.homePageUrl.value}
</Select.Trigger>
<Select.Content>
{#each homePageUrlOptions as option (option.value)}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<SwitchWithLabel
id="self-account-editing"
label={m.enable_self_account_editing()}
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value}
/>
<SwitchWithLabel
id="disable-animations"
label={m.disable_animations()}
description={m.turn_off_ui_animations()}
bind:checked={$inputs.disableAnimations.value}
/>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="flex flex-col gap-5">
<FormInput label={m.application_name()} bind:input={$inputs.appName} />
<FormInput
label={m.session_duration()}
type="number"
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<Field.Field>
<Field.Label>{m.app_config_home_page()}</Field.Label>
<Field.Description>
{m.app_config_home_page_description()}
</Field.Description>
<Select.Root
type="single"
value={$inputs.homePageUrl.value}
onValueChange={(v) => ($inputs.homePageUrl.value = v as string)}
>
<Select.Trigger class="w-full" aria-label={m.app_config_home_page()}>
{homePageUrlOptions.find((option) => option.value === $inputs.homePageUrl.value)?.label ??
$inputs.homePageUrl.value}
</Select.Trigger>
<Select.Content>
{#each homePageUrlOptions as option (option.value)}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<SwitchWithLabel
id="self-account-editing"
label={m.enable_self_account_editing()}
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value}
/>
<SwitchWithLabel
id="disable-animations"
label={m.disable_animations()}
description={m.turn_off_ui_animations()}
bind:checked={$inputs.disableAnimations.value}
/>
<Field.Field class="space-y-5">
<div>
<Field.Label>
{m.accent_color()}
</Field.Label>
<Field.Description>
{m.select_an_accent_color_to_customize_the_appearance_of_pocket_id()}
</Field.Description>
</div>
<AccentColorPicker
previousColor={appConfig.accentColor}
bind:selectedColor={$inputs.accentColor.value}
disabled={$appConfigStore.uiConfigDisabled}
/>
</Field.Field>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>
<Field.Field class="space-y-5">
<div>
<Field.Label>
{m.accent_color()}
</Field.Label>
<Field.Description>
{m.select_an_accent_color_to_customize_the_appearance_of_pocket_id()}
</Field.Description>
</div>
<AccentColorPicker
previousColor={appConfig.accentColor}
bind:selectedColor={$inputs.accentColor.value}
disabled={$appConfigStore.uiConfigDisabled}
/>
</Field.Field>
</div>
</fieldset>
@@ -7,8 +7,8 @@
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 { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { createForm, pickSchemaValues } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
@@ -47,19 +47,18 @@
ldapSoftDeleteUsers: z.boolean()
});
let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
const formStore = createForm(formSchema, pickSchemaValues(formSchema, appConfig));
const inputs = formStore.inputs;
async function onSubmit() {
const data = form.validate();
if (!data) return false;
await callback({
...data,
ldapEnabled: true
});
toast.success(m.ldap_configuration_updated_successfully());
return true;
async function saveLdap(data: z.infer<typeof formSchema>, enable: boolean) {
await callback({ ...data, ldapEnabled: enable });
}
trackFormChanges(
() => formStore,
(data) => saveLdap(data, ldapEnabled)
);
async function onDisable() {
ldapEnabled = false;
await callback({ ldapEnabled });
@@ -67,8 +66,15 @@
}
async function onEnable() {
if (await onSubmit()) {
const data = formStore.validate();
if (!data) return;
try {
await saveLdap(data, true);
formStore.commit(data);
ldapEnabled = true;
toast.success(m.ldap_configuration_updated_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
@@ -83,7 +89,7 @@
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<div>
<h4 class="text-lg font-semibold">{m.client_configuration()}</h4>
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
@@ -204,9 +210,8 @@
>{m.disable()}</Button
>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>{m.sync_now()}</Button>
<Button type="submit" disabled={$appConfigStore.uiConfigDisabled}>{m.save()}</Button>
{:else}
<Button onclick={onEnable} disabled={$appConfigStore.uiConfigDisabled}>{m.enable()}</Button>
{/if}
</div>
</form>
</div>
@@ -1,14 +1,12 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let {
@@ -19,8 +17,6 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
const formSchema = z.object({
webauthnUserVerification: z.enum(['required', 'preferred']),
webauthnAllowSyncedPasskeys: z.boolean(),
@@ -59,117 +55,97 @@
}
};
let { inputs, ...form } = $derived(createForm(formSchema, initialConfig));
let formStore = $derived(createForm(formSchema, initialConfig));
let inputs = $derived(formStore.inputs);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
try {
await callback(data);
toast.success(m.passkey_configuration_updated_successfully());
} finally {
isLoading = false;
}
}
trackFormChanges(() => formStore, callback);
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<Field.Group>
<Field.Field data-invalid={!!$inputs.webauthnUserVerification.error}>
<div>
<Field.Label for="passkey-user-verification">{m.user_verification()}</Field.Label>
<Field.Description>{m.user_verification_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnUserVerification.value}
onValueChange={(value) =>
($inputs.webauthnUserVerification.value = value as 'required' | 'preferred')}
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<Field.Group>
<Field.Field data-invalid={!!$inputs.webauthnUserVerification.error}>
<div>
<Field.Label for="passkey-user-verification">{m.user_verification()}</Field.Label>
<Field.Description>{m.user_verification_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnUserVerification.value}
onValueChange={(value) =>
($inputs.webauthnUserVerification.value = value as 'required' | 'preferred')}
>
<Select.Trigger
id="passkey-user-verification"
class="w-full"
aria-label={m.user_verification()}
aria-invalid={!!$inputs.webauthnUserVerification.error}
placeholder={m.user_verification()}
>
<Select.Trigger
id="passkey-user-verification"
class="w-full"
aria-label={m.user_verification()}
aria-invalid={!!$inputs.webauthnUserVerification.error}
placeholder={m.user_verification()}
>
{userVerificationOptions[$inputs.webauthnUserVerification.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(userVerificationOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
{userVerificationOptions[$inputs.webauthnUserVerification.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(userVerificationOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Content>
<div>
<Field.Label for="allow-synced-passkeys">{m.allow_synced_passkeys()}</Field.Label>
<Field.Description>{m.allow_synced_passkeys_description()}</Field.Description>
</div>
</Field.Content>
<Switch
id="allow-synced-passkeys"
class="my-auto"
bind:checked={$inputs.webauthnAllowSyncedPasskeys.value}
/>
</Field.Field>
<Field.Field data-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}>
<Field.Field orientation="horizontal">
<Field.Content>
<div>
<Field.Label for="passkey-authenticator-type"
>{m.allowed_authenticator_type()}</Field.Label
>
<Field.Description>{m.allowed_authenticator_type_description()}</Field.Description>
<Field.Label for="allow-synced-passkeys">{m.allow_synced_passkeys()}</Field.Label>
<Field.Description>{m.allow_synced_passkeys_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnAuthenticatorAttachment.value}
onValueChange={(value) =>
($inputs.webauthnAuthenticatorAttachment.value = value as
'any' | 'platform' | 'cross-platform')}
>
<Select.Trigger
id="passkey-authenticator-type"
class="w-full"
aria-label={m.allowed_authenticator_type()}
aria-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}
placeholder={m.allowed_authenticator_type()}
>
{authenticatorAttachmentOptions[$inputs.webauthnAuthenticatorAttachment.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(authenticatorAttachmentOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
</Field.Content>
<Switch
id="allow-synced-passkeys"
class="my-auto"
bind:checked={$inputs.webauthnAllowSyncedPasskeys.value}
/>
</Field.Field>
<Field.Field orientation="horizontal" class="justify-end">
<Button type="submit" disabled={$appConfigStore.uiConfigDisabled} {isLoading}>
{m.save()}
</Button>
</Field.Field>
</Field.Group>
</fieldset>
</form>
<Field.Field data-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}>
<div>
<Field.Label for="passkey-authenticator-type">{m.allowed_authenticator_type()}</Field.Label>
<Field.Description>{m.allowed_authenticator_type_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnAuthenticatorAttachment.value}
onValueChange={(value) =>
($inputs.webauthnAuthenticatorAttachment.value = value as
'any' | 'platform' | 'cross-platform')}
>
<Select.Trigger
id="passkey-authenticator-type"
class="w-full"
aria-label={m.allowed_authenticator_type()}
aria-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}
placeholder={m.allowed_authenticator_type()}
>
{authenticatorAttachmentOptions[$inputs.webauthnAuthenticatorAttachment.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(authenticatorAttachmentOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
</Field.Group>
</fieldset>
@@ -1,14 +1,12 @@
<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';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { toast } from 'svelte-sonner';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
let {
appConfig,
@@ -18,10 +16,9 @@
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs ?? []);
let customClaims = $state(appConfig.signupDefaultCustomClaims ?? []);
let allowUserSignups = $state(appConfig.allowUserSignups);
let isLoading = $state(false);
const signupOptions = {
disabled: {
@@ -38,97 +35,89 @@
}
};
async function onSubmit() {
isLoading = true;
await callback({
allowUserSignups: allowUserSignups,
trackUnsavedValue(
() => ({
allowUserSignups,
signupDefaultUserGroupIDs: selectedGroupIds,
signupDefaultCustomClaims: customClaims
});
toast.success(m.user_creation_updated_successfully());
isLoading = false;
}
$effect(() => {
customClaims = appConfig.signupDefaultCustomClaims || [];
allowUserSignups = appConfig.allowUserSignups;
});
}),
(value) => {
allowUserSignups = value.allowUserSignups;
selectedGroupIds = value.signupDefaultUserGroupIDs;
customClaims = value.signupDefaultCustomClaims;
},
callback
);
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="grid gap-2">
<Field.Field>
<div>
<Field.Label for="enable-user-signup">{m.enable_user_signups()}</Field.Label>
<Field.Description>
{m.enable_user_signups_description()}
</Field.Description>
</div>
<Select.Root
type="single"
value={allowUserSignups}
onValueChange={(v) => (allowUserSignups = v as typeof allowUserSignups)}
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="grid gap-2">
<Field.Field>
<div>
<Field.Label for="enable-user-signup">{m.enable_user_signups()}</Field.Label>
<Field.Description>
{m.enable_user_signups_description()}
</Field.Description>
</div>
<Select.Root
type="single"
value={allowUserSignups}
onValueChange={(v) => (allowUserSignups = v as typeof allowUserSignups)}
>
<Select.Trigger
id="enable-user-signup"
class="w-full"
aria-label={m.enable_user_signups()}
placeholder={m.enable_user_signups()}
>
<Select.Trigger
id="enable-user-signup"
class="w-full"
aria-label={m.enable_user_signups()}
placeholder={m.enable_user_signups()}
>
{signupOptions[allowUserSignups]?.label}
</Select.Trigger>
<Select.Content>
<Select.Item value="disabled">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.disabled.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.disabled.description}
</span>
</div>
</Select.Item>
<Select.Item value="withToken">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.withToken.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.withToken.description}
</span>
</div>
</Select.Item>
<Select.Item value="open">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.open.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.open.description}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
</div>
<Field.Field>
<div>
<Field.Label for="default-groups">{m.user_groups()}</Field.Label>
<Field.Description>
{m.user_creation_groups_description()}
</Field.Description>
</div>
<UserGroupInput bind:selectedGroupIds />
</Field.Field>
<Field.Field>
<div>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
</div>
<CustomClaimsInput bind:customClaims />
{signupOptions[allowUserSignups]?.label}
</Select.Trigger>
<Select.Content>
<Select.Item value="disabled">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.disabled.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.disabled.description}
</span>
</div>
</Select.Item>
<Select.Item value="withToken">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.withToken.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.withToken.description}
</span>
</div>
</Select.Item>
<Select.Item value="open">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.open.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.open.description}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
</div>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
<Field.Field>
<div>
<Field.Label for="default-groups">{m.user_groups()}</Field.Label>
<Field.Description>
{m.user_creation_groups_description()}
</Field.Description>
</div>
</fieldset>
</form>
<UserGroupInput bind:selectedGroupIds />
</Field.Field>
<Field.Field>
<div>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
</div>
<CustomClaimsInput bind:customClaims />
</Field.Field>
</fieldset>
@@ -1,6 +1,5 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import {
cachedApplicationLogo,
@@ -8,6 +7,7 @@
cachedDefaultProfilePicture,
cachedEmailLogo
} from '$lib/utils/cached-image-util';
import { trackUnsavedSection } from '$lib/utils/unsaved-changes-util.svelte';
import ApplicationImage from './application-image.svelte';
let {
@@ -20,7 +20,7 @@
defaultProfilePicture: File | null | undefined,
backgroundImage: File | null | undefined,
favicon: File | undefined
) => void;
) => Promise<void>;
} = $props();
let logoLight = $state<File | null | undefined>();
@@ -34,6 +34,44 @@
let backgroundImageSet = $state(true);
let logoLightSet = $state(true);
let logoDarkSet = $state(true);
// Bumped on discard to remount the image inputs, resetting their internal preview state.
let resetKey = $state(0);
// An image is "pending" while it holds a file to upload (or null to reset it to the default).
function clearPendingImages() {
logoLight =
logoDark =
logoEmail =
defaultProfilePicture =
backgroundImage =
favicon =
undefined;
}
trackUnsavedSection(
() =>
[logoLight, logoDark, logoEmail, defaultProfilePicture, backgroundImage, favicon].some(
(image) => image !== undefined
),
async () => {
await callback(
logoLight,
logoDark,
logoEmail,
defaultProfilePicture,
backgroundImage,
favicon
);
// The previews already show the uploaded images, so only the pending files are cleared.
clearPendingImages();
},
() => {
clearPendingImages();
logoLightSet = logoDarkSet = defaultProfilePictureSet = backgroundImageSet = true;
resetKey++;
}
);
</script>
{#snippet lightLogoFallback()}
@@ -44,71 +82,64 @@
<Logo defaultOnly colorScheme="dark" class="size-full" />
{/snippet}
<div class="flex flex-col gap-8">
<ApplicationImage
id="favicon"
imageClass="size-14 p-2"
label={m.favicon()}
bind:image={favicon}
imageURL="/api/application-images/favicon"
accept="image/svg+xml, image/png, image/x-icon"
/>
<ApplicationImage
id="logo-light"
imageClass="size-24"
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL={cachedApplicationLogo.getUrl(true)}
fallback={lightLogoFallback}
forceColorScheme="light"
isResetable
bind:isImageSet={logoLightSet}
/>
<ApplicationImage
id="logo-dark"
imageClass="size-24"
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL={cachedApplicationLogo.getUrl(false)}
fallback={darkLogoFallback}
forceColorScheme="dark"
isResetable
bind:isImageSet={logoDarkSet}
/>
<ApplicationImage
id="logo-email"
imageClass="size-24"
label={m.email_logo()}
bind:image={logoEmail}
imageURL={cachedEmailLogo.getUrl()}
accept="image/png, image/jpeg"
forceColorScheme="light"
/>
<ApplicationImage
id="default-profile-picture"
imageClass="size-24"
label={m.default_profile_picture()}
isResetable
bind:image={defaultProfilePicture}
imageURL={cachedDefaultProfilePicture.getUrl()}
isImageSet={defaultProfilePictureSet}
/>
<ApplicationImage
id="background-image"
imageClass="max-h-[350px] max-w-[500px]"
label={m.background_image()}
isResetable
bind:image={backgroundImage}
imageURL={cachedBackgroundImage.getUrl()}
isImageSet={backgroundImageSet}
/>
</div>
<div class="flex justify-end">
<Button
class="mt-5"
usePromiseLoading
onclick={() =>
callback(logoLight, logoDark, logoEmail, defaultProfilePicture, backgroundImage, favicon)}
>{m.save()}</Button
>
</div>
{#key resetKey}
<div class="flex flex-col gap-8">
<ApplicationImage
id="favicon"
imageClass="size-14 p-2"
label={m.favicon()}
bind:image={favicon}
imageURL="/api/application-images/favicon"
accept="image/svg+xml, image/png, image/x-icon"
/>
<ApplicationImage
id="logo-light"
imageClass="size-24"
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL={cachedApplicationLogo.getUrl(true)}
fallback={lightLogoFallback}
forceColorScheme="light"
isResetable
bind:isImageSet={logoLightSet}
/>
<ApplicationImage
id="logo-dark"
imageClass="size-24"
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL={cachedApplicationLogo.getUrl(false)}
fallback={darkLogoFallback}
forceColorScheme="dark"
isResetable
bind:isImageSet={logoDarkSet}
/>
<ApplicationImage
id="logo-email"
imageClass="size-24"
label={m.email_logo()}
bind:image={logoEmail}
imageURL={cachedEmailLogo.getUrl()}
accept="image/png, image/jpeg"
forceColorScheme="light"
/>
<ApplicationImage
id="default-profile-picture"
imageClass="size-24"
label={m.default_profile_picture()}
isResetable
bind:image={defaultProfilePicture}
imageURL={cachedDefaultProfilePicture.getUrl()}
isImageSet={defaultProfilePictureSet}
/>
<ApplicationImage
id="background-image"
imageClass="max-h-[350px] max-w-[500px]"
label={m.background_image()}
isResetable
bind:image={backgroundImage}
imageURL={cachedBackgroundImage.getUrl()}
isImageSet={backgroundImageSet}
/>
</div>
{/key}
@@ -8,7 +8,6 @@
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus, ShieldCheck, ShieldPlus } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
@@ -20,26 +19,20 @@
const oidcService = new OIDCService();
async function createOIDCClient(client: OidcClientCreateWithLogo) {
try {
clientSecretStore.clear();
const createdClient = await oidcService.createClient(client);
clientSecretStore.clear();
const createdClient = await oidcService.createClient(client);
const logoPromise = client.logo
? oidcService.updateClientLogo(createdClient, client.logo, true)
: Promise.resolve();
const darkLogoPromise = client.darkLogo
? oidcService.updateClientLogo(createdClient, client.darkLogo, false)
: Promise.resolve();
await Promise.all([logoPromise, darkLogoPromise]);
const logoPromise = client.logo
? oidcService.updateClientLogo(createdClient, client.logo, true)
: Promise.resolve();
const darkLogoPromise = client.darkLogo
? oidcService.updateClientLogo(createdClient, client.darkLogo, false)
: Promise.resolve();
await Promise.all([logoPromise, darkLogoPromise]);
// A new client starts without any secret: the admin creates the ones they need from the credentials tab
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(createdClient.id)}`);
toast.success(m.oidc_client_created_successfully());
return true;
} catch (e) {
axiosErrorToast(e);
return false;
}
// A new client starts without any secret: the admin creates the ones they need from the credentials tab
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(createdClient.id)}`);
toast.success(m.oidc_client_created_successfully());
}
</script>
@@ -1,5 +1,4 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
@@ -23,7 +22,9 @@
import type { ScimServiceProviderCreate } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { LucideChevronLeft, LucideInfo, LucideTriangleAlert } from '@lucide/svelte';
import { onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import { backNavigate } from '../../users/navigate-back-util';
@@ -40,7 +41,6 @@
...data.client,
allowedUserGroupIds: data.client.allowedUserGroups.map((g) => g.id)
});
// Secrets are managed by their own endpoints, so they are kept out of the client object that the forms below submit
let clientSecrets = $state<OidcClientSecret[]>(data.client.credentials?.secrets ?? []);
@@ -52,6 +52,14 @@
const scimService = new ScimService();
const backNavigation = backNavigate('/settings/admin/oidc-clients');
const allowedUserGroups = trackUnsavedValue(
() => client.allowedUserGroupIds,
(allowedUserGroupIds) => {
client.allowedUserGroupIds = allowedUserGroupIds;
},
(allowedUserGroupIds) => oidcService.updateAllowedUserGroups(client.id, allowedUserGroupIds)
);
const setupDetails = $state({
[m.issuer_url()]: data.oidcConfiguration.issuer,
[m.authorization_url()]: data.oidcConfiguration.authorization_endpoint,
@@ -64,7 +72,6 @@
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise =
updatedClient.logo !== undefined
@@ -82,54 +89,41 @@
? m.enabled()
: m.disabled();
await Promise.all([dataPromise, imagePromise, darkImagePromise])
.then(() => {
setupDetails[m.requires_pushed_authorization_requests()] =
updatedClient.requiresPushedAuthorizationRequests ? m.enabled() : m.disabled();
if (updatedClient.logoUrl) {
cachedOidcClientLogo.bustCache(client.id, true);
}
if (updatedClient.darkLogoUrl) {
cachedOidcClientLogo.bustCache(client.id, false);
}
const [savedClient] = await Promise.all([dataPromise, imagePromise, darkImagePromise]);
Object.assign(client, savedClient);
// Update the hasLogo and hasDarkLogo flags after successful upload
if (updatedClient.logo !== undefined || updatedClient.logoUrl !== undefined) {
client.hasLogo = updatedClient.logo !== null || !!updatedClient.logoUrl;
}
if (updatedClient.darkLogo !== undefined || updatedClient.darkLogoUrl !== undefined) {
client.hasDarkLogo = updatedClient.darkLogo !== null || !!updatedClient.darkLogoUrl;
}
if (updatedClient.pkceEnabled) {
client.pkceEnabled = updatedClient.pkceEnabled;
}
toast.success(m.oidc_client_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
setupDetails[m.requires_pushed_authorization_requests()] =
updatedClient.requiresPushedAuthorizationRequests ? m.enabled() : m.disabled();
if (updatedClient.logoUrl) {
cachedOidcClientLogo.bustCache(client.id, true);
}
if (updatedClient.darkLogoUrl) {
cachedOidcClientLogo.bustCache(client.id, false);
}
return success;
// Update the hasLogo and hasDarkLogo flags after successful upload
if (updatedClient.logo !== undefined || updatedClient.logoUrl !== undefined) {
client.hasLogo = updatedClient.logo !== null || !!updatedClient.logoUrl;
}
if (updatedClient.darkLogo !== undefined || updatedClient.darkLogoUrl !== undefined) {
client.hasDarkLogo = updatedClient.darkLogo !== null || !!updatedClient.darkLogoUrl;
}
if (updatedClient.pkceEnabled) {
client.pkceEnabled = updatedClient.pkceEnabled;
}
}
async function updateTokenLifetimes(lifetimes: OidcClientTokenLifetimes) {
const success = await updateClient({ ...client, ...lifetimes });
if (success) {
client.accessTokenDurationMinutes = lifetimes.accessTokenDurationMinutes;
client.refreshTokenDurationMinutes = lifetimes.refreshTokenDurationMinutes;
}
return success;
await updateClient({ ...client, ...lifetimes });
client.accessTokenDurationMinutes = lifetimes.accessTokenDurationMinutes;
client.refreshTokenDurationMinutes = lifetimes.refreshTokenDurationMinutes;
}
async function updateFederatedCredentials(federatedIdentities: OidcClientFederatedIdentity[]) {
// Secrets are read-only in this request, but they are carried over so the client object keeps matching what the server has
const credentials: OidcClientCredentials = { federatedIdentities, secrets: clientSecrets };
const success = await updateClient({ ...client, credentials });
if (success) {
client.credentials = credentials;
}
return success;
await updateClient({ ...client, credentials });
client.credentials = credentials;
}
async function enableGroupRestriction() {
@@ -165,6 +159,7 @@
.then(() => {
toast.success(m.user_groups_restriction_updated_successfully());
client.allowedUserGroupIds = [];
allowedUserGroups.markSaved();
client.isGroupRestricted = false;
})
.catch(axiosErrorToast);
@@ -173,44 +168,18 @@
});
}
async function updateUserGroupClients(allowedGroups: string[]) {
await oidcService
.updateAllowedUserGroups(client.id, allowedGroups)
.then(() => {
toast.success(m.allowed_user_groups_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
});
}
async function saveScimServiceProvider(provider: ScimServiceProviderCreate | null) {
try {
if (!provider) {
await scimService.deleteServiceProvider(scimServiceProvider!.id);
scimServiceProvider = undefined;
toast.success(m.scim_disabled_successfully());
return true;
}
let createdProvider;
if (scimServiceProvider) {
createdProvider = await scimService.updateServiceProvider(scimServiceProvider.id, provider);
toast.success(m.scim_configuration_updated_successfully());
} else {
createdProvider = await scimService.createServiceProvider(provider);
toast.success(m.scim_enabled_successfully());
}
scimServiceProvider = createdProvider;
return true;
} catch (e) {
axiosErrorToast(e);
return false;
if (!provider) {
await scimService.deleteServiceProvider(scimServiceProvider!.id);
scimServiceProvider = undefined;
return;
}
scimServiceProvider = scimServiceProvider
? await scimService.updateServiceProvider(scimServiceProvider.id, provider)
: await scimService.createServiceProvider(provider);
}
beforeNavigate(() => {
clientSecretStore.clear();
});
onDestroy(() => clientSecretStore.clear());
</script>
<svelte:head>
@@ -343,11 +312,6 @@
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<div class="mt-5 flex justify-end gap-3">
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
<Button
usePromiseLoading
onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button
>
</div>
</Card.Content>
{/if}
@@ -3,8 +3,8 @@
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientFederatedIdentity } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { slide } from 'svelte/transition';
import { z } from 'zod/v4';
import FederatedIdentitiesInput from '../federated-identities-input.svelte';
@@ -14,10 +14,9 @@
callback
}: {
client: OidcClient;
callback: (federatedIdentities: OidcClientFederatedIdentity[]) => Promise<boolean>;
callback: (federatedIdentities: OidcClientFederatedIdentity[]) => Promise<void>;
} = $props();
let isLoading = $state(false);
const isCIMDClient = $derived(client.clientType === 'cimd');
const formSchema = z.object({
@@ -34,12 +33,16 @@
)
})
});
const { inputs, errors, ...form } = createForm(formSchema, {
const formStore = createForm(formSchema, {
credentials: {
federatedIdentities:
client.credentials?.federatedIdentities?.map((identity) => ({ ...identity })) ?? []
client.credentials?.federatedIdentities?.map((identity) => ({
...identity,
publicKeys: identity.publicKeys?.length ? identity.publicKeys : undefined
})) ?? []
}
});
const { inputs, errors } = formStore;
const hasFederatedIdentities = $derived($inputs.credentials.value.federatedIdentities.length > 0);
@@ -61,63 +64,54 @@
subject: '',
audience: '',
jwks: '',
publicKeys: [],
publicKeys: undefined,
replayProtection: true
}
];
}
async function onSubmit() {
if (isCIMDClient) return;
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data.credentials.federatedIdentities).finally(() => (isLoading = false));
// Metadata document clients manage their own credentials, so there is nothing to save here.
if (!isCIMDClient) {
trackFormChanges(
() => formStore,
(data) => callback(data.credentials.federatedIdentities)
);
}
</script>
<form novalidate onsubmit={preventDefault(onSubmit)}>
<Card.Root data-testid="federated-credentials-card">
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div>
<Card.Title>{m.federated_client_credentials()}</Card.Title>
<Card.Description>
{m.federated_client_credentials_description()}
<a
class="underline underline-offset-4"
href="https://pocket-id.org/docs/guides/oidc-client-authentication"
target="_blank"
rel="noreferrer"
>
{m.docs()}
</a>
</Card.Description>
</div>
{#if !hasFederatedIdentities}
<Button disabled={isCIMDClient} onclick={addFederatedIdentity}>
{m.create()}
</Button>
{/if}
<Card.Root data-testid="federated-credentials-card">
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div>
<Card.Title>{m.federated_client_credentials()}</Card.Title>
<Card.Description>
{m.federated_client_credentials_description()}
<a
class="underline underline-offset-4"
href="https://pocket-id.org/docs/guides/oidc-client-authentication"
target="_blank"
rel="noreferrer"
>
{m.docs()}
</a>
</Card.Description>
</div>
</Card.Header>
{#if hasFederatedIdentities}
<div transition:slide>
<Card.Content>
<FederatedIdentitiesInput
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
disabled={isCIMDClient}
/>
</Card.Content>
</div>
{/if}
{#if !isCIMDClient && hasFederatedIdentities}
<Card.Footer class="justify-end">
<Button type="submit" disabled={isLoading}>{m.save()}</Button>
</Card.Footer>
{/if}
</Card.Root>
</form>
{#if !hasFederatedIdentities}
<Button disabled={isCIMDClient} onclick={addFederatedIdentity}>
{m.create()}
</Button>
{/if}
</div>
</Card.Header>
{#if hasFederatedIdentities}
<div transition:slide>
<Card.Content>
<FederatedIdentitiesInput
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
disabled={isCIMDClient}
/>
</Card.Content>
</div>
{/if}
</Card.Root>
@@ -1,11 +1,10 @@
<script lang="ts">
import DurationInput from '$lib/components/form/duration-input.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientTokenLifetimes } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let {
@@ -13,11 +12,9 @@
callback
}: {
client: OidcClient;
callback: (lifetimes: OidcClientTokenLifetimes) => Promise<boolean>;
callback: (lifetimes: OidcClientTokenLifetimes) => Promise<void>;
} = $props();
let isLoading = $state(false);
const durationSchema = z
.number()
.min(1, { message: m.token_lifetime_minimum() })
@@ -29,44 +26,34 @@
accessTokenDurationMinutes: durationSchema,
refreshTokenDurationMinutes: durationSchema
});
const { inputs, ...form } = createForm(formSchema, {
const formStore = createForm(formSchema, {
accessTokenDurationMinutes: client.accessTokenDurationMinutes,
refreshTokenDurationMinutes: client.refreshTokenDurationMinutes
});
const { inputs } = formStore;
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data).finally(() => (isLoading = false));
}
trackFormChanges(() => formStore, callback);
</script>
<form novalidate onsubmit={preventDefault(onSubmit)}>
<Card.Root data-testid="token-lifetimes-card">
<Card.Header>
<Card.Title>{m.token_lifetimes()}</Card.Title>
<Card.Description>{m.token_lifetimes_description()}</Card.Description>
</Card.Header>
<Card.Content>
<div class="md:grid md:grid-cols-2 gap-10 space-y-5 md:space-y-0">
<DurationInput
id="access-token-lifetime"
label={m.access_token_lifetime()}
description={m.access_token_lifetime_description()}
bind:input={$inputs.accessTokenDurationMinutes}
/>
<DurationInput
id="refresh-token-lifetime"
label={m.refresh_token_inactivity_timeout()}
description={m.refresh_token_inactivity_timeout_description()}
bind:input={$inputs.refreshTokenDurationMinutes}
/>
</div>
</Card.Content>
<Card.Footer class="justify-end">
<Button type="submit" disabled={isLoading}>{m.save()}</Button>
</Card.Footer>
</Card.Root>
</form>
<Card.Root data-testid="token-lifetimes-card">
<Card.Header>
<Card.Title>{m.token_lifetimes()}</Card.Title>
<Card.Description>{m.token_lifetimes_description()}</Card.Description>
</Card.Header>
<Card.Content>
<div class="md:grid md:grid-cols-2 gap-10 space-y-5 md:space-y-0">
<DurationInput
id="access-token-lifetime"
label={m.access_token_lifetime()}
description={m.access_token_lifetime_description()}
bind:input={$inputs.accessTokenDurationMinutes}
/>
<DurationInput
id="refresh-token-lifetime"
label={m.refresh_token_inactivity_timeout()}
description={m.refresh_token_inactivity_timeout_description()}
bind:input={$inputs.refreshTokenDurationMinutes}
/>
</div>
</Card.Content>
</Card.Root>
@@ -5,8 +5,10 @@
import { m } from '$lib/paraglide/messages';
import ScimService from '$lib/services/scim-service';
import type { ScimServiceProvider, ScimServiceProviderCreate } from '$lib/types/scim.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { emptyToUndefined } from '$lib/utils/zod-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
@@ -17,7 +19,7 @@
oidcClientId
}: {
existingProvider?: ScimServiceProvider;
onSave: (provider: ScimServiceProviderCreate | null) => Promise<boolean>;
onSave: (provider: ScimServiceProviderCreate | null) => Promise<void>;
oidcClientId: string;
} = $props();
@@ -36,17 +38,39 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, serviceProvider);
const formStore = createForm<FormSchema>(formSchema, serviceProvider);
const { inputs } = formStore;
async function onSubmit() {
const data = form.validate();
if (!data) return false;
return await onSave({
async function saveProvider(data: z.infer<FormSchema>) {
await onSave({
...data,
oidcClientId
});
}
// Enable/disable/sync have their own buttons rather than going through the unsaved-changes
// bar, so they report their outcome themselves.
async function trySaveProvider() {
const data = formStore.validate();
if (!data) return false;
try {
await saveProvider(data);
formStore.commit(data);
return true;
} catch (e) {
axiosErrorToast(e);
return false;
}
}
async function onEnable() {
if (await trySaveProvider()) toast.success(m.scim_enabled_successfully());
}
// Tracked by the unsaved-changes bar once the provider exists; until then the Enable button
// saves it.
trackFormChanges(() => formStore, saveProvider, { enabled: () => !!existingProvider });
async function onDisable() {
openConfirmDialog({
title: m.disable_scim_provisioning(),
@@ -58,9 +82,17 @@
label: m.disable(),
destructive: true,
action: async () => {
await onSave(null);
form.setValue('endpoint', '');
form.setValue('token', '');
try {
await onSave(null);
toast.success(m.scim_disabled_successfully());
} catch (e) {
axiosErrorToast(e);
return;
}
formStore.setValue('endpoint', '');
formStore.setValue('token', '');
// The cleared fields are the saved state now, so they aren't unsaved changes.
formStore.commit({ endpoint: '', token: undefined });
}
}
});
@@ -78,8 +110,7 @@
confirm: {
label: m.save_and_sync(),
action: async () => {
const saved = await onSubmit();
if (saved) {
if (await trySaveProvider()) {
syncProvider();
}
}
@@ -106,7 +137,7 @@
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<form onsubmit={preventDefault(onEnable)}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput
@@ -137,8 +168,9 @@
{#if existingProvider}
<Button variant="destructive" onclick={onDisable}>{m.disable()}</Button>
<Button variant="secondary" isLoading={isSyncing} onclick={onSync}>{m.sync_now()}</Button>
{:else}
<Button type="submit">{m.enable()}</Button>
{/if}
<Button type="submit">{existingProvider ? m.save() : m.enable()}</Button>
</div>
</div>
</form>
@@ -30,11 +30,9 @@
children?: Snippet;
} = $props();
// The source can't be derived from the identity alone: it stays on "Public keys" while no key has been added yet
let keySources = $state<KeySource[]>([]);
function keySourceFor(index: number, identity: OidcClientFederatedIdentity): KeySource {
return keySources[index] ?? (identity.publicKeys?.length ? 'publicKeys' : 'jwks');
// An empty publicKeys array records that Public keys is selected before the first key is added
function keySourceFor(identity: OidcClientFederatedIdentity): KeySource {
return identity.publicKeys === undefined ? 'jwks' : 'publicKeys';
}
function addFederatedIdentity() {
@@ -45,7 +43,7 @@
subject: '',
audience: '',
jwks: '',
publicKeys: [],
publicKeys: undefined,
replayProtection: true
}
];
@@ -53,7 +51,6 @@
function removeFederatedIdentity(index: number) {
federatedIdentities = federatedIdentities.filter((_, i) => i !== index);
keySources = keySources.filter((_, i) => i !== index);
}
function updateFederatedIdentity<K extends keyof OidcClientFederatedIdentity>(
@@ -69,15 +66,12 @@
// Only one of the two sources is ever submitted, so the one that is not selected is cleared
function updateKeySource(index: number, source: KeySource) {
// The list is rebuilt in full so it stays aligned with the identities when one of them is removed
keySources = federatedIdentities.map((identity, i) =>
i === index ? source : keySourceFor(i, identity)
);
if (source === 'jwks') {
updateFederatedIdentity(index, 'publicKeys', []);
} else {
updateFederatedIdentity(index, 'jwks', '');
}
const identity = federatedIdentities[index];
federatedIdentities[index] = {
...identity,
jwks: source === 'jwks' ? identity.jwks : '',
publicKeys: source === 'publicKeys' ? (identity.publicKeys ?? []) : undefined
};
}
function getFieldError(index: number, field: keyof OidcClientFederatedIdentity): string | null {
@@ -157,7 +151,7 @@
<Field.Label>{m.signing_keys()}</Field.Label>
<RadioGroup.Root
class="flex flex-wrap gap-x-6 gap-y-3"
value={keySourceFor(i, identity)}
value={keySourceFor(identity)}
onValueChange={(value) => updateKeySource(i, value as KeySource)}
{disabled}
>
@@ -173,7 +167,7 @@
</div>
</RadioGroup.Root>
{#if keySourceFor(i, identity) === 'publicKeys'}
{#if keySourceFor(identity) === 'publicKeys'}
<FederatedIdentityKeysInput
id="public-keys-{i}"
publicKeys={identity.publicKeys ?? []}
@@ -18,9 +18,6 @@
disabled?: boolean;
} = $props();
// The list is owned by this component: the identity it belongs to lives in a store, whose
// updates don't propagate back down to this input
let keys = $state<Jwk[]>(publicKeys);
let pastedKey = $state('');
let error = $state<string | null>(null);
@@ -33,29 +30,27 @@
// A JWKS may repeat a key that was already added, and duplicate key IDs would make the key
// to verify an assertion with ambiguous
const existingKeyIds = new Set(keys.map(getJwkKeyId));
const existingKeyIds = new Set(publicKeys.map(getJwkKeyId));
const duplicate = result.keys.find((key) => existingKeyIds.has(getJwkKeyId(key)));
if (duplicate) {
error = m.public_key_already_added({ keyId: getJwkKeyId(duplicate) });
return;
}
keys = [...keys, ...result.keys];
onChange(keys);
onChange([...publicKeys, ...result.keys]);
pastedKey = '';
error = null;
}
function removeKey(index: number) {
keys = keys.filter((_, i) => i !== index);
onChange(keys);
onChange(publicKeys.filter((_, i) => i !== index));
}
</script>
<div class="flex flex-col gap-3">
{#if keys.length > 0}
{#if publicKeys.length > 0}
<ul class="flex flex-col gap-2" data-testid="federated-identity-public-keys">
{#each keys as key, i (getJwkKeyId(key))}
{#each publicKeys as key, i (getJwkKeyId(key))}
<li
class="bg-muted/40 flex items-center justify-between gap-3 rounded-2xl px-4 py-2"
data-testid="federated-identity-public-key"
@@ -11,8 +11,10 @@
OidcClientUpdateWithLogo
} from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { cn } from '$lib/utils/style';
import { callbackUrlSchema, emptyToUndefined, optionalUrl } from '$lib/utils/zod-util';
import { LucideChevronDown, LucideMoon, LucideSun } from '@lucide/svelte';
@@ -27,21 +29,28 @@
mode
}: {
existingClient?: OidcClient;
callback: (client: OidcClientCreateWithLogo | OidcClientUpdateWithLogo) => Promise<boolean>;
callback: (client: OidcClientCreateWithLogo | OidcClientUpdateWithLogo) => Promise<void>;
mode: 'create' | 'update';
} = $props();
let isLoading = $state(false);
let showAdvancedOptions = $state(false);
let logo = $state<File | null | undefined>();
let darkLogo = $state<File | null | undefined>();
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? cachedOidcClientLogo.getUrl(existingClient!.id) : null
);
let darkLogoDataURL: string | null = $state(
existingClient?.hasDarkLogo ? cachedOidcClientLogo.getUrl(existingClient!.id, false) : null
);
// What discarding restores the previews to; moves forward whenever a logo is saved.
let savedLogoDataURL = existingClient?.hasLogo
? cachedOidcClientLogo.getUrl(existingClient!.id)
: null;
let savedDarkLogoDataURL = existingClient?.hasDarkLogo
? cachedOidcClientLogo.getUrl(existingClient!.id, false)
: null;
let logoDataURL: string | null = $state(savedLogoDataURL);
let darkLogoDataURL: string | null = $state(savedDarkLogoDataURL);
const isCIMDClient = $derived(existingClient?.clientType === 'cimd');
// Defaults for new clients; existing clients keep the lifetimes edited in their own card.
const DEFAULT_ACCESS_TOKEN_DURATION_MINUTES = 60;
const DEFAULT_REFRESH_TOKEN_DURATION_MINUTES = 30 * 24 * 60;
const client = {
id: '',
name: existingClient?.name || '',
@@ -57,9 +66,7 @@
launchURL: existingClient?.launchURL || '',
logoUrl: '',
darkLogoUrl: '',
pkceSupported: existingClient?.pkceSupported || false,
accessTokenDurationMinutes: existingClient?.accessTokenDurationMinutes ?? 60,
refreshTokenDurationMinutes: existingClient?.refreshTokenDurationMinutes ?? 30 * 24 * 60
pkceSupported: existingClient?.pkceSupported || false
};
const formSchema = z.object({
@@ -84,58 +91,83 @@
skipConsent: z.boolean(),
launchURL: optionalUrl,
logoUrl: optionalUrl,
darkLogoUrl: optionalUrl,
accessTokenDurationMinutes: z
.number()
.min(1)
.max(365 * 24 * 60)
.int(),
refreshTokenDurationMinutes: z
.number()
.min(1)
.max(365 * 24 * 60)
.int()
darkLogoUrl: optionalUrl
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, client);
const formStore = createForm<FormSchema>(formSchema, client);
const { inputs } = formStore;
const pkcePromptNeeded = $derived(!$inputs.pkceEnabled.value && client.pkceSupported);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback({
async function saveClient(data: z.infer<FormSchema>) {
await callback({
...data,
credentials: existingClient?.credentials ?? { federatedIdentities: [], secrets: [] },
logo: $inputs.logoUrl?.value ? undefined : logo,
logoUrl: $inputs.logoUrl?.value,
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
darkLogoUrl: $inputs.darkLogoUrl?.value,
isGroupRestricted: existingClient?.isGroupRestricted ?? true
isGroupRestricted: existingClient?.isGroupRestricted ?? true,
// The token lifetimes are edited in their own card. The current values are sent along
// because the backend falls back to the defaults for missing ones.
accessTokenDurationMinutes:
existingClient?.accessTokenDurationMinutes ?? DEFAULT_ACCESS_TOKEN_DURATION_MINUTES,
refreshTokenDurationMinutes:
existingClient?.refreshTokenDurationMinutes ?? DEFAULT_REFRESH_TOKEN_DURATION_MINUTES
});
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
const hasDarkLogo = darkLogo != null || !!$inputs.darkLogoUrl?.value;
if (success && existingClient) {
if (existingClient) {
if (hasLogo) {
logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
}
if (hasDarkLogo) {
darkLogoDataURL = cachedOidcClientLogo.getUrl(existingClient.id, false);
}
savedLogoDataURL = logoDataURL;
savedDarkLogoDataURL = darkLogoDataURL;
// The uploaded file has been persisted, so it's no longer "pending" for dirty-tracking.
logo = undefined;
darkLogo = undefined;
} else {
formStore.reset();
}
}
if (success && !existingClient) form.reset();
isLoading = false;
// Create mode has its own Save button rather than going through the unsaved-changes bar.
async function onSubmit() {
const data = formStore.validate();
if (!data) return;
isLoading = true;
try {
await saveClient(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
function discardLogoChanges() {
logo = undefined;
darkLogo = undefined;
logoDataURL = savedLogoDataURL;
darkLogoDataURL = savedDarkLogoDataURL;
}
if (mode === 'update') {
trackFormChanges(() => formStore, saveClient, {
dirty: () => logo !== undefined || darkLogo !== undefined,
discard: discardLogoChanges
});
}
function onLogoChange(input: File | string | null, light: boolean = true) {
if (input == null) return;
const logoUrlInput = light ? $inputs.logoUrl : $inputs.darkLogoUrl;
const logoUrlKey = light ? 'logoUrl' : 'darkLogoUrl';
if (typeof input === 'string') {
if (light) {
@@ -145,7 +177,7 @@
darkLogo = null;
darkLogoDataURL = input || null;
}
logoUrlInput!.value = input;
formStore.setValue(logoUrlKey, input);
} else {
if (light) {
logo = input;
@@ -154,9 +186,7 @@
darkLogo = input;
darkLogoDataURL = URL.createObjectURL(input);
}
if (logoUrlInput) {
logoUrlInput.value = '';
}
formStore.setValue(logoUrlKey, '');
}
}
@@ -342,6 +372,8 @@
)}
/>
</Button>
<Button {isLoading} type="submit" class="absolute right-0">{m.save()}</Button>
{#if mode === 'create'}
<Button {isLoading} type="submit" class="absolute right-0">{m.save()}</Button>
{/if}
</div>
</form>
@@ -5,7 +5,6 @@
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus, UserCog, UserPlus } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
@@ -17,18 +16,9 @@
const userGroupService = new UserGroupService();
async function createUserGroup(userGroup: UserGroupCreate) {
let success = true;
await userGroupService
.create(userGroup)
.then((createdUserGroup) => {
toast.success(m.user_group_created_successfully());
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
const createdUserGroup = await userGroupService.create(userGroup);
toast.success(m.user_group_created_successfully());
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
}
</script>
@@ -1,7 +1,6 @@
<script lang="ts">
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 * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
@@ -9,9 +8,8 @@
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';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../../users/navigate-back-util';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
@@ -23,7 +21,6 @@
userIds: data.userGroup.users.map((u) => u.id),
allowedOidcClientIds: data.userGroup.allowedOidcClients.map((c) => c.id)
});
let oidcClientSelectionRef: OidcClientSelection;
const userGroupService = new UserGroupService();
@@ -31,47 +28,35 @@
const backNavigation = backNavigate('/settings/admin/user-groups');
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
let success = true;
await userGroupService
.update(userGroup.id, updatedUserGroup)
.then(() => toast.success(m.user_group_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
await userGroupService.update(userGroup.id, updatedUserGroup);
}
async function updateUserGroupUsers(userIds: string[]) {
await userGroupService
.updateUsers(userGroup.id, userIds)
.then(() => toast.success(m.users_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
trackUnsavedValue(
() => userGroup.userIds,
(userIds) => {
userGroup.userIds = userIds;
},
(userIds) => userGroupService.updateUsers(userGroup.id, userIds)
);
async function updateCustomClaims() {
await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
trackUnsavedValue(
() => userGroup.allowedOidcClientIds,
(clientIds) => {
userGroup.allowedOidcClientIds = clientIds;
},
async (clientIds) => {
await userGroupService.updateAllowedOidcClients(userGroup.id, clientIds);
oidcClientSelectionRef.refresh();
}
);
async function updateAllowedOidcClients(allowedClients: string[]) {
await userGroupService
.updateAllowedOidcClients(userGroup.id, allowedClients)
.then(() => {
toast.success(m.allowed_oidc_clients_updated_successfully());
oidcClientSelectionRef.refresh();
})
.catch((e) => {
axiosErrorToast(e);
});
}
trackUnsavedValue(
() => userGroup.customClaims,
(customClaims) => {
userGroup.customClaims = customClaims;
},
(customClaims) => customClaimService.updateUserGroupCustomClaims(userGroup.id, customClaims)
);
</script>
<svelte:head>
@@ -120,12 +105,6 @@
bind:selectedUserIds={userGroup.userIds}
selectionDisabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
onclick={() => updateUserGroupUsers(userGroup.userIds)}>{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -141,11 +120,6 @@
bind:this={oidcClientSelectionRef}
bind:selectedGroupIds={userGroup.allowedOidcClientIds}
/>
<div class="mt-5 flex justify-end gap-3">
<Button onclick={() => updateAllowedOidcClients(userGroup.allowedOidcClientIds)}
>{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -160,9 +134,6 @@
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -18,6 +18,15 @@
const oidcClientService = new OidcService();
let tableRef: AdvancedTable<OidcClientWithAllowedGroups>;
let unrestrictedClientIds = $state<string[]>([]);
function getTableSelectedIds() {
return [...new Set([...selectedGroupIds, ...unrestrictedClientIds])];
}
function setTableSelectedIds(clientIds: string[]) {
selectedGroupIds = clientIds.filter((id) => !unrestrictedClientIds.includes(id));
}
export function refresh() {
return tableRef?.refresh();
@@ -56,8 +65,7 @@
async function fetchCallback(requestOptions: ListRequestOptions) {
const clients = await oidcClientService.listClients(requestOptions);
const unrestrictedClientIds = clients.data.filter((c) => !c.isGroupRestricted).map((c) => c.id);
selectedGroupIds = [...new Set([...selectedGroupIds, ...unrestrictedClientIds])];
unrestrictedClientIds = clients.data.filter((c) => !c.isGroupRestricted).map((c) => c.id);
return clients;
}
@@ -82,7 +90,7 @@
id="oidc-client-selection"
{fetchCallback}
defaultSort={{ column: 'name', direction: 'asc' }}
bind:selectedIds={selectedGroupIds}
bind:selectedIds={getTableSelectedIds, setTableSelectedIds}
rowSelectionDisabled={(item) => !item.isGroupRestricted}
{columns}
/>
@@ -4,8 +4,10 @@
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let {
@@ -13,7 +15,7 @@
existingUserGroup
}: {
existingUserGroup?: UserGroupCreate;
callback: (userGroup: UserGroupCreate) => Promise<boolean>;
callback: (userGroup: UserGroupCreate) => Promise<void>;
} = $props();
let isLoading = $state(false);
@@ -31,7 +33,8 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, userGroup);
const formStore = createForm<FormSchema>(formSchema, userGroup);
const { inputs } = formStore;
function onFriendlyNameInput(e: any) {
if (!hasManualNameEdit) {
@@ -43,17 +46,31 @@
hasManualNameEdit = true;
}
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
async function saveUserGroup(data: z.infer<FormSchema>) {
await callback(data);
// Reset form if user group was successfully created
if (success && !existingUserGroup) {
form.reset();
if (!existingUserGroup) {
formStore.reset();
hasManualNameEdit = false;
}
isLoading = false;
}
// Create mode has its own Save button rather than going through the unsaved-changes bar.
async function onSubmit() {
const data = formStore.validate();
if (!data) return;
isLoading = true;
try {
await saveUserGroup(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
if (existingUserGroup) {
trackFormChanges(() => formStore, saveUserGroup);
}
</script>
@@ -77,8 +94,10 @@
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{#if !existingUserGroup}
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{/if}
</fieldset>
</form>
@@ -9,7 +9,6 @@
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { ChevronDown, LucideMinus, UserPen, UserPlus } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
@@ -28,17 +27,12 @@
const userService = new UserService();
async function createUser(user: UserCreate) {
let success = true;
await userService
.create(user)
.then(() => toast.success(m.user_created_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
await userListRef.refresh();
return success;
try {
await userService.create(user);
toast.success(m.user_created_successfully());
} finally {
await userListRef.refresh();
}
}
</script>
@@ -2,7 +2,6 @@
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';
import * as Card from '$lib/components/ui/card';
import * as Item from '$lib/components/ui/item/index.js';
import * as Tabs from '$lib/components/ui/tabs';
@@ -14,6 +13,7 @@
import type { Passkey } from '$lib/types/passkey.type';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { KeyRound, LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../navigate-back-util';
@@ -31,36 +31,25 @@
const customClaimService = new CustomClaimService();
const backNavigation = backNavigate('/settings/admin/users');
async function updateUserGroups(userIds: string[]) {
await userService
.updateUserGroups(user.id, userIds)
.then(() => toast.success(m.user_groups_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateUser(updatedUser: UserCreate) {
let success = true;
await userService
.update(user.id, updatedUser)
.then(() => toast.success(m.user_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
await userService.update(user.id, updatedUser);
}
async function updateCustomClaims() {
await customClaimService
.updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
trackUnsavedValue(
() => user.userGroupIds,
(userGroupIds) => {
user.userGroupIds = userGroupIds;
},
(userGroupIds) => userService.updateUserGroups(user.id, userGroupIds)
);
trackUnsavedValue(
() => user.customClaims,
(customClaims) => {
user.customClaims = customClaims;
},
(customClaims) => customClaimService.updateUserCustomClaims(user.id, customClaims)
);
async function updateProfilePicture(image: File) {
await userService
@@ -137,13 +126,6 @@
bind:selectedGroupIds={user.userGroupIds}
selectionDisabled={!!user.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
onclick={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -179,9 +161,6 @@
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -8,8 +8,10 @@
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { LucideMailCheck, LucideMailWarning } from '@lucide/svelte';
import { get } from 'svelte/store';
@@ -22,7 +24,7 @@
}: {
existingUser?: User;
emailsVerifiedPerDefault?: boolean;
callback: (user: UserCreate) => Promise<boolean>;
callback: (user: UserCreate) => Promise<void>;
} = $props();
let isLoading = $state(false);
@@ -54,15 +56,27 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, user);
const formStore = createForm<FormSchema>(formSchema, user);
const { inputs } = formStore;
async function saveUser(data: z.infer<FormSchema>) {
await callback(data);
// Reset form if user was successfully created
if (!existingUser) formStore.reset();
}
// Create mode has its own Save button rather than going through the unsaved-changes bar.
async function onSubmit() {
const data = form.validate();
const data = formStore.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
// Reset form if user was successfully created
if (success && !existingUser) form.reset();
isLoading = false;
try {
await saveUser(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
function onNameInput() {
if (!hasManualDisplayNameEdit) {
@@ -71,6 +85,10 @@
}`.trim();
}
}
if (existingUser) {
trackFormChanges(() => formStore, saveUser);
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
@@ -130,8 +148,10 @@
bind:checked={$inputs.disabled.value}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{#if !existingUser}
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{/if}
</fieldset>
</form>