mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-30 04:29:05 +01:00
feat(ux): replace individual save buttons with single "unsaved changes bar" (#1757)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
+6
-1
@@ -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>
|
||||
|
||||
|
||||
+17
-29
@@ -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>
|
||||
|
||||
+28
-20
@@ -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>
|
||||
|
||||
+65
-80
@@ -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>
|
||||
|
||||
+21
-16
@@ -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>
|
||||
|
||||
+87
-111
@@ -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>
|
||||
|
||||
+81
-92
@@ -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>
|
||||
|
||||
+101
-70
@@ -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}
|
||||
|
||||
+50
-56
@@ -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>
|
||||
|
||||
+27
-40
@@ -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>
|
||||
|
||||
+45
-13
@@ -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 ?? []}
|
||||
|
||||
+5
-10
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user