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

This commit is contained in:
Elias Schneider
2026-09-19 15:23:06 +00:00
committed by GitHub
parent 818333fb38
commit 63050add4e
58 changed files with 1916 additions and 1184 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ export default [
}
},
{
files: ['**/*.svelte'],
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
parser: ts.parser
+7 -18
View File
@@ -110,7 +110,6 @@
"client": "Client",
"actor": "Actor",
"unknown": "Unknown",
"account_details_updated_successfully": "Account details updated successfully",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
"account_settings": "Account Settings",
"passkey_missing": "Passkey missing",
@@ -136,7 +135,6 @@
"device_passkeys_only_description": "Allow only passkeys built into the user's device.",
"external_security_keys_only": "External security keys only",
"external_security_keys_only_description": "Allow only roaming authenticators such as USB or NFC security keys.",
"passkey_configuration_updated_successfully": "Passkey configuration updated successfully",
"synced_passkeys_not_allowed": "Synced passkeys are not allowed by your administrator",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"manage_this_users_passkeys": "Manage this user's passkeys.",
@@ -181,13 +179,11 @@
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
"last_used": "Last Used",
"actions": "Actions",
"images_updated_successfully": "Images updated successfully. It may take a few minutes to update.",
"general": "General",
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Save changes?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
"save_and_send": "Save and send",
@@ -213,7 +209,6 @@
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
"session_duration": "Session Duration",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
@@ -266,9 +261,6 @@
"local": "Local",
"toggle_menu": "Toggle menu",
"edit": "Edit",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Back",
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
@@ -286,8 +278,6 @@
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
"user_group_deleted_successfully": "User group deleted successfully",
"user_count": "User Count",
"user_group_updated_successfully": "User group updated successfully",
"users_updated_successfully": "Users updated successfully",
"user_group_details_name": "User Group Details {name}",
"assign_users_to_this_group": "Assign users to this group.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
@@ -320,7 +310,6 @@
"certificate_url": "Certificate URL",
"enabled": "Enabled",
"disabled": "Disabled",
"oidc_client_updated_successfully": "OIDC client updated successfully",
"client_type": "Type",
"client_type_standard": "Standard",
"client_type_metadata_document": "Metadata Document",
@@ -344,7 +333,6 @@
"unrestrict": "Unrestrict",
"restrict": "Restrict",
"user_groups_restriction_updated_successfully": "User groups restriction updated successfully",
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
"dark_mode_logo": "Dark Mode Logo",
@@ -468,7 +456,6 @@
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
"user_creation_groups_description": "Assign these groups automatically to new users upon signup.",
"user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.",
"user_creation_updated_successfully": "User creation settings updated successfully.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"go_to_login": "Go to login",
@@ -547,7 +534,6 @@
"allowed_oidc_clients_description": "Select the OIDC clients that members of this user group are allowed to sign in to.",
"unrestrict_oidc_client": "Unrestrict {clientName}",
"confirm_unrestrict_oidc_client_description": "Are you sure you want to unrestrict the OIDC client {#b}{clientName}{/b}? This will remove all group assignments for this client and any user will be able to sign in.",
"allowed_oidc_clients_updated_successfully": "Allowed OIDC clients updated successfully",
"yes": "Yes",
"no": "No",
"restricted": "Restricted",
@@ -556,7 +542,6 @@
"scim_endpoint": "SCIM Endpoint",
"scim_token": "SCIM Token",
"last_successful_sync_at": "Last successful sync: {time}",
"scim_configuration_updated_successfully": "SCIM configuration updated successfully.",
"scim_enabled_successfully": "SCIM enabled successfully.",
"scim_disabled_successfully": "SCIM disabled successfully.",
"disable_scim_provisioning": "Disable SCIM Provisioning",
@@ -603,9 +588,7 @@
"add_permission": "Add permission",
"type": "Type",
"api_created_successfully": "API created successfully",
"api_updated_successfully": "API updated successfully",
"api_deleted_successfully": "API deleted successfully",
"api_permissions_updated_successfully": "Permissions updated successfully",
"are_you_sure_you_want_to_delete_this_api": "Are you sure you want to delete this API? Clients will lose access to its permissions.",
"api_access": "API access",
"api_access_description": "Select which APIs this client may request tokens for on behalf of users (user-delegated access) and for itself via the client credentials grant (client access), and which permissions it may ask for.",
@@ -670,5 +653,11 @@
"client_secrets_limit_reached": "An app cannot have more than 20 client secrets.",
"delete_client_secret": "Delete client secret",
"are_you_sure_you_want_to_delete_this_client_secret": "Are you sure you want to delete this client secret? Apps using it will no longer be able to authenticate.",
"client_secret_deleted_successfully": "Client secret deleted successfully"
"client_secret_deleted_successfully": "Client secret deleted successfully",
"you_have_unsaved_changes": "You have unsaved changes",
"changes_saved_successfully": "Changes saved successfully",
"discard": "Discard",
"discard_unsaved_changes": "Discard unsaved changes?",
"discard_unsaved_changes_confirm": "You have unsaved changes on this page. Are you sure you want to discard them?",
"please_fix_the_errors_before_saving": "Please fix the highlighted errors before saving"
}
+105
View File
@@ -20,12 +20,15 @@
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
@@ -222,3 +225,105 @@
.animate-delayed-fade {
animation: delayed-fade 0.5s ease-out forwards;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-10px);
}
50% {
transform: translateX(10px);
}
75% {
transform: translateX(-10px);
}
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
/* The bar shares its surface token with the cards it floats over, so it needs an elevation of its own */
.shadow-raised {
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.5),
0 1px 2px rgb(0 0 0 / 0.08),
0 12px 32px -10px rgb(0 0 0 / 0.25);
}
.dark .shadow-raised {
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.07),
0 1px 2px rgb(0 0 0 / 0.6),
0 16px 40px -12px rgb(0 0 0 / 0.9);
}
/* Marks a moment: the bar arriving, a save settling, a blocked navigation */
@keyframes bloom {
0% {
opacity: 0;
}
30% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.animate-bloom {
animation: bloom 1.5s ease-out forwards;
}
/* Dark mode has glow headroom: a luminous colour over a near-black pill reads as light spilling out of it */
/* Light mode has none, so the same gradient washes out to almost nothing there */
/* The light values keep the same origin below the pill, so the bloom rises the same way in both, but spread wider and carry more colour to register against white */
.bloom-pending {
background: radial-gradient(150% 260% at 50% 140%, rgb(217 119 6 / 0.26), transparent 72%);
}
/* Waits out the bar's 380ms motion so it blooms after the pill settles */
/* `both` keeps it hidden during that wait */
.bloom-success {
background: radial-gradient(150% 260% at 50% 140%, rgb(22 163 74 / 0.3), transparent 72%);
animation-delay: 380ms;
animation-fill-mode: both;
}
.bloom-error {
background: radial-gradient(
150% 260% at 50% 140%,
color-mix(in oklab, var(--destructive) 28%, transparent),
transparent 72%
);
}
.dark .bloom-pending {
background: radial-gradient(130% 150% at 50% 130%, rgb(251 191 36 / 0.24), transparent 70%);
}
.dark .bloom-success {
background: radial-gradient(130% 150% at 50% 130%, rgb(34 197 94 / 0.24), transparent 70%);
}
.dark .bloom-error {
background: radial-gradient(
130% 150% at 50% 130%,
color-mix(in oklab, var(--destructive) 26%, transparent),
transparent 70%
);
}
@media (prefers-reduced-motion: reduce) {
.animate-bloom {
animation: none;
}
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
@@ -28,7 +29,7 @@
function onDismiss() {
const url = new URL(page.url);
url.searchParams.delete('emailVerificationState');
history.replaceState(null, '', url.toString());
replaceState(url, page.state);
emailVerificationState = null;
}
@@ -40,10 +40,25 @@
let unit = $state<DurationUnit>(preferredUnit(input.value));
let amount = $state(formatAmount(input.value / minutesPerUnit[unit]));
// The minutes that `amount` and `unit` currently represent. Only a value that arrives from
// outside (e.g. after discarding changes) re-derives them, so that switching the unit isn't
// undone by the rounding of the displayed amount.
let displayedMinutes = input.value;
$effect(() => {
const minutes = input.value;
if (Object.is(minutes, displayedMinutes)) return;
displayedMinutes = minutes;
if (!Number.isFinite(minutes)) return;
unit = preferredUnit(minutes);
amount = formatAmount(minutes / minutesPerUnit[unit]);
});
function updateAmount(event: Event) {
amount = (event.currentTarget as HTMLInputElement).value;
input.value = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
displayedMinutes = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
input.value = displayedMinutes;
}
function updateUnit(value: string | undefined) {
@@ -85,7 +85,15 @@
{@render children()}
{:else if input}
{#if type === 'date'}
<DatePicker {id} bind:value={input.value as Date} />
<DatePicker
{id}
bind:value={
() => input.value as Date | undefined,
(value) => {
input.value = value;
}
}
/>
{:else}
<Input
aria-invalid={!!input.error}
@@ -93,7 +101,12 @@
{id}
{placeholder}
{type}
bind:value={input.value}
bind:value={
() => input.value as string | number | undefined,
(value) => {
input.value = value;
}
}
{disabled}
oninput={(e) => onInput?.(e)}
{readonly}
@@ -4,6 +4,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import unsavedChangesStoreSvelte from '$lib/stores/unsaved-changes-store.svelte';
import userStore from '$lib/stores/user-store';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { LucideLogOut, LucideUser } from '@lucide/svelte';
@@ -11,7 +12,12 @@
const webauthnService = new WebAuthnService();
async function logout() {
await webauthnService.logout();
if (!unsavedChangesStoreSvelte.hasChanges) {
await webauthnService.logout();
}
// Navigate regardless of unsaved changes status
// If there are unsaved changes, the save changes bar will shake and navigation will be canceled
goto('/login');
}
</script>
@@ -1,4 +1,6 @@
<script lang="ts" generics="T extends {id:string}">
import { replaceState } from '$app/navigation';
import { page as currentPage } from '$app/state';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import * as Pagination from '$lib/components/ui/pagination';
import * as Select from '$lib/components/ui/select';
@@ -151,7 +153,7 @@
function changePageState(page: number) {
const url = new URL(window.location.href);
url.searchParams.set(`${id}-page`, page.toString());
history.replaceState(history.state, '', url.toString());
replaceState(url, currentPage.state);
requestOptions.pagination!.page = page;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import { cn } from '$lib/utils/style.js';
import { Tabs as TabsPrimitive } from 'bits-ui';
@@ -21,8 +22,8 @@
});
function onTabChange(newValue: string) {
if (useHash && page.url.hash !== newValue) {
history.replaceState(history.state, '', location.pathname + location.search + `#${newValue}`);
if (useHash && page.url.hash.substring(1) !== newValue) {
replaceState(location.pathname + location.search + `#${newValue}`, page.state);
}
}
</script>
@@ -0,0 +1,247 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import unsavedChanges from '$lib/stores/unsaved-changes-store.svelte';
import { cn } from '$lib/utils/style';
import { LucideCircleAlert } from '@lucide/svelte';
import { tick } from 'svelte';
import { cubicOut } from 'svelte/easing';
const ROW_HEIGHT = 44;
const BOTTOM_OFFSET = 20;
const VIEWPORT_MARGIN = 16;
const INSET = 18;
const INSET_BUTTONS = 8;
const MOTION =
'duration-[380ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none';
const LAYER = 'absolute top-0 flex items-center whitespace-nowrap';
type BloomTint = 'pending' | 'success' | 'error';
let shake = $state(false);
let bloom = $state<{ tint: BloomTint }>();
let pillWidth = $state<number>();
let compact = $state(false);
let frame = $state<HTMLElement>();
let pendingMessage = $state<HTMLElement>();
let statusMessage = $state<HTMLElement>();
let actions = $state<HTMLElement>();
const animate = $derived(!$appConfigStore?.disableAnimations);
const status = $derived(unsavedChanges.status);
const showingStatus = $derived(!!status);
const showingActions = $derived(unsavedChanges.hasChanges);
const barOpen = $derived(showingActions || showingStatus);
// Settled means in view; the other message waits a row height away
const pendingSettled = $derived(!showingStatus && !compact);
const statusSettled = $derived(showingStatus && !compact);
// A new object every time, so {#key} remounts the layer and replays the animation
function flashBloom(tint: BloomTint) {
bloom = { tint };
}
$effect(() => {
if (barOpen) flashBloom('pending');
});
$effect(() => {
if (status) flashBloom(status.type === 'error' ? 'error' : 'success');
});
function measure() {
const message = showingStatus ? statusMessage : pendingMessage;
if (!message || !actions || !frame) return;
// offsetWidth ignores the scale the buttons carry while fading, and rounding the text up keeps its last glyph
const end = showingActions ? actions.offsetWidth + INSET_BUTTONS : INSET;
const roomy = INSET + Math.ceil(message.getBoundingClientRect().width) + end;
// An unmeasured frame reports no width, which must not read as "nothing fits"
const available = frame.clientWidth ? frame.clientWidth - 2 * VIEWPORT_MARGIN : Infinity;
compact = showingActions && roomy > available;
pillWidth = Math.min(compact ? actions.offsetWidth + 2 * INSET_BUTTONS : roomy, available);
}
$effect(measure);
// Catches what no state change announces: a longer message, a font loading, the viewport resizing
$effect(() => {
const layers = [pendingMessage, statusMessage, actions, frame].filter((layer) => !!layer);
if (layers.length === 0) return;
const observer = new ResizeObserver(measure);
for (const layer of layers) observer.observe(layer);
return () => observer.disconnect();
});
function rise(_node: HTMLElement, { duration = 220 } = {}) {
return {
duration: animate ? duration : 0,
easing: cubicOut,
css: (t: number, u: number) =>
`opacity: ${t}; transform: translateY(${u * 8}px) scale(${0.98 + t * 0.02})`
};
}
beforeNavigate((nav) => {
if (!unsavedChanges.hasChanges) return;
// Cancelling a full page unload makes the browser show its own "leave site?" prompt.
nav.cancel();
if (nav.type === 'leave') return;
shake = true;
flashBloom('pending');
});
// A fresh edit supersedes any status message left over from a previous save.
$effect(() => {
if (unsavedChanges.hasChanges) unsavedChanges.clearStatus();
});
function handleSaveShortcut(event: KeyboardEvent) {
if (event.key.toLowerCase() !== 's' || !(event.metaKey || event.ctrlKey)) return;
if (!unsavedChanges.hasChanges || unsavedChanges.saving) return;
event.preventDefault();
void saveAll();
}
async function saveAll() {
if ((await unsavedChanges.saveAll()) === 'invalid') await revealFirstInvalidField();
}
// Brings the first field that failed validation into view, switching to the tab that contains
// it if necessary, since the bar is global while the field may sit on a hidden tab.
async function revealFirstInvalidField() {
await tick();
const field = document.querySelector<HTMLElement>(
'[aria-invalid="true"], [data-slot="field-error"]'
);
if (!field) return;
for (
let panel = field.closest<HTMLElement>('[role="tabpanel"]');
panel;
panel = panel.parentElement?.closest<HTMLElement>('[role="tabpanel"]') ?? null
) {
const value = panel.dataset.value;
if (value === undefined) continue;
panel
.closest('[data-slot="tabs"]')
?.querySelector<HTMLElement>(`[role="tab"][data-value="${CSS.escape(value)}"]`)
?.click();
}
await tick();
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
field.focus({ preventScroll: true });
}
</script>
<svelte:window onkeydown={handleSaveShortcut} />
<!-- Belongs to the message, so it leaves with it and never counts towards the buttons' width -->
{#snippet divider()}
<span class="bg-border ml-2.5 mr-2 h-4.5 w-px shrink-0"></span>
{/snippet}
{#if barOpen}
<div
bind:this={frame}
class={cn('fixed inset-x-0 z-50 flex', compact ? 'justify-end' : 'justify-center')}
style:bottom="{BOTTOM_OFFSET}px"
style:padding-inline="{VIEWPORT_MARGIN}px"
transition:rise
class:animate-shake={shake}
onanimationend={() => (shake = false)}
>
<div
role="status"
aria-live="polite"
class={cn(
'bg-popover text-popover-foreground shadow-raised dark:bg-secondary relative isolate max-w-full overflow-hidden rounded-full border',
animate && `transition-[width] ${MOTION}`
)}
style:height="{ROW_HEIGHT}px"
style:width={pillWidth ? `${pillWidth}px` : undefined}
>
{#if animate && bloom}
{#key bloom}
<span
aria-hidden="true"
class="animate-bloom pointer-events-none absolute inset-0 -z-1 bloom-{bloom.tint}"
></span>
{/key}
{/if}
<span
bind:this={pendingMessage}
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
style:height="{ROW_HEIGHT}px"
style:left="{INSET}px"
style:translate={pendingSettled ? '0' : `0 -${ROW_HEIGHT}px`}
inert={!pendingSettled}
>
{#if unsavedChanges.dirtyCount > 1}
<span
class="bg-primary/15 text-foreground flex size-5 items-center justify-center rounded-full font-bold tabular-nums"
>
{unsavedChanges.dirtyCount}
</span>
{/if}
<span class="text-sm font-semibold tracking-tight">{m.you_have_unsaved_changes()}</span>
{@render divider()}
</span>
<span
bind:this={statusMessage}
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
style:height="{ROW_HEIGHT}px"
style:left="{INSET}px"
style:translate={statusSettled ? '0' : `0 ${ROW_HEIGHT}px`}
inert={!statusSettled}
>
{#if status?.type === 'error'}
<LucideCircleAlert class="text-destructive size-4 shrink-0" />
{/if}
<span class="text-sm font-semibold tracking-tight">{status?.message ?? ''}</span>
{#if showingActions}
{@render divider()}
{/if}
</span>
<span
bind:this={actions}
class={cn(
LAYER,
animate && `transition-[opacity,scale] ${MOTION}`,
!showingActions && 'scale-[0.96] opacity-0'
)}
style:height="{ROW_HEIGHT}px"
style:right="{INSET_BUTTONS}px"
inert={!showingActions}
>
<Button
variant="ghost"
size="sm"
class="hover:bg-foreground/10 dark:hover:bg-foreground/10"
disabled={unsavedChanges.saving}
onclick={() => unsavedChanges.discardAll()}
>
{m.discard()}
</Button>
<Button class="ml-1.5" size="sm" isLoading={unsavedChanges.saving} onclick={saveAll}>
{m.save()}
</Button>
</span>
</div>
</div>
{/if}
@@ -0,0 +1,105 @@
import { m } from '$lib/paraglide/messages';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { isAxiosError } from 'axios';
import { SvelteSet } from 'svelte/reactivity';
export interface UnsavedSection {
readonly dirty: boolean;
validate?: () => boolean;
save: () => Promise<void>;
discard: () => void;
}
export type SaveResult = 'saved' | 'invalid' | 'failed';
const STATUS_DISPLAY_MS = 3000;
const sections = new SvelteSet<UnsavedSection>();
let saving = $state(false);
let status = $state<{ type: 'success' | 'error'; message: string } | null>(null);
let statusTimeout: ReturnType<typeof setTimeout> | undefined;
const dirtyCount = $derived([...sections].filter((section) => section.dirty).length);
const hasChanges = $derived(dirtyCount > 0);
function register(section: UnsavedSection) {
sections.add(section);
}
function unregister(section: UnsavedSection) {
sections.delete(section);
}
function clearStatus() {
clearTimeout(statusTimeout);
status = null;
}
function showStatus(type: 'success' | 'error', message: string) {
clearTimeout(statusTimeout);
status = { type, message };
statusTimeout = setTimeout(() => (status = null), STATUS_DISPLAY_MS);
}
function errorMessage(e: unknown) {
if (isAxiosError(e)) return getAxiosErrorMessage(e);
return e instanceof Error ? e.message : m.an_unknown_error_occurred();
}
async function saveAll(): Promise<SaveResult> {
const dirtySections = [...sections].filter((section) => section.dirty);
if (dirtySections.length === 0) return 'saved';
// Every section is validated before anything is persisted, so that a validation error in one section can't leave the page half-saved.
const allValid = dirtySections.map((section) => section.validate?.() ?? true).every(Boolean);
if (!allValid) {
showStatus('error', m.please_fix_the_errors_before_saving());
return 'invalid';
}
saving = true;
let failure: { error: unknown } | undefined;
// Saved one after another rather than in parallel: several sections of a page can target the same endpoint.
for (const section of dirtySections) {
try {
await section.save();
} catch (error) {
failure ??= { error };
}
}
saving = false;
if (failure) {
showStatus('error', errorMessage(failure.error));
return 'failed';
}
showStatus('success', m.changes_saved_successfully());
return 'saved';
}
function discardAll() {
clearStatus();
for (const section of sections) {
if (section.dirty) section.discard();
}
}
export default {
get hasChanges() {
return hasChanges;
},
get dirtyCount() {
return dirtyCount;
},
get saving() {
return saving;
},
get status() {
return status;
},
register,
unregister,
saveAll,
discardAll,
clearStatus
};
+107 -34
View File
@@ -1,3 +1,4 @@
import { reactiveState } from '$lib/utils/reactive-state.svelte';
import { get, writable } from 'svelte/store';
import { z } from 'zod/v4';
@@ -12,22 +13,28 @@ type FormInputs<T> = {
};
export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValues: z.infer<T>) {
// Create a writable store for the inputs
const inputsStore = writable<FormInputs<z.infer<T>>>(initializeInputs(initialValues));
// The values the inputs are compared against and restored to. Kept as a private deep copy so
// that editing an object or array field can't change what it is supposed to be compared with.
let baseline: z.infer<T> = deepCopy(initialValues);
// The inputs are deeply reactive, so that field components can update them in place
// (`input.value = ...`) and anything derived from them, such as the unsaved-changes
// tracking, still re-evaluates.
const inputsStore = writable<FormInputs<z.infer<T>>>(reactiveState(initializeInputs()));
const errorsStore = writable<z.ZodError<any> | undefined>();
function initializeInputs(initialValues: z.infer<T>): FormInputs<z.infer<T>> {
function initializeInputs(): FormInputs<z.infer<T>> {
const inputs: FormInputs<z.infer<T>> = {} as FormInputs<z.infer<T>>;
const shape =
schema instanceof z.ZodObject ? (schema.shape as Record<string, z.ZodTypeAny>) : {};
for (const key in initialValues) {
if (Object.prototype.hasOwnProperty.call(initialValues, key)) {
for (const key in baseline) {
if (Object.prototype.hasOwnProperty.call(baseline, key)) {
const fieldSchema = shape[key];
inputs[key as keyof z.infer<T>] = {
value: initialValues[key as keyof z.infer<T>],
value: deepCopy(baseline[key as keyof z.infer<T>]),
error: null,
required: fieldSchema ? isRequired(fieldSchema) : false
};
@@ -37,40 +44,31 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
}
function validate() {
let success = true;
inputsStore.update((inputs) => {
const values = Object.fromEntries(
Object.entries(inputs).map(([key, input]) => [key, input.value])
);
const inputs = get(inputsStore);
const values = Object.fromEntries(
Object.entries(inputs).map(([key, input]) => [key, trimValue(input.value)])
);
const result = schema.safeParse(values);
errorsStore.set(result.error);
const result = schema.safeParse(values);
errorsStore.set(result.error);
if (!result.success) {
success = false;
for (const input of Object.keys(inputs)) {
const error = result.error.issues.find((e) => e.path[0] === input);
if (error) {
inputs[input as keyof z.infer<T>].error = error.message;
} else {
inputs[input as keyof z.infer<T>].error = null;
}
}
} else {
for (const input of Object.keys(inputs)) {
inputs[input as keyof z.infer<T>].error = null;
}
}
// Set the error messages for each input based on the validation result
for (const input of Object.keys(inputs)) {
inputs[input as keyof z.infer<T>].error = result.success
? null
: (result.error.issues.find((e) => e.path[0] === input)?.message ?? null);
}
// If the validation succeeded, update the baseline with the new valid values
if (result.success) {
for (const key in result.data) {
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
inputs[key as keyof z.infer<T>].value = result.data[key];
}
}
}
return inputs;
});
return success ? data() : null;
inputsStore.set(inputs);
return result.success ? result.data : null;
}
function data() {
@@ -86,13 +84,37 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
return values;
}
function isDirty() {
const inputs = get(inputsStore);
return Object.keys(inputs).some(
(key) => !deepEqual(inputs[key as keyof z.infer<T>].value, baseline[key as keyof z.infer<T>])
);
}
// Moves the baseline forward, e.g. after a successful save whose data isn't reflected back
// into a reactive prop that would otherwise rebuild the form.
function commit(values: z.infer<T>) {
baseline = { ...baseline, ...deepCopy(values) };
// Notify subscribers, so that anything comparing the inputs against the baseline
// (e.g. the unsaved-changes tracking) is re-evaluated.
inputsStore.update((inputs) => inputs);
}
function clearErrors() {
errorsStore.set(undefined);
inputsStore.update((inputs) => {
for (const input of Object.values(inputs) as FormInput<unknown>[]) input.error = null;
return inputs;
});
}
function reset() {
inputsStore.update((inputs) => {
for (const input of Object.keys(inputs)) {
const current = inputs[input as keyof z.infer<T>];
inputs[input as keyof z.infer<T>] = {
...current,
value: initialValues[input as keyof z.infer<T>],
value: deepCopy(baseline[input as keyof z.infer<T>]),
error: null
};
}
@@ -154,6 +176,57 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
data,
validate,
setValue,
reset
reset,
clearErrors,
isDirty,
commit
};
}
/**
* Picks the properties declared by `schema` out of a larger object, e.g. to build a form for one
* section of the application configuration.
*/
export function pickSchemaValues<T extends z.ZodObject>(schema: T, values: z.infer<T>): z.infer<T> {
return Object.fromEntries(
Object.keys(schema.shape).map((key) => [key, (values as Record<string, unknown>)[key]])
) as z.infer<T>;
}
/**
* Creates a deep copy of the given value, ensuring that nested objects and arrays are also copied.
*/
export function deepCopy<T>(value: T): T {
if (Array.isArray(value)) return value.map(deepCopy) as T;
if (value instanceof Date) return new Date(value) as T;
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, deepCopy(item)])
) as T;
}
return value;
}
/**
* Performs a deep comparison between two values to determine if they are equivalent.
*/
export function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (typeof a !== typeof b || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
return a.every((item, i) => deepEqual(item, b[i]));
}
if (typeof a === 'object' && typeof b === 'object') {
const aKeys = Object.keys(a as object);
const bKeys = Object.keys(b as object);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) =>
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
);
}
return false;
}
@@ -0,0 +1,9 @@
/**
* Wraps a value in deeply reactive state, so that reads of it inside `$derived` and `$effect`
* are tracked even when it is mutated in place afterwards. Lives in its own module because
* runes are only available in `.svelte.ts` files.
*/
export function reactiveState<T>(value: T): T {
const state = $state(value);
return state;
}
@@ -0,0 +1,112 @@
import { m } from '$lib/paraglide/messages';
import unsavedChanges, { type UnsavedSection } from '$lib/stores/unsaved-changes-store.svelte';
import { deepCopy, deepEqual } from '$lib/utils/form-util';
import { untrack } from 'svelte';
import { fromStore, type Readable } from 'svelte/store';
/**
* Registers a section of a page (e.g. a card) with the unsaved-changes bar.
* The section is unregistered when the calling component is destroyed.
*/
export function trackUnsavedSection(
dirty: () => boolean,
save: () => Promise<void>,
discard: () => void,
options: { validate?: () => boolean } = {}
) {
const isDirty = $derived.by(dirty);
const section: UnsavedSection = {
get dirty() {
return isDirty;
},
validate: options.validate,
save,
discard
};
$effect(() => {
unsavedChanges.register(section);
return () => unsavedChanges.unregister(section);
});
}
interface TrackedForm<T> {
inputs: Readable<unknown>;
isDirty: () => boolean;
validate: () => T | null;
commit: (data: T) => void;
reset: () => void;
clearErrors: () => void;
}
/**
* Convenience wrapper around `trackUnsavedSection` for forms built with `createForm`.
*/
export function trackFormChanges<T>(
getForm: () => TrackedForm<T>,
onSave: (data: T) => Promise<unknown>,
extra: { dirty?: () => boolean; discard?: () => void; enabled?: () => boolean } = {}
) {
const inputs = $derived(fromStore(getForm().inputs));
const isDirty = $derived.by(() => {
// The inputs are deeply reactive, so edits are tracked through the reads in `isDirty()`.
// Reading the store on top of that picks up `commit()`, which moves the baseline the
// inputs are compared against without touching the inputs themselves.
void inputs.current;
if (extra.enabled && !extra.enabled()) return false;
return getForm().isDirty() || (extra.dirty?.() ?? false);
});
// Validation errors are only meaningful while there is something to save: a field that is
// edited back to its saved value would otherwise keep showing the error of a failed save.
$effect(() => {
if (!isDirty) untrack(() => getForm().clearErrors());
});
trackUnsavedSection(
() => isDirty,
async () => {
const form = getForm();
const data = form.validate();
// The bar validates every section before saving, so this only happens if the inputs
// changed in between.
if (!data) throw new Error(m.please_fix_the_errors_before_saving());
await onSave(data);
form.commit(data);
},
() => {
getForm().reset();
extra.discard?.();
},
{ validate: () => getForm().validate() !== null }
);
}
/**
* Convenience wrapper around `trackUnsavedSection` for a value that is edited in place rather
* than through `createForm`, typically a list bound to a selection component.
*/
export function trackUnsavedValue<T>(
read: () => T,
write: (value: T) => void,
save: (value: T) => Promise<unknown>
) {
let saved = $state.raw(deepCopy(read()));
// Reads again rather than reusing the value that was saved, so that a caller that replaces
// the value with the server's response is taken into account.
const markSaved = () => {
saved = deepCopy(read());
};
trackUnsavedSection(
() => !deepEqual(read(), saved),
async () => {
await save(deepCopy(read()));
markSaved();
},
() => write(deepCopy(saved))
);
return { markSaved };
}
+3 -1
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import ConfirmDialog from '$lib/components/confirm-dialog/confirm-dialog.svelte';
import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import UnsavedChangesBar from '$lib/components/unsaved-changes-bar.svelte';
import { m } from '$lib/paraglide/messages';
import { startPageTrace } from '$lib/utils/tracing-util';
import { beforeNavigate } from '$app/navigation';
import { ModeWatcher } from 'mode-watcher';
import { type Snippet } from 'svelte';
import '../app.css';
@@ -44,4 +45,5 @@
}}
/>
<ConfirmDialog />
<UnsavedChangesBar />
<ModeWatcher disableTransitions={false} />
@@ -32,7 +32,7 @@
// If the previous page is a Pocket ID page, go back there instead of the generic alternative login page
afterNavigate((e) => {
if (e.from?.url.pathname) {
if (e.from?.url?.pathname) {
backHref = e.from.url.pathname + e.from.url.search;
}
});
@@ -11,7 +11,7 @@
import userStore from '$lib/stores/user-store';
import type { Passkey } from '$lib/types/passkey.type';
import type { AccountUpdate } from '$lib/types/user.type';
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { KeyRound, Languages, LucideAlertTriangle, UserCog } from '@lucide/svelte';
import { startRegistration } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner';
@@ -32,19 +32,7 @@
);
async function updateAccount(user: AccountUpdate) {
let success = true;
await userService
.updateCurrent(user)
.then((user) => {
toast.success(m.account_details_updated_successfully());
userStore.setUser(user);
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
userStore.setUser(await userService.updateCurrent(user));
}
async function createPasskey() {
@@ -1,15 +1,14 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field/index.js';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AccountUpdate } 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 { toast } from 'svelte-sonner';
import { get } from 'svelte/store';
@@ -24,12 +23,11 @@
}: {
account: AccountUpdate;
userId: string;
callback: (user: AccountUpdate) => Promise<boolean>;
callback: (user: AccountUpdate) => Promise<void>;
isLdapUser?: boolean;
userInfoInputDisabled?: boolean;
} = $props();
let isLoading = $state(false);
let hasManualDisplayNameEdit = $state(!!account.displayName);
const userService = new UserService();
@@ -43,10 +41,11 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, {
const formStore = createForm<FormSchema>(formSchema, {
...account,
email: account.email || ''
});
const { inputs } = formStore;
function onNameInput() {
if (!hasManualDisplayNameEdit) {
@@ -56,13 +55,7 @@
}
}
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data);
isLoading = false;
}
trackFormChanges(() => formStore, callback);
async function updateProfilePicture(image: File) {
await userService
@@ -79,7 +72,7 @@
}
</script>
<form onsubmit={preventDefault(onSubmit)} class="space-y-6">
<form class="space-y-6">
<ProfilePictureSettings
{userId}
{isLdapUser}
@@ -101,9 +94,5 @@
onInput={() => (hasManualDisplayNameEdit = true)}
/>
</Field.Group>
<div class="flex justify-end pt-4">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>
@@ -6,7 +6,6 @@
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { ApiCreate } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus, LucidePlus, LucideServer } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
@@ -18,18 +17,9 @@
const apisService = new ApisService();
async function createApi(api: ApiCreate) {
let success = true;
await apisService
.create(api)
.then((createdApi) => {
toast.success(m.api_created_successfully());
goto(`/settings/admin/apis/${createdApi.id}`);
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
const createdApi = await apisService.create(api);
toast.success(m.api_created_successfully());
goto(`/settings/admin/apis/${createdApi.id}`);
}
</script>
@@ -1,13 +1,11 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { ApiCimdAccessUpdate, ApiCreate, ApiPermissionInput } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../../users/navigate-back-util';
import ApiForm from '../api-form.svelte';
import ApiAccessCard from './api-access-card.svelte';
@@ -17,7 +15,9 @@
let api = $state(data.api);
let permissions = $state<ApiPermissionInput[]>(toPermissionInputs(data.api.permissions));
function toPermissionInputs(apiPermissions: typeof data.api.permissions): ApiPermissionInput[] {
function toPermissionInputs(
apiPermissions: { key: string; name: string; description?: string }[]
): ApiPermissionInput[] {
const inputs = apiPermissions.map((p) => ({
key: p.key,
name: p.name,
@@ -37,47 +37,32 @@
let accessCard = $state<ApiAccessCard>();
async function updateApi(updated: ApiCreate) {
let success = true;
await apisService
.update(api.id, { name: updated.name })
.then((res) => {
api = { ...api, ...res };
toast.success(m.api_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
api = { ...api, ...(await apisService.update(api.id, { name: updated.name })) };
}
async function updatePermissions() {
await apisService
.updatePermissions(
api.id,
permissions.filter((p) => !isEmptyPermission(p))
)
.then((res) => {
api = res;
permissions = toPermissionInputs(res.permissions);
toast.success(m.api_permissions_updated_successfully());
})
.catch(axiosErrorToast);
// A removed permission takes the client grants that referenced it with it
await accessCard?.refresh();
async function updatePermissions(updatedPermissions: ApiPermissionInput[]) {
try {
const res = await apisService.updatePermissions(api.id, updatedPermissions);
api = res;
permissions = toPermissionInputs(res.permissions);
} finally {
// A removed permission takes the client grants that referenced it with it
await accessCard?.refresh();
}
}
trackUnsavedValue(
() => permissions.filter((p) => !isEmptyPermission(p)),
(savedPermissions) => (permissions = toPermissionInputs(savedPermissions)),
updatePermissions
);
async function updateCimdAccess(update: ApiCimdAccessUpdate) {
await apisService
.updateCimdAccess(api.id, update)
.then((res) => {
api = res;
toast.success(m.api_access_updated_successfully());
})
.catch(axiosErrorToast);
await accessCard?.refresh();
try {
api = await apisService.updateCimdAccess(api.id, update);
} finally {
await accessCard?.refresh();
}
}
</script>
@@ -108,9 +93,6 @@
defaultExpanded={true}
>
<ApiPermissionsInput bind:permissions />
<div class="mt-5 flex justify-end">
<Button usePromiseLoading onclick={updatePermissions}>{m.save()}</Button>
</div>
</CollapsibleCard>
<ApiAccessCard bind:this={accessCard} {api} onCimdAccessSave={updateCimdAccess} />
@@ -10,7 +10,10 @@
let {
api,
onCimdAccessSave
}: { api: Api; onCimdAccessSave: (update: ApiCimdAccessUpdate) => Promise<void> } = $props();
}: {
api: Api;
onCimdAccessSave: (update: ApiCimdAccessUpdate) => Promise<void>;
} = $props();
let clientsTab = $state<ApiClientsTab>();
let tab = $state('clients');
@@ -1,31 +1,28 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import type { Api, ApiCimdAccessUpdate } from '$lib/types/api.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let { api, onSave }: { api: Api; onSave: (update: ApiCimdAccessUpdate) => Promise<void> } =
$props();
const formSchema = z.object({ enabled: z.boolean(), permissionIds: z.array(z.string()) });
const { inputs, ...form } = createForm(formSchema, {
const formStore = createForm(formSchema, {
enabled: api.allowCimdClients,
permissionIds: api.permissions.filter((p) => p.allowedForCimdClients).map((p) => p.id)
});
const { inputs } = formStore;
async function save() {
const data = form.validate();
if (data) await onSave(data);
}
trackFormChanges(() => formStore, onSave);
</script>
<form novalidate onsubmit={preventDefault(save)}>
<div>
<FormInput bind:input={$inputs.enabled} class="my-5">
<SwitchWithLabel
id="allow-cimd-clients"
@@ -48,7 +45,7 @@
class="mt-0.5"
checked={$inputs.permissionIds.value.includes(permission.id)}
onCheckedChange={(checked: boolean) =>
form.setValue(
formStore.setValue(
'permissionIds',
checked
? [...$inputs.permissionIds.value, permission.id]
@@ -71,8 +68,4 @@
{/if}
</div>
{/if}
<div class="mt-5 flex justify-end">
<Button type="submit" usePromiseLoading>{m.save()}</Button>
</div>
</form>
</div>
@@ -3,8 +3,10 @@
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { Api, ApiCreate } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let {
@@ -12,7 +14,7 @@
existingApi
}: {
existingApi?: Api;
callback: (api: ApiCreate) => Promise<boolean>;
callback: (api: ApiCreate) => Promise<void>;
} = $props();
let isLoading = $state(false);
@@ -35,17 +37,30 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, api);
const formStore = createForm<FormSchema>(formSchema, api);
const { inputs } = formStore;
async function saveApi(data: z.infer<FormSchema>) {
await callback(data);
if (!existingApi) formStore.reset();
}
// Create mode has its own Save button rather than going through the unsaved-changes bar.
async function onSubmit() {
const data = form.validate();
const data = formStore.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
if (success && !existingApi) {
form.reset();
try {
await saveApi(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
isLoading = false;
}
if (isEdit) {
trackFormChanges(() => formStore, saveApi);
}
</script>
@@ -59,7 +74,9 @@
readonly={isEdit}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{#if !isEdit}
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{/if}
</form>
@@ -6,9 +6,7 @@
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideInfo } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import AppConfigDynamicClientsForm from './forms/app-config-dynamic-clients-form.svelte';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
@@ -19,20 +17,17 @@
let { data } = $props();
let appConfig = $state(data.appConfig);
let persistedAppConfig = { ...data.appConfig };
const appConfigService = new AppConfigService();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
appConfig = await appConfigService
.update({
...appConfig,
...updatedAppConfig
})
.catch((e) => {
axiosErrorToast(e);
throw e;
});
await appConfigStore.reload();
persistedAppConfig = await appConfigService.update({
...persistedAppConfig,
...updatedAppConfig
});
appConfigStore.set({ ...$appConfigStore, ...persistedAppConfig });
}
async function updateImages(
@@ -84,9 +79,7 @@
defaultProfilePicturePromise,
backgroundImagePromise,
faviconPromise
])
.then(() => toast.success(m.images_updated_successfully()))
.catch(axiosErrorToast);
]);
}
</script>
@@ -27,9 +27,14 @@
let isCustomColor = $derived(!accentColors.some((c) => c.color === selectedColor));
let isPreviousColorCustom = $derived(!accentColors.some((c) => c.color === previousColor));
// Applies the selection as a live preview. Because it re-runs whenever the bound value
// changes, it also restores the saved colour when the selection is discarded.
$effect(() => {
applyAccentColor(selectedColor);
});
function handleAccentColorChange(accentValue: string) {
selectedColor = accentValue;
applyAccentColor(accentValue);
}
</script>
@@ -1,13 +1,11 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import UrlListInput from '$lib/components/form/url-list-input.svelte';
import { Button } from '$lib/components/ui/button';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { toast } from 'svelte-sonner';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
let {
appConfig,
@@ -17,35 +15,25 @@
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let cimdUrlAllowlist: string[] = $derived(
appConfig.cimdUrlAllowlist?.length ? appConfig.cimdUrlAllowlist : ['']
let cimdUrlAllowlist: string[] = $state(appConfig.cimdUrlAllowlist ?? []);
if (cimdUrlAllowlist.length === 0) cimdUrlAllowlist.push('');
trackUnsavedValue(
() => cimdUrlAllowlist.filter((u) => u.trim() !== ''),
(value) => {
cimdUrlAllowlist = value;
if (cimdUrlAllowlist.length === 0) cimdUrlAllowlist.push('');
},
(value) => callback({ cimdUrlAllowlist: value })
);
let isLoading = $state(false);
async function onSubmit() {
isLoading = true;
const update: Partial<AllAppConfig> = {
cimdUrlAllowlist: cimdUrlAllowlist.filter((u) => u.trim() !== '')
};
await callback(update).finally(() => (isLoading = false));
toast.success(m.application_configuration_updated_successfully());
}
</script>
{#snippet cimdUrlAllowlistDescription()}
<FormattedMessage message={m.cimd_url_allowlist_description} />
{/snippet}
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<FormInput label={m.cimd_url_allowlist()} description={cimdUrlAllowlistDescription}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" keepAtLeastOne />
</FormInput>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<FormInput label={m.cimd_url_allowlist()} description={cimdUrlAllowlistDescription}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" keepAtLeastOne />
</FormInput>
</fieldset>
@@ -9,8 +9,9 @@
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { createForm, pickSchemaValues } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
@@ -87,23 +88,32 @@
requireFieldsWhen(emailEnabled, m.smtp_field_required_when_email_enabled());
});
let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
const formStore = createForm(formSchema, pickSchemaValues(formSchema, appConfig));
const inputs = formStore.inputs;
async function onSubmit() {
const data = form.validate();
if (!data) return false;
async function saveEmailConfig(data: z.infer<typeof formSchema>) {
await callback(data);
// Update the app config to don't display the unsaved changes warning
Object.assign(appConfig, data);
toast.success(m.email_configuration_updated_successfully());
return true;
}
// Saving from the "send test email" prompt is outside the unsaved-changes bar, so it
// reports failures itself rather than letting them reach the bar.
async function saveForTestEmail() {
const data = formStore.validate();
if (!data) return false;
try {
await saveEmailConfig(data);
formStore.commit(data);
return true;
} catch (e) {
axiosErrorToast(e);
return false;
}
}
trackFormChanges(() => formStore, saveEmailConfig);
async function onTestEmail() {
const hasChanges = Object.entries($inputs).some(
([key, input]) => input.value !== appConfig[key as keyof AllAppConfig]
);
const hasChanges = formStore.isDirty();
if (hasChanges) {
openConfirmDialog({
@@ -113,8 +123,7 @@
confirm: {
label: m.save_and_send(),
action: async () => {
const saved = await onSubmit();
if (saved) {
if (await saveForTestEmail()) {
sendTestEmail();
}
}
@@ -135,7 +144,7 @@
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<div>
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<h4 class="mb-4 text-lg font-semibold">{m.general()}</h4>
<div class="flex flex-col gap-5">
@@ -221,6 +230,5 @@
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>{m.send_test_email()}</Button
>
<Button type="submit" disabled={$appConfigStore.uiConfigDisabled}>{m.save()}</Button>
</div>
</form>
</div>
@@ -1,15 +1,13 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
import AccentColorPicker from './accent-color-picker.svelte';
@@ -21,8 +19,6 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
const homePageUrlOptions = [
{ label: m.my_account(), value: '/settings/account' },
{ label: m.my_apps(), value: '/settings/apps' }
@@ -46,82 +42,71 @@
accentColor: z.string()
});
let { inputs, ...form } = $derived(createForm(formSchema, updatedAppConfig));
let formStore = $derived(createForm(formSchema, updatedAppConfig));
let inputs = $derived(formStore.inputs);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data).finally(() => (isLoading = false));
toast.success(m.application_configuration_updated_successfully());
}
trackFormChanges(() => formStore, callback);
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="flex flex-col gap-5">
<FormInput label={m.application_name()} bind:input={$inputs.appName} />
<FormInput
label={m.session_duration()}
type="number"
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<Field.Field>
<Field.Label>{m.app_config_home_page()}</Field.Label>
<Field.Description>
{m.app_config_home_page_description()}
</Field.Description>
<Select.Root
type="single"
value={$inputs.homePageUrl.value}
onValueChange={(v) => ($inputs.homePageUrl.value = v as string)}
>
<Select.Trigger class="w-full" aria-label={m.app_config_home_page()}>
{homePageUrlOptions.find((option) => option.value === $inputs.homePageUrl.value)
?.label ?? $inputs.homePageUrl.value}
</Select.Trigger>
<Select.Content>
{#each homePageUrlOptions as option (option.value)}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<SwitchWithLabel
id="self-account-editing"
label={m.enable_self_account_editing()}
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value}
/>
<SwitchWithLabel
id="disable-animations"
label={m.disable_animations()}
description={m.turn_off_ui_animations()}
bind:checked={$inputs.disableAnimations.value}
/>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="flex flex-col gap-5">
<FormInput label={m.application_name()} bind:input={$inputs.appName} />
<FormInput
label={m.session_duration()}
type="number"
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<Field.Field>
<Field.Label>{m.app_config_home_page()}</Field.Label>
<Field.Description>
{m.app_config_home_page_description()}
</Field.Description>
<Select.Root
type="single"
value={$inputs.homePageUrl.value}
onValueChange={(v) => ($inputs.homePageUrl.value = v as string)}
>
<Select.Trigger class="w-full" aria-label={m.app_config_home_page()}>
{homePageUrlOptions.find((option) => option.value === $inputs.homePageUrl.value)?.label ??
$inputs.homePageUrl.value}
</Select.Trigger>
<Select.Content>
{#each homePageUrlOptions as option (option.value)}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<SwitchWithLabel
id="self-account-editing"
label={m.enable_self_account_editing()}
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value}
/>
<SwitchWithLabel
id="disable-animations"
label={m.disable_animations()}
description={m.turn_off_ui_animations()}
bind:checked={$inputs.disableAnimations.value}
/>
<Field.Field class="space-y-5">
<div>
<Field.Label>
{m.accent_color()}
</Field.Label>
<Field.Description>
{m.select_an_accent_color_to_customize_the_appearance_of_pocket_id()}
</Field.Description>
</div>
<AccentColorPicker
previousColor={appConfig.accentColor}
bind:selectedColor={$inputs.accentColor.value}
disabled={$appConfigStore.uiConfigDisabled}
/>
</Field.Field>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>
<Field.Field class="space-y-5">
<div>
<Field.Label>
{m.accent_color()}
</Field.Label>
<Field.Description>
{m.select_an_accent_color_to_customize_the_appearance_of_pocket_id()}
</Field.Description>
</div>
<AccentColorPicker
previousColor={appConfig.accentColor}
bind:selectedColor={$inputs.accentColor.value}
disabled={$appConfigStore.uiConfigDisabled}
/>
</Field.Field>
</div>
</fieldset>
@@ -7,8 +7,8 @@
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { createForm, pickSchemaValues } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
@@ -47,19 +47,18 @@
ldapSoftDeleteUsers: z.boolean()
});
let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
const formStore = createForm(formSchema, pickSchemaValues(formSchema, appConfig));
const inputs = formStore.inputs;
async function onSubmit() {
const data = form.validate();
if (!data) return false;
await callback({
...data,
ldapEnabled: true
});
toast.success(m.ldap_configuration_updated_successfully());
return true;
async function saveLdap(data: z.infer<typeof formSchema>, enable: boolean) {
await callback({ ...data, ldapEnabled: enable });
}
trackFormChanges(
() => formStore,
(data) => saveLdap(data, ldapEnabled)
);
async function onDisable() {
ldapEnabled = false;
await callback({ ldapEnabled });
@@ -67,8 +66,15 @@
}
async function onEnable() {
if (await onSubmit()) {
const data = formStore.validate();
if (!data) return;
try {
await saveLdap(data, true);
formStore.commit(data);
ldapEnabled = true;
toast.success(m.ldap_configuration_updated_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
@@ -83,7 +89,7 @@
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<div>
<h4 class="text-lg font-semibold">{m.client_configuration()}</h4>
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
@@ -204,9 +210,8 @@
>{m.disable()}</Button
>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>{m.sync_now()}</Button>
<Button type="submit" disabled={$appConfigStore.uiConfigDisabled}>{m.save()}</Button>
{:else}
<Button onclick={onEnable} disabled={$appConfigStore.uiConfigDisabled}>{m.enable()}</Button>
{/if}
</div>
</form>
</div>
@@ -1,14 +1,12 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let {
@@ -19,8 +17,6 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
const formSchema = z.object({
webauthnUserVerification: z.enum(['required', 'preferred']),
webauthnAllowSyncedPasskeys: z.boolean(),
@@ -59,117 +55,97 @@
}
};
let { inputs, ...form } = $derived(createForm(formSchema, initialConfig));
let formStore = $derived(createForm(formSchema, initialConfig));
let inputs = $derived(formStore.inputs);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
try {
await callback(data);
toast.success(m.passkey_configuration_updated_successfully());
} finally {
isLoading = false;
}
}
trackFormChanges(() => formStore, callback);
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<Field.Group>
<Field.Field data-invalid={!!$inputs.webauthnUserVerification.error}>
<div>
<Field.Label for="passkey-user-verification">{m.user_verification()}</Field.Label>
<Field.Description>{m.user_verification_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnUserVerification.value}
onValueChange={(value) =>
($inputs.webauthnUserVerification.value = value as 'required' | 'preferred')}
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<Field.Group>
<Field.Field data-invalid={!!$inputs.webauthnUserVerification.error}>
<div>
<Field.Label for="passkey-user-verification">{m.user_verification()}</Field.Label>
<Field.Description>{m.user_verification_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnUserVerification.value}
onValueChange={(value) =>
($inputs.webauthnUserVerification.value = value as 'required' | 'preferred')}
>
<Select.Trigger
id="passkey-user-verification"
class="w-full"
aria-label={m.user_verification()}
aria-invalid={!!$inputs.webauthnUserVerification.error}
placeholder={m.user_verification()}
>
<Select.Trigger
id="passkey-user-verification"
class="w-full"
aria-label={m.user_verification()}
aria-invalid={!!$inputs.webauthnUserVerification.error}
placeholder={m.user_verification()}
>
{userVerificationOptions[$inputs.webauthnUserVerification.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(userVerificationOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
{userVerificationOptions[$inputs.webauthnUserVerification.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(userVerificationOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Content>
<div>
<Field.Label for="allow-synced-passkeys">{m.allow_synced_passkeys()}</Field.Label>
<Field.Description>{m.allow_synced_passkeys_description()}</Field.Description>
</div>
</Field.Content>
<Switch
id="allow-synced-passkeys"
class="my-auto"
bind:checked={$inputs.webauthnAllowSyncedPasskeys.value}
/>
</Field.Field>
<Field.Field data-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}>
<Field.Field orientation="horizontal">
<Field.Content>
<div>
<Field.Label for="passkey-authenticator-type"
>{m.allowed_authenticator_type()}</Field.Label
>
<Field.Description>{m.allowed_authenticator_type_description()}</Field.Description>
<Field.Label for="allow-synced-passkeys">{m.allow_synced_passkeys()}</Field.Label>
<Field.Description>{m.allow_synced_passkeys_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnAuthenticatorAttachment.value}
onValueChange={(value) =>
($inputs.webauthnAuthenticatorAttachment.value = value as
'any' | 'platform' | 'cross-platform')}
>
<Select.Trigger
id="passkey-authenticator-type"
class="w-full"
aria-label={m.allowed_authenticator_type()}
aria-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}
placeholder={m.allowed_authenticator_type()}
>
{authenticatorAttachmentOptions[$inputs.webauthnAuthenticatorAttachment.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(authenticatorAttachmentOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
</Field.Content>
<Switch
id="allow-synced-passkeys"
class="my-auto"
bind:checked={$inputs.webauthnAllowSyncedPasskeys.value}
/>
</Field.Field>
<Field.Field orientation="horizontal" class="justify-end">
<Button type="submit" disabled={$appConfigStore.uiConfigDisabled} {isLoading}>
{m.save()}
</Button>
</Field.Field>
</Field.Group>
</fieldset>
</form>
<Field.Field data-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}>
<div>
<Field.Label for="passkey-authenticator-type">{m.allowed_authenticator_type()}</Field.Label>
<Field.Description>{m.allowed_authenticator_type_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnAuthenticatorAttachment.value}
onValueChange={(value) =>
($inputs.webauthnAuthenticatorAttachment.value = value as
'any' | 'platform' | 'cross-platform')}
>
<Select.Trigger
id="passkey-authenticator-type"
class="w-full"
aria-label={m.allowed_authenticator_type()}
aria-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}
placeholder={m.allowed_authenticator_type()}
>
{authenticatorAttachmentOptions[$inputs.webauthnAuthenticatorAttachment.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(authenticatorAttachmentOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
</Field.Group>
</fieldset>
@@ -1,14 +1,12 @@
<script lang="ts">
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { toast } from 'svelte-sonner';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
let {
appConfig,
@@ -18,10 +16,9 @@
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs ?? []);
let customClaims = $state(appConfig.signupDefaultCustomClaims ?? []);
let allowUserSignups = $state(appConfig.allowUserSignups);
let isLoading = $state(false);
const signupOptions = {
disabled: {
@@ -38,97 +35,89 @@
}
};
async function onSubmit() {
isLoading = true;
await callback({
allowUserSignups: allowUserSignups,
trackUnsavedValue(
() => ({
allowUserSignups,
signupDefaultUserGroupIDs: selectedGroupIds,
signupDefaultCustomClaims: customClaims
});
toast.success(m.user_creation_updated_successfully());
isLoading = false;
}
$effect(() => {
customClaims = appConfig.signupDefaultCustomClaims || [];
allowUserSignups = appConfig.allowUserSignups;
});
}),
(value) => {
allowUserSignups = value.allowUserSignups;
selectedGroupIds = value.signupDefaultUserGroupIDs;
customClaims = value.signupDefaultCustomClaims;
},
callback
);
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="grid gap-2">
<Field.Field>
<div>
<Field.Label for="enable-user-signup">{m.enable_user_signups()}</Field.Label>
<Field.Description>
{m.enable_user_signups_description()}
</Field.Description>
</div>
<Select.Root
type="single"
value={allowUserSignups}
onValueChange={(v) => (allowUserSignups = v as typeof allowUserSignups)}
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="grid gap-2">
<Field.Field>
<div>
<Field.Label for="enable-user-signup">{m.enable_user_signups()}</Field.Label>
<Field.Description>
{m.enable_user_signups_description()}
</Field.Description>
</div>
<Select.Root
type="single"
value={allowUserSignups}
onValueChange={(v) => (allowUserSignups = v as typeof allowUserSignups)}
>
<Select.Trigger
id="enable-user-signup"
class="w-full"
aria-label={m.enable_user_signups()}
placeholder={m.enable_user_signups()}
>
<Select.Trigger
id="enable-user-signup"
class="w-full"
aria-label={m.enable_user_signups()}
placeholder={m.enable_user_signups()}
>
{signupOptions[allowUserSignups]?.label}
</Select.Trigger>
<Select.Content>
<Select.Item value="disabled">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.disabled.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.disabled.description}
</span>
</div>
</Select.Item>
<Select.Item value="withToken">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.withToken.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.withToken.description}
</span>
</div>
</Select.Item>
<Select.Item value="open">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.open.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.open.description}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
</div>
<Field.Field>
<div>
<Field.Label for="default-groups">{m.user_groups()}</Field.Label>
<Field.Description>
{m.user_creation_groups_description()}
</Field.Description>
</div>
<UserGroupInput bind:selectedGroupIds />
</Field.Field>
<Field.Field>
<div>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
</div>
<CustomClaimsInput bind:customClaims />
{signupOptions[allowUserSignups]?.label}
</Select.Trigger>
<Select.Content>
<Select.Item value="disabled">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.disabled.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.disabled.description}
</span>
</div>
</Select.Item>
<Select.Item value="withToken">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.withToken.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.withToken.description}
</span>
</div>
</Select.Item>
<Select.Item value="open">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.open.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.open.description}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
</div>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
<Field.Field>
<div>
<Field.Label for="default-groups">{m.user_groups()}</Field.Label>
<Field.Description>
{m.user_creation_groups_description()}
</Field.Description>
</div>
</fieldset>
</form>
<UserGroupInput bind:selectedGroupIds />
</Field.Field>
<Field.Field>
<div>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
</div>
<CustomClaimsInput bind:customClaims />
</Field.Field>
</fieldset>
@@ -1,6 +1,5 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import {
cachedApplicationLogo,
@@ -8,6 +7,7 @@
cachedDefaultProfilePicture,
cachedEmailLogo
} from '$lib/utils/cached-image-util';
import { trackUnsavedSection } from '$lib/utils/unsaved-changes-util.svelte';
import ApplicationImage from './application-image.svelte';
let {
@@ -20,7 +20,7 @@
defaultProfilePicture: File | null | undefined,
backgroundImage: File | null | undefined,
favicon: File | undefined
) => void;
) => Promise<void>;
} = $props();
let logoLight = $state<File | null | undefined>();
@@ -34,6 +34,44 @@
let backgroundImageSet = $state(true);
let logoLightSet = $state(true);
let logoDarkSet = $state(true);
// Bumped on discard to remount the image inputs, resetting their internal preview state.
let resetKey = $state(0);
// An image is "pending" while it holds a file to upload (or null to reset it to the default).
function clearPendingImages() {
logoLight =
logoDark =
logoEmail =
defaultProfilePicture =
backgroundImage =
favicon =
undefined;
}
trackUnsavedSection(
() =>
[logoLight, logoDark, logoEmail, defaultProfilePicture, backgroundImage, favicon].some(
(image) => image !== undefined
),
async () => {
await callback(
logoLight,
logoDark,
logoEmail,
defaultProfilePicture,
backgroundImage,
favicon
);
// The previews already show the uploaded images, so only the pending files are cleared.
clearPendingImages();
},
() => {
clearPendingImages();
logoLightSet = logoDarkSet = defaultProfilePictureSet = backgroundImageSet = true;
resetKey++;
}
);
</script>
{#snippet lightLogoFallback()}
@@ -44,71 +82,64 @@
<Logo defaultOnly colorScheme="dark" class="size-full" />
{/snippet}
<div class="flex flex-col gap-8">
<ApplicationImage
id="favicon"
imageClass="size-14 p-2"
label={m.favicon()}
bind:image={favicon}
imageURL="/api/application-images/favicon"
accept="image/svg+xml, image/png, image/x-icon"
/>
<ApplicationImage
id="logo-light"
imageClass="size-24"
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL={cachedApplicationLogo.getUrl(true)}
fallback={lightLogoFallback}
forceColorScheme="light"
isResetable
bind:isImageSet={logoLightSet}
/>
<ApplicationImage
id="logo-dark"
imageClass="size-24"
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL={cachedApplicationLogo.getUrl(false)}
fallback={darkLogoFallback}
forceColorScheme="dark"
isResetable
bind:isImageSet={logoDarkSet}
/>
<ApplicationImage
id="logo-email"
imageClass="size-24"
label={m.email_logo()}
bind:image={logoEmail}
imageURL={cachedEmailLogo.getUrl()}
accept="image/png, image/jpeg"
forceColorScheme="light"
/>
<ApplicationImage
id="default-profile-picture"
imageClass="size-24"
label={m.default_profile_picture()}
isResetable
bind:image={defaultProfilePicture}
imageURL={cachedDefaultProfilePicture.getUrl()}
isImageSet={defaultProfilePictureSet}
/>
<ApplicationImage
id="background-image"
imageClass="max-h-[350px] max-w-[500px]"
label={m.background_image()}
isResetable
bind:image={backgroundImage}
imageURL={cachedBackgroundImage.getUrl()}
isImageSet={backgroundImageSet}
/>
</div>
<div class="flex justify-end">
<Button
class="mt-5"
usePromiseLoading
onclick={() =>
callback(logoLight, logoDark, logoEmail, defaultProfilePicture, backgroundImage, favicon)}
>{m.save()}</Button
>
</div>
{#key resetKey}
<div class="flex flex-col gap-8">
<ApplicationImage
id="favicon"
imageClass="size-14 p-2"
label={m.favicon()}
bind:image={favicon}
imageURL="/api/application-images/favicon"
accept="image/svg+xml, image/png, image/x-icon"
/>
<ApplicationImage
id="logo-light"
imageClass="size-24"
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL={cachedApplicationLogo.getUrl(true)}
fallback={lightLogoFallback}
forceColorScheme="light"
isResetable
bind:isImageSet={logoLightSet}
/>
<ApplicationImage
id="logo-dark"
imageClass="size-24"
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL={cachedApplicationLogo.getUrl(false)}
fallback={darkLogoFallback}
forceColorScheme="dark"
isResetable
bind:isImageSet={logoDarkSet}
/>
<ApplicationImage
id="logo-email"
imageClass="size-24"
label={m.email_logo()}
bind:image={logoEmail}
imageURL={cachedEmailLogo.getUrl()}
accept="image/png, image/jpeg"
forceColorScheme="light"
/>
<ApplicationImage
id="default-profile-picture"
imageClass="size-24"
label={m.default_profile_picture()}
isResetable
bind:image={defaultProfilePicture}
imageURL={cachedDefaultProfilePicture.getUrl()}
isImageSet={defaultProfilePictureSet}
/>
<ApplicationImage
id="background-image"
imageClass="max-h-[350px] max-w-[500px]"
label={m.background_image()}
isResetable
bind:image={backgroundImage}
imageURL={cachedBackgroundImage.getUrl()}
isImageSet={backgroundImageSet}
/>
</div>
{/key}
@@ -8,7 +8,6 @@
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus, ShieldCheck, ShieldPlus } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
@@ -20,26 +19,20 @@
const oidcService = new OIDCService();
async function createOIDCClient(client: OidcClientCreateWithLogo) {
try {
clientSecretStore.clear();
const createdClient = await oidcService.createClient(client);
clientSecretStore.clear();
const createdClient = await oidcService.createClient(client);
const logoPromise = client.logo
? oidcService.updateClientLogo(createdClient, client.logo, true)
: Promise.resolve();
const darkLogoPromise = client.darkLogo
? oidcService.updateClientLogo(createdClient, client.darkLogo, false)
: Promise.resolve();
await Promise.all([logoPromise, darkLogoPromise]);
const logoPromise = client.logo
? oidcService.updateClientLogo(createdClient, client.logo, true)
: Promise.resolve();
const darkLogoPromise = client.darkLogo
? oidcService.updateClientLogo(createdClient, client.darkLogo, false)
: Promise.resolve();
await Promise.all([logoPromise, darkLogoPromise]);
// A new client starts without any secret: the admin creates the ones they need from the credentials tab
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(createdClient.id)}`);
toast.success(m.oidc_client_created_successfully());
return true;
} catch (e) {
axiosErrorToast(e);
return false;
}
// A new client starts without any secret: the admin creates the ones they need from the credentials tab
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(createdClient.id)}`);
toast.success(m.oidc_client_created_successfully());
}
</script>
@@ -1,5 +1,4 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
@@ -23,7 +22,9 @@
import type { ScimServiceProviderCreate } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { LucideChevronLeft, LucideInfo, LucideTriangleAlert } from '@lucide/svelte';
import { onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import { backNavigate } from '../../users/navigate-back-util';
@@ -40,7 +41,6 @@
...data.client,
allowedUserGroupIds: data.client.allowedUserGroups.map((g) => g.id)
});
// Secrets are managed by their own endpoints, so they are kept out of the client object that the forms below submit
let clientSecrets = $state<OidcClientSecret[]>(data.client.credentials?.secrets ?? []);
@@ -52,6 +52,14 @@
const scimService = new ScimService();
const backNavigation = backNavigate('/settings/admin/oidc-clients');
const allowedUserGroups = trackUnsavedValue(
() => client.allowedUserGroupIds,
(allowedUserGroupIds) => {
client.allowedUserGroupIds = allowedUserGroupIds;
},
(allowedUserGroupIds) => oidcService.updateAllowedUserGroups(client.id, allowedUserGroupIds)
);
const setupDetails = $state({
[m.issuer_url()]: data.oidcConfiguration.issuer,
[m.authorization_url()]: data.oidcConfiguration.authorization_endpoint,
@@ -64,7 +72,6 @@
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise =
updatedClient.logo !== undefined
@@ -82,54 +89,41 @@
? m.enabled()
: m.disabled();
await Promise.all([dataPromise, imagePromise, darkImagePromise])
.then(() => {
setupDetails[m.requires_pushed_authorization_requests()] =
updatedClient.requiresPushedAuthorizationRequests ? m.enabled() : m.disabled();
if (updatedClient.logoUrl) {
cachedOidcClientLogo.bustCache(client.id, true);
}
if (updatedClient.darkLogoUrl) {
cachedOidcClientLogo.bustCache(client.id, false);
}
const [savedClient] = await Promise.all([dataPromise, imagePromise, darkImagePromise]);
Object.assign(client, savedClient);
// Update the hasLogo and hasDarkLogo flags after successful upload
if (updatedClient.logo !== undefined || updatedClient.logoUrl !== undefined) {
client.hasLogo = updatedClient.logo !== null || !!updatedClient.logoUrl;
}
if (updatedClient.darkLogo !== undefined || updatedClient.darkLogoUrl !== undefined) {
client.hasDarkLogo = updatedClient.darkLogo !== null || !!updatedClient.darkLogoUrl;
}
if (updatedClient.pkceEnabled) {
client.pkceEnabled = updatedClient.pkceEnabled;
}
toast.success(m.oidc_client_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
setupDetails[m.requires_pushed_authorization_requests()] =
updatedClient.requiresPushedAuthorizationRequests ? m.enabled() : m.disabled();
if (updatedClient.logoUrl) {
cachedOidcClientLogo.bustCache(client.id, true);
}
if (updatedClient.darkLogoUrl) {
cachedOidcClientLogo.bustCache(client.id, false);
}
return success;
// Update the hasLogo and hasDarkLogo flags after successful upload
if (updatedClient.logo !== undefined || updatedClient.logoUrl !== undefined) {
client.hasLogo = updatedClient.logo !== null || !!updatedClient.logoUrl;
}
if (updatedClient.darkLogo !== undefined || updatedClient.darkLogoUrl !== undefined) {
client.hasDarkLogo = updatedClient.darkLogo !== null || !!updatedClient.darkLogoUrl;
}
if (updatedClient.pkceEnabled) {
client.pkceEnabled = updatedClient.pkceEnabled;
}
}
async function updateTokenLifetimes(lifetimes: OidcClientTokenLifetimes) {
const success = await updateClient({ ...client, ...lifetimes });
if (success) {
client.accessTokenDurationMinutes = lifetimes.accessTokenDurationMinutes;
client.refreshTokenDurationMinutes = lifetimes.refreshTokenDurationMinutes;
}
return success;
await updateClient({ ...client, ...lifetimes });
client.accessTokenDurationMinutes = lifetimes.accessTokenDurationMinutes;
client.refreshTokenDurationMinutes = lifetimes.refreshTokenDurationMinutes;
}
async function updateFederatedCredentials(federatedIdentities: OidcClientFederatedIdentity[]) {
// Secrets are read-only in this request, but they are carried over so the client object keeps matching what the server has
const credentials: OidcClientCredentials = { federatedIdentities, secrets: clientSecrets };
const success = await updateClient({ ...client, credentials });
if (success) {
client.credentials = credentials;
}
return success;
await updateClient({ ...client, credentials });
client.credentials = credentials;
}
async function enableGroupRestriction() {
@@ -165,6 +159,7 @@
.then(() => {
toast.success(m.user_groups_restriction_updated_successfully());
client.allowedUserGroupIds = [];
allowedUserGroups.markSaved();
client.isGroupRestricted = false;
})
.catch(axiosErrorToast);
@@ -173,44 +168,18 @@
});
}
async function updateUserGroupClients(allowedGroups: string[]) {
await oidcService
.updateAllowedUserGroups(client.id, allowedGroups)
.then(() => {
toast.success(m.allowed_user_groups_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
});
}
async function saveScimServiceProvider(provider: ScimServiceProviderCreate | null) {
try {
if (!provider) {
await scimService.deleteServiceProvider(scimServiceProvider!.id);
scimServiceProvider = undefined;
toast.success(m.scim_disabled_successfully());
return true;
}
let createdProvider;
if (scimServiceProvider) {
createdProvider = await scimService.updateServiceProvider(scimServiceProvider.id, provider);
toast.success(m.scim_configuration_updated_successfully());
} else {
createdProvider = await scimService.createServiceProvider(provider);
toast.success(m.scim_enabled_successfully());
}
scimServiceProvider = createdProvider;
return true;
} catch (e) {
axiosErrorToast(e);
return false;
if (!provider) {
await scimService.deleteServiceProvider(scimServiceProvider!.id);
scimServiceProvider = undefined;
return;
}
scimServiceProvider = scimServiceProvider
? await scimService.updateServiceProvider(scimServiceProvider.id, provider)
: await scimService.createServiceProvider(provider);
}
beforeNavigate(() => {
clientSecretStore.clear();
});
onDestroy(() => clientSecretStore.clear());
</script>
<svelte:head>
@@ -343,11 +312,6 @@
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<div class="mt-5 flex justify-end gap-3">
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
<Button
usePromiseLoading
onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button
>
</div>
</Card.Content>
{/if}
@@ -3,8 +3,8 @@
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientFederatedIdentity } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { slide } from 'svelte/transition';
import { z } from 'zod/v4';
import FederatedIdentitiesInput from '../federated-identities-input.svelte';
@@ -14,10 +14,9 @@
callback
}: {
client: OidcClient;
callback: (federatedIdentities: OidcClientFederatedIdentity[]) => Promise<boolean>;
callback: (federatedIdentities: OidcClientFederatedIdentity[]) => Promise<void>;
} = $props();
let isLoading = $state(false);
const isCIMDClient = $derived(client.clientType === 'cimd');
const formSchema = z.object({
@@ -34,12 +33,16 @@
)
})
});
const { inputs, errors, ...form } = createForm(formSchema, {
const formStore = createForm(formSchema, {
credentials: {
federatedIdentities:
client.credentials?.federatedIdentities?.map((identity) => ({ ...identity })) ?? []
client.credentials?.federatedIdentities?.map((identity) => ({
...identity,
publicKeys: identity.publicKeys?.length ? identity.publicKeys : undefined
})) ?? []
}
});
const { inputs, errors } = formStore;
const hasFederatedIdentities = $derived($inputs.credentials.value.federatedIdentities.length > 0);
@@ -61,63 +64,54 @@
subject: '',
audience: '',
jwks: '',
publicKeys: [],
publicKeys: undefined,
replayProtection: true
}
];
}
async function onSubmit() {
if (isCIMDClient) return;
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data.credentials.federatedIdentities).finally(() => (isLoading = false));
// Metadata document clients manage their own credentials, so there is nothing to save here.
if (!isCIMDClient) {
trackFormChanges(
() => formStore,
(data) => callback(data.credentials.federatedIdentities)
);
}
</script>
<form novalidate onsubmit={preventDefault(onSubmit)}>
<Card.Root data-testid="federated-credentials-card">
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div>
<Card.Title>{m.federated_client_credentials()}</Card.Title>
<Card.Description>
{m.federated_client_credentials_description()}
<a
class="underline underline-offset-4"
href="https://pocket-id.org/docs/guides/oidc-client-authentication"
target="_blank"
rel="noreferrer"
>
{m.docs()}
</a>
</Card.Description>
</div>
{#if !hasFederatedIdentities}
<Button disabled={isCIMDClient} onclick={addFederatedIdentity}>
{m.create()}
</Button>
{/if}
<Card.Root data-testid="federated-credentials-card">
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div>
<Card.Title>{m.federated_client_credentials()}</Card.Title>
<Card.Description>
{m.federated_client_credentials_description()}
<a
class="underline underline-offset-4"
href="https://pocket-id.org/docs/guides/oidc-client-authentication"
target="_blank"
rel="noreferrer"
>
{m.docs()}
</a>
</Card.Description>
</div>
</Card.Header>
{#if hasFederatedIdentities}
<div transition:slide>
<Card.Content>
<FederatedIdentitiesInput
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
disabled={isCIMDClient}
/>
</Card.Content>
</div>
{/if}
{#if !isCIMDClient && hasFederatedIdentities}
<Card.Footer class="justify-end">
<Button type="submit" disabled={isLoading}>{m.save()}</Button>
</Card.Footer>
{/if}
</Card.Root>
</form>
{#if !hasFederatedIdentities}
<Button disabled={isCIMDClient} onclick={addFederatedIdentity}>
{m.create()}
</Button>
{/if}
</div>
</Card.Header>
{#if hasFederatedIdentities}
<div transition:slide>
<Card.Content>
<FederatedIdentitiesInput
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
disabled={isCIMDClient}
/>
</Card.Content>
</div>
{/if}
</Card.Root>
@@ -1,11 +1,10 @@
<script lang="ts">
import DurationInput from '$lib/components/form/duration-input.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientTokenLifetimes } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let {
@@ -13,11 +12,9 @@
callback
}: {
client: OidcClient;
callback: (lifetimes: OidcClientTokenLifetimes) => Promise<boolean>;
callback: (lifetimes: OidcClientTokenLifetimes) => Promise<void>;
} = $props();
let isLoading = $state(false);
const durationSchema = z
.number()
.min(1, { message: m.token_lifetime_minimum() })
@@ -29,44 +26,34 @@
accessTokenDurationMinutes: durationSchema,
refreshTokenDurationMinutes: durationSchema
});
const { inputs, ...form } = createForm(formSchema, {
const formStore = createForm(formSchema, {
accessTokenDurationMinutes: client.accessTokenDurationMinutes,
refreshTokenDurationMinutes: client.refreshTokenDurationMinutes
});
const { inputs } = formStore;
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data).finally(() => (isLoading = false));
}
trackFormChanges(() => formStore, callback);
</script>
<form novalidate onsubmit={preventDefault(onSubmit)}>
<Card.Root data-testid="token-lifetimes-card">
<Card.Header>
<Card.Title>{m.token_lifetimes()}</Card.Title>
<Card.Description>{m.token_lifetimes_description()}</Card.Description>
</Card.Header>
<Card.Content>
<div class="md:grid md:grid-cols-2 gap-10 space-y-5 md:space-y-0">
<DurationInput
id="access-token-lifetime"
label={m.access_token_lifetime()}
description={m.access_token_lifetime_description()}
bind:input={$inputs.accessTokenDurationMinutes}
/>
<DurationInput
id="refresh-token-lifetime"
label={m.refresh_token_inactivity_timeout()}
description={m.refresh_token_inactivity_timeout_description()}
bind:input={$inputs.refreshTokenDurationMinutes}
/>
</div>
</Card.Content>
<Card.Footer class="justify-end">
<Button type="submit" disabled={isLoading}>{m.save()}</Button>
</Card.Footer>
</Card.Root>
</form>
<Card.Root data-testid="token-lifetimes-card">
<Card.Header>
<Card.Title>{m.token_lifetimes()}</Card.Title>
<Card.Description>{m.token_lifetimes_description()}</Card.Description>
</Card.Header>
<Card.Content>
<div class="md:grid md:grid-cols-2 gap-10 space-y-5 md:space-y-0">
<DurationInput
id="access-token-lifetime"
label={m.access_token_lifetime()}
description={m.access_token_lifetime_description()}
bind:input={$inputs.accessTokenDurationMinutes}
/>
<DurationInput
id="refresh-token-lifetime"
label={m.refresh_token_inactivity_timeout()}
description={m.refresh_token_inactivity_timeout_description()}
bind:input={$inputs.refreshTokenDurationMinutes}
/>
</div>
</Card.Content>
</Card.Root>
@@ -5,8 +5,10 @@
import { m } from '$lib/paraglide/messages';
import ScimService from '$lib/services/scim-service';
import type { ScimServiceProvider, ScimServiceProviderCreate } from '$lib/types/scim.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { emptyToUndefined } from '$lib/utils/zod-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
@@ -17,7 +19,7 @@
oidcClientId
}: {
existingProvider?: ScimServiceProvider;
onSave: (provider: ScimServiceProviderCreate | null) => Promise<boolean>;
onSave: (provider: ScimServiceProviderCreate | null) => Promise<void>;
oidcClientId: string;
} = $props();
@@ -36,17 +38,39 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, serviceProvider);
const formStore = createForm<FormSchema>(formSchema, serviceProvider);
const { inputs } = formStore;
async function onSubmit() {
const data = form.validate();
if (!data) return false;
return await onSave({
async function saveProvider(data: z.infer<FormSchema>) {
await onSave({
...data,
oidcClientId
});
}
// Enable/disable/sync have their own buttons rather than going through the unsaved-changes
// bar, so they report their outcome themselves.
async function trySaveProvider() {
const data = formStore.validate();
if (!data) return false;
try {
await saveProvider(data);
formStore.commit(data);
return true;
} catch (e) {
axiosErrorToast(e);
return false;
}
}
async function onEnable() {
if (await trySaveProvider()) toast.success(m.scim_enabled_successfully());
}
// Tracked by the unsaved-changes bar once the provider exists; until then the Enable button
// saves it.
trackFormChanges(() => formStore, saveProvider, { enabled: () => !!existingProvider });
async function onDisable() {
openConfirmDialog({
title: m.disable_scim_provisioning(),
@@ -58,9 +82,17 @@
label: m.disable(),
destructive: true,
action: async () => {
await onSave(null);
form.setValue('endpoint', '');
form.setValue('token', '');
try {
await onSave(null);
toast.success(m.scim_disabled_successfully());
} catch (e) {
axiosErrorToast(e);
return;
}
formStore.setValue('endpoint', '');
formStore.setValue('token', '');
// The cleared fields are the saved state now, so they aren't unsaved changes.
formStore.commit({ endpoint: '', token: undefined });
}
}
});
@@ -78,8 +110,7 @@
confirm: {
label: m.save_and_sync(),
action: async () => {
const saved = await onSubmit();
if (saved) {
if (await trySaveProvider()) {
syncProvider();
}
}
@@ -106,7 +137,7 @@
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<form onsubmit={preventDefault(onEnable)}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput
@@ -137,8 +168,9 @@
{#if existingProvider}
<Button variant="destructive" onclick={onDisable}>{m.disable()}</Button>
<Button variant="secondary" isLoading={isSyncing} onclick={onSync}>{m.sync_now()}</Button>
{:else}
<Button type="submit">{m.enable()}</Button>
{/if}
<Button type="submit">{existingProvider ? m.save() : m.enable()}</Button>
</div>
</div>
</form>
@@ -30,11 +30,9 @@
children?: Snippet;
} = $props();
// The source can't be derived from the identity alone: it stays on "Public keys" while no key has been added yet
let keySources = $state<KeySource[]>([]);
function keySourceFor(index: number, identity: OidcClientFederatedIdentity): KeySource {
return keySources[index] ?? (identity.publicKeys?.length ? 'publicKeys' : 'jwks');
// An empty publicKeys array records that Public keys is selected before the first key is added
function keySourceFor(identity: OidcClientFederatedIdentity): KeySource {
return identity.publicKeys === undefined ? 'jwks' : 'publicKeys';
}
function addFederatedIdentity() {
@@ -45,7 +43,7 @@
subject: '',
audience: '',
jwks: '',
publicKeys: [],
publicKeys: undefined,
replayProtection: true
}
];
@@ -53,7 +51,6 @@
function removeFederatedIdentity(index: number) {
federatedIdentities = federatedIdentities.filter((_, i) => i !== index);
keySources = keySources.filter((_, i) => i !== index);
}
function updateFederatedIdentity<K extends keyof OidcClientFederatedIdentity>(
@@ -69,15 +66,12 @@
// Only one of the two sources is ever submitted, so the one that is not selected is cleared
function updateKeySource(index: number, source: KeySource) {
// The list is rebuilt in full so it stays aligned with the identities when one of them is removed
keySources = federatedIdentities.map((identity, i) =>
i === index ? source : keySourceFor(i, identity)
);
if (source === 'jwks') {
updateFederatedIdentity(index, 'publicKeys', []);
} else {
updateFederatedIdentity(index, 'jwks', '');
}
const identity = federatedIdentities[index];
federatedIdentities[index] = {
...identity,
jwks: source === 'jwks' ? identity.jwks : '',
publicKeys: source === 'publicKeys' ? (identity.publicKeys ?? []) : undefined
};
}
function getFieldError(index: number, field: keyof OidcClientFederatedIdentity): string | null {
@@ -157,7 +151,7 @@
<Field.Label>{m.signing_keys()}</Field.Label>
<RadioGroup.Root
class="flex flex-wrap gap-x-6 gap-y-3"
value={keySourceFor(i, identity)}
value={keySourceFor(identity)}
onValueChange={(value) => updateKeySource(i, value as KeySource)}
{disabled}
>
@@ -173,7 +167,7 @@
</div>
</RadioGroup.Root>
{#if keySourceFor(i, identity) === 'publicKeys'}
{#if keySourceFor(identity) === 'publicKeys'}
<FederatedIdentityKeysInput
id="public-keys-{i}"
publicKeys={identity.publicKeys ?? []}
@@ -18,9 +18,6 @@
disabled?: boolean;
} = $props();
// The list is owned by this component: the identity it belongs to lives in a store, whose
// updates don't propagate back down to this input
let keys = $state<Jwk[]>(publicKeys);
let pastedKey = $state('');
let error = $state<string | null>(null);
@@ -33,29 +30,27 @@
// A JWKS may repeat a key that was already added, and duplicate key IDs would make the key
// to verify an assertion with ambiguous
const existingKeyIds = new Set(keys.map(getJwkKeyId));
const existingKeyIds = new Set(publicKeys.map(getJwkKeyId));
const duplicate = result.keys.find((key) => existingKeyIds.has(getJwkKeyId(key)));
if (duplicate) {
error = m.public_key_already_added({ keyId: getJwkKeyId(duplicate) });
return;
}
keys = [...keys, ...result.keys];
onChange(keys);
onChange([...publicKeys, ...result.keys]);
pastedKey = '';
error = null;
}
function removeKey(index: number) {
keys = keys.filter((_, i) => i !== index);
onChange(keys);
onChange(publicKeys.filter((_, i) => i !== index));
}
</script>
<div class="flex flex-col gap-3">
{#if keys.length > 0}
{#if publicKeys.length > 0}
<ul class="flex flex-col gap-2" data-testid="federated-identity-public-keys">
{#each keys as key, i (getJwkKeyId(key))}
{#each publicKeys as key, i (getJwkKeyId(key))}
<li
class="bg-muted/40 flex items-center justify-between gap-3 rounded-2xl px-4 py-2"
data-testid="federated-identity-public-key"
@@ -11,8 +11,10 @@
OidcClientUpdateWithLogo
} from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { cn } from '$lib/utils/style';
import { callbackUrlSchema, emptyToUndefined, optionalUrl } from '$lib/utils/zod-util';
import { LucideChevronDown, LucideMoon, LucideSun } from '@lucide/svelte';
@@ -27,21 +29,28 @@
mode
}: {
existingClient?: OidcClient;
callback: (client: OidcClientCreateWithLogo | OidcClientUpdateWithLogo) => Promise<boolean>;
callback: (client: OidcClientCreateWithLogo | OidcClientUpdateWithLogo) => Promise<void>;
mode: 'create' | 'update';
} = $props();
let isLoading = $state(false);
let showAdvancedOptions = $state(false);
let logo = $state<File | null | undefined>();
let darkLogo = $state<File | null | undefined>();
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? cachedOidcClientLogo.getUrl(existingClient!.id) : null
);
let darkLogoDataURL: string | null = $state(
existingClient?.hasDarkLogo ? cachedOidcClientLogo.getUrl(existingClient!.id, false) : null
);
// What discarding restores the previews to; moves forward whenever a logo is saved.
let savedLogoDataURL = existingClient?.hasLogo
? cachedOidcClientLogo.getUrl(existingClient!.id)
: null;
let savedDarkLogoDataURL = existingClient?.hasDarkLogo
? cachedOidcClientLogo.getUrl(existingClient!.id, false)
: null;
let logoDataURL: string | null = $state(savedLogoDataURL);
let darkLogoDataURL: string | null = $state(savedDarkLogoDataURL);
const isCIMDClient = $derived(existingClient?.clientType === 'cimd');
// Defaults for new clients; existing clients keep the lifetimes edited in their own card.
const DEFAULT_ACCESS_TOKEN_DURATION_MINUTES = 60;
const DEFAULT_REFRESH_TOKEN_DURATION_MINUTES = 30 * 24 * 60;
const client = {
id: '',
name: existingClient?.name || '',
@@ -57,9 +66,7 @@
launchURL: existingClient?.launchURL || '',
logoUrl: '',
darkLogoUrl: '',
pkceSupported: existingClient?.pkceSupported || false,
accessTokenDurationMinutes: existingClient?.accessTokenDurationMinutes ?? 60,
refreshTokenDurationMinutes: existingClient?.refreshTokenDurationMinutes ?? 30 * 24 * 60
pkceSupported: existingClient?.pkceSupported || false
};
const formSchema = z.object({
@@ -84,58 +91,83 @@
skipConsent: z.boolean(),
launchURL: optionalUrl,
logoUrl: optionalUrl,
darkLogoUrl: optionalUrl,
accessTokenDurationMinutes: z
.number()
.min(1)
.max(365 * 24 * 60)
.int(),
refreshTokenDurationMinutes: z
.number()
.min(1)
.max(365 * 24 * 60)
.int()
darkLogoUrl: optionalUrl
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, client);
const formStore = createForm<FormSchema>(formSchema, client);
const { inputs } = formStore;
const pkcePromptNeeded = $derived(!$inputs.pkceEnabled.value && client.pkceSupported);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback({
async function saveClient(data: z.infer<FormSchema>) {
await callback({
...data,
credentials: existingClient?.credentials ?? { federatedIdentities: [], secrets: [] },
logo: $inputs.logoUrl?.value ? undefined : logo,
logoUrl: $inputs.logoUrl?.value,
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
darkLogoUrl: $inputs.darkLogoUrl?.value,
isGroupRestricted: existingClient?.isGroupRestricted ?? true
isGroupRestricted: existingClient?.isGroupRestricted ?? true,
// The token lifetimes are edited in their own card. The current values are sent along
// because the backend falls back to the defaults for missing ones.
accessTokenDurationMinutes:
existingClient?.accessTokenDurationMinutes ?? DEFAULT_ACCESS_TOKEN_DURATION_MINUTES,
refreshTokenDurationMinutes:
existingClient?.refreshTokenDurationMinutes ?? DEFAULT_REFRESH_TOKEN_DURATION_MINUTES
});
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
const hasDarkLogo = darkLogo != null || !!$inputs.darkLogoUrl?.value;
if (success && existingClient) {
if (existingClient) {
if (hasLogo) {
logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
}
if (hasDarkLogo) {
darkLogoDataURL = cachedOidcClientLogo.getUrl(existingClient.id, false);
}
savedLogoDataURL = logoDataURL;
savedDarkLogoDataURL = darkLogoDataURL;
// The uploaded file has been persisted, so it's no longer "pending" for dirty-tracking.
logo = undefined;
darkLogo = undefined;
} else {
formStore.reset();
}
}
if (success && !existingClient) form.reset();
isLoading = false;
// Create mode has its own Save button rather than going through the unsaved-changes bar.
async function onSubmit() {
const data = formStore.validate();
if (!data) return;
isLoading = true;
try {
await saveClient(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
function discardLogoChanges() {
logo = undefined;
darkLogo = undefined;
logoDataURL = savedLogoDataURL;
darkLogoDataURL = savedDarkLogoDataURL;
}
if (mode === 'update') {
trackFormChanges(() => formStore, saveClient, {
dirty: () => logo !== undefined || darkLogo !== undefined,
discard: discardLogoChanges
});
}
function onLogoChange(input: File | string | null, light: boolean = true) {
if (input == null) return;
const logoUrlInput = light ? $inputs.logoUrl : $inputs.darkLogoUrl;
const logoUrlKey = light ? 'logoUrl' : 'darkLogoUrl';
if (typeof input === 'string') {
if (light) {
@@ -145,7 +177,7 @@
darkLogo = null;
darkLogoDataURL = input || null;
}
logoUrlInput!.value = input;
formStore.setValue(logoUrlKey, input);
} else {
if (light) {
logo = input;
@@ -154,9 +186,7 @@
darkLogo = input;
darkLogoDataURL = URL.createObjectURL(input);
}
if (logoUrlInput) {
logoUrlInput.value = '';
}
formStore.setValue(logoUrlKey, '');
}
}
@@ -342,6 +372,8 @@
)}
/>
</Button>
<Button {isLoading} type="submit" class="absolute right-0">{m.save()}</Button>
{#if mode === 'create'}
<Button {isLoading} type="submit" class="absolute right-0">{m.save()}</Button>
{/if}
</div>
</form>
@@ -5,7 +5,6 @@
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus, UserCog, UserPlus } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
@@ -17,18 +16,9 @@
const userGroupService = new UserGroupService();
async function createUserGroup(userGroup: UserGroupCreate) {
let success = true;
await userGroupService
.create(userGroup)
.then((createdUserGroup) => {
toast.success(m.user_group_created_successfully());
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
const createdUserGroup = await userGroupService.create(userGroup);
toast.success(m.user_group_created_successfully());
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
}
</script>
@@ -1,7 +1,6 @@
<script lang="ts">
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
@@ -9,9 +8,8 @@
import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../../users/navigate-back-util';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
@@ -23,7 +21,6 @@
userIds: data.userGroup.users.map((u) => u.id),
allowedOidcClientIds: data.userGroup.allowedOidcClients.map((c) => c.id)
});
let oidcClientSelectionRef: OidcClientSelection;
const userGroupService = new UserGroupService();
@@ -31,47 +28,35 @@
const backNavigation = backNavigate('/settings/admin/user-groups');
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
let success = true;
await userGroupService
.update(userGroup.id, updatedUserGroup)
.then(() => toast.success(m.user_group_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
await userGroupService.update(userGroup.id, updatedUserGroup);
}
async function updateUserGroupUsers(userIds: string[]) {
await userGroupService
.updateUsers(userGroup.id, userIds)
.then(() => toast.success(m.users_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
trackUnsavedValue(
() => userGroup.userIds,
(userIds) => {
userGroup.userIds = userIds;
},
(userIds) => userGroupService.updateUsers(userGroup.id, userIds)
);
async function updateCustomClaims() {
await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
trackUnsavedValue(
() => userGroup.allowedOidcClientIds,
(clientIds) => {
userGroup.allowedOidcClientIds = clientIds;
},
async (clientIds) => {
await userGroupService.updateAllowedOidcClients(userGroup.id, clientIds);
oidcClientSelectionRef.refresh();
}
);
async function updateAllowedOidcClients(allowedClients: string[]) {
await userGroupService
.updateAllowedOidcClients(userGroup.id, allowedClients)
.then(() => {
toast.success(m.allowed_oidc_clients_updated_successfully());
oidcClientSelectionRef.refresh();
})
.catch((e) => {
axiosErrorToast(e);
});
}
trackUnsavedValue(
() => userGroup.customClaims,
(customClaims) => {
userGroup.customClaims = customClaims;
},
(customClaims) => customClaimService.updateUserGroupCustomClaims(userGroup.id, customClaims)
);
</script>
<svelte:head>
@@ -120,12 +105,6 @@
bind:selectedUserIds={userGroup.userIds}
selectionDisabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
onclick={() => updateUserGroupUsers(userGroup.userIds)}>{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -141,11 +120,6 @@
bind:this={oidcClientSelectionRef}
bind:selectedGroupIds={userGroup.allowedOidcClientIds}
/>
<div class="mt-5 flex justify-end gap-3">
<Button onclick={() => updateAllowedOidcClients(userGroup.allowedOidcClientIds)}
>{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -160,9 +134,6 @@
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -18,6 +18,15 @@
const oidcClientService = new OidcService();
let tableRef: AdvancedTable<OidcClientWithAllowedGroups>;
let unrestrictedClientIds = $state<string[]>([]);
function getTableSelectedIds() {
return [...new Set([...selectedGroupIds, ...unrestrictedClientIds])];
}
function setTableSelectedIds(clientIds: string[]) {
selectedGroupIds = clientIds.filter((id) => !unrestrictedClientIds.includes(id));
}
export function refresh() {
return tableRef?.refresh();
@@ -56,8 +65,7 @@
async function fetchCallback(requestOptions: ListRequestOptions) {
const clients = await oidcClientService.listClients(requestOptions);
const unrestrictedClientIds = clients.data.filter((c) => !c.isGroupRestricted).map((c) => c.id);
selectedGroupIds = [...new Set([...selectedGroupIds, ...unrestrictedClientIds])];
unrestrictedClientIds = clients.data.filter((c) => !c.isGroupRestricted).map((c) => c.id);
return clients;
}
@@ -82,7 +90,7 @@
id="oidc-client-selection"
{fetchCallback}
defaultSort={{ column: 'name', direction: 'asc' }}
bind:selectedIds={selectedGroupIds}
bind:selectedIds={getTableSelectedIds, setTableSelectedIds}
rowSelectionDisabled={(item) => !item.isGroupRestricted}
{columns}
/>
@@ -4,8 +4,10 @@
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { z } from 'zod/v4';
let {
@@ -13,7 +15,7 @@
existingUserGroup
}: {
existingUserGroup?: UserGroupCreate;
callback: (userGroup: UserGroupCreate) => Promise<boolean>;
callback: (userGroup: UserGroupCreate) => Promise<void>;
} = $props();
let isLoading = $state(false);
@@ -31,7 +33,8 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, userGroup);
const formStore = createForm<FormSchema>(formSchema, userGroup);
const { inputs } = formStore;
function onFriendlyNameInput(e: any) {
if (!hasManualNameEdit) {
@@ -43,17 +46,31 @@
hasManualNameEdit = true;
}
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
async function saveUserGroup(data: z.infer<FormSchema>) {
await callback(data);
// Reset form if user group was successfully created
if (success && !existingUserGroup) {
form.reset();
if (!existingUserGroup) {
formStore.reset();
hasManualNameEdit = false;
}
isLoading = false;
}
// Create mode has its own Save button rather than going through the unsaved-changes bar.
async function onSubmit() {
const data = formStore.validate();
if (!data) return;
isLoading = true;
try {
await saveUserGroup(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
if (existingUserGroup) {
trackFormChanges(() => formStore, saveUserGroup);
}
</script>
@@ -77,8 +94,10 @@
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{#if !existingUserGroup}
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{/if}
</fieldset>
</form>
@@ -9,7 +9,6 @@
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { ChevronDown, LucideMinus, UserPen, UserPlus } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
@@ -28,17 +27,12 @@
const userService = new UserService();
async function createUser(user: UserCreate) {
let success = true;
await userService
.create(user)
.then(() => toast.success(m.user_created_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
await userListRef.refresh();
return success;
try {
await userService.create(user);
toast.success(m.user_created_successfully());
} finally {
await userListRef.refresh();
}
}
</script>
@@ -2,7 +2,6 @@
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Item from '$lib/components/ui/item/index.js';
import * as Tabs from '$lib/components/ui/tabs';
@@ -14,6 +13,7 @@
import type { Passkey } from '$lib/types/passkey.type';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { KeyRound, LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../navigate-back-util';
@@ -31,36 +31,25 @@
const customClaimService = new CustomClaimService();
const backNavigation = backNavigate('/settings/admin/users');
async function updateUserGroups(userIds: string[]) {
await userService
.updateUserGroups(user.id, userIds)
.then(() => toast.success(m.user_groups_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateUser(updatedUser: UserCreate) {
let success = true;
await userService
.update(user.id, updatedUser)
.then(() => toast.success(m.user_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
await userService.update(user.id, updatedUser);
}
async function updateCustomClaims() {
await customClaimService
.updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
trackUnsavedValue(
() => user.userGroupIds,
(userGroupIds) => {
user.userGroupIds = userGroupIds;
},
(userGroupIds) => userService.updateUserGroups(user.id, userGroupIds)
);
trackUnsavedValue(
() => user.customClaims,
(customClaims) => {
user.customClaims = customClaims;
},
(customClaims) => customClaimService.updateUserCustomClaims(user.id, customClaims)
);
async function updateProfilePicture(image: File) {
await userService
@@ -137,13 +126,6 @@
bind:selectedGroupIds={user.userGroupIds}
selectionDisabled={!!user.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
onclick={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -179,9 +161,6 @@
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
@@ -8,8 +8,10 @@
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { LucideMailCheck, LucideMailWarning } from '@lucide/svelte';
import { get } from 'svelte/store';
@@ -22,7 +24,7 @@
}: {
existingUser?: User;
emailsVerifiedPerDefault?: boolean;
callback: (user: UserCreate) => Promise<boolean>;
callback: (user: UserCreate) => Promise<void>;
} = $props();
let isLoading = $state(false);
@@ -54,15 +56,27 @@
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, user);
const formStore = createForm<FormSchema>(formSchema, user);
const { inputs } = formStore;
async function saveUser(data: z.infer<FormSchema>) {
await callback(data);
// Reset form if user was successfully created
if (!existingUser) formStore.reset();
}
// Create mode has its own Save button rather than going through the unsaved-changes bar.
async function onSubmit() {
const data = form.validate();
const data = formStore.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
// Reset form if user was successfully created
if (success && !existingUser) form.reset();
isLoading = false;
try {
await saveUser(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
function onNameInput() {
if (!hasManualDisplayNameEdit) {
@@ -71,6 +85,10 @@
}`.trim();
}
}
if (existingUser) {
trackFormChanges(() => formStore, saveUser);
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
@@ -130,8 +148,10 @@
bind:checked={$inputs.disabled.value}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{#if !existingUser}
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
{/if}
</fieldset>
</form>
+38 -10
View File
@@ -3,6 +3,7 @@ import { emailVerificationTokens, users } from '../data';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -15,11 +16,38 @@ test('Update account details', async ({ page }) => {
await page.getByLabel('Display Name').fill('Timothy Apple');
await page.getByLabel('Email').fill('timothy.apple@test.com');
await page.getByLabel('Username').fill('timothy');
await page.getByRole('button', { name: 'Save' }).click();
await saveUnsavedChanges(page);
});
await expect(page.locator('[data-type="success"]')).toHaveText(
'Account details updated successfully'
);
test('Failed account update remains dirty and can be retried', async ({ page }) => {
await page.goto('/settings/account');
let failedUpdates = 0;
await page.route('**/api/users/me', async (route) => {
if (route.request().method() !== 'PUT' || failedUpdates > 0) {
await route.fallback();
return;
}
failedUpdates++;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Temporary account update failure' })
});
});
const displayName = page.getByLabel('Display Name');
await displayName.fill('Retryable Account');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Temporary account update failure', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeVisible();
expect(failedUpdates).toBe(1);
await saveUnsavedChanges(page);
await page.reload();
await expect(page.getByLabel('Display Name')).toHaveValue('Retryable Account');
});
test('Update account details fails with already taken email', async ({ page }) => {
@@ -27,9 +55,9 @@ test('Update account details fails with already taken email', async ({ page }) =
await page.getByLabel('Email').fill(users.craig.email);
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Email is already in use');
await expect(page.getByText('Email is already in use', { exact: true })).toBeVisible();
});
test('Update account details fails with already taken username', async ({ page }) => {
@@ -37,9 +65,9 @@ test('Update account details fails with already taken username', async ({ page }
await page.getByLabel('Username').fill(users.craig.username);
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
});
test('Update account details fails with already taken username in different casing', async ({
@@ -49,9 +77,9 @@ test('Update account details fails with already taken username in different casi
await page.getByLabel('Username').fill(users.craig.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
});
test('Change Locale', async ({ page }) => {
+4 -11
View File
@@ -3,6 +3,7 @@ import * as jose from 'jose';
import { apis, oidcClients } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import * as oidcUtil from '../utils/oidc.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -69,9 +70,7 @@ test('Edit the name of an API', async ({ page }) => {
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
await page.getByLabel('Name', { exact: true }).fill('Orders API renamed');
await page.getByRole('button', { name: 'Save' }).nth(0).click();
await expect(page.locator('[data-type="success"]')).toHaveText('API updated successfully');
await saveUnsavedChanges(page);
await page.reload();
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Orders API renamed');
@@ -84,11 +83,7 @@ test('Add a permission to an API', async ({ page }) => {
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByPlaceholder('Permission', { exact: true }).last().fill('ship:orders');
await page.getByPlaceholder('Name', { exact: true }).last().fill('Ship orders');
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Permissions updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
// The two seeded permissions plus the newly added one
@@ -259,9 +254,7 @@ test('Allow all metadata document clients for an API', async ({ page }) => {
await page.getByRole('tab', { name: 'Metadata document clients' }).click();
await page.getByLabel('Allow all metadata document clients').click();
await page.getByLabel(apis.orders.permissions.readOrders.name, { exact: true }).click();
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
await saveUnsavedChanges(page);
await page.reload();
await page.getByRole('tab', { name: 'Metadata document clients' }).click();
+145 -43
View File
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { cleanupBackend } from '../utils/cleanup.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async ({ page }) => {
await cleanupBackend();
@@ -13,11 +14,7 @@ test('Update general configuration', async ({ page }) => {
await page.getByRole('button', { name: 'Home Page' }).click();
await page.getByRole('option', { name: 'My Apps' }).click();
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Application configuration updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -28,6 +25,95 @@ test('Update general configuration', async ({ page }) => {
await page.waitForURL('/settings/apps');
});
test('Save configuration from every editable tab together', async ({ page }) => {
await page.getByLabel('Application Name', { exact: true }).fill('Combined Settings');
await page.getByRole('tab', { name: 'User Creation' }).click();
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await page.getByRole('option', { name: 'Open Signup' }).click();
await page.getByRole('tab', { name: 'Passkeys' }).click();
await page.getByRole('button', { name: 'User verification' }).click();
await page.getByRole('option', { name: 'Preferred' }).click();
await page.getByRole('tab', { name: 'Email' }).click();
await page.getByLabel('SMTP Host').fill('smtp.combined.test');
await page.getByLabel('SMTP Port').fill('587');
await page.getByLabel('SMTP From').fill('combined@example.com');
await page.getByRole('tab', { name: 'OIDC' }).click();
await page.getByRole('textbox').fill('https://combined.example.com/*');
await saveUnsavedChanges(page);
await page.getByRole('tab', { name: 'General' }).click();
await expect(page.getByLabel('Application Name', { exact: true })).toHaveValue(
'Combined Settings'
);
await page.getByRole('tab', { name: 'Email' }).click();
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.combined.test');
await page.getByRole('tab', { name: 'Passkeys' }).click();
await expect(page.getByRole('button', { name: 'User verification' })).toContainText('Preferred');
await page.getByRole('tab', { name: 'OIDC' }).click();
await expect(page.getByRole('textbox')).toHaveValue('https://combined.example.com/*');
await page.reload();
await page.getByRole('tab', { name: 'General' }).click();
await expect(page.getByLabel('Application Name', { exact: true })).toHaveValue(
'Combined Settings'
);
await page.getByRole('tab', { name: 'User Creation' }).click();
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await expect(page.getByRole('option', { name: 'Open Signup' })).toHaveAttribute(
'aria-selected',
'true'
);
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: 'Email' }).click();
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.combined.test');
await page.getByRole('tab', { name: 'Passkeys' }).click();
await expect(page.getByRole('button', { name: 'User verification' })).toContainText('Preferred');
await page.getByRole('tab', { name: 'OIDC' }).click();
await expect(page.getByRole('textbox')).toHaveValue('https://combined.example.com/*');
});
test('Invalid hidden configuration prevents every section from being saved', async ({ page }) => {
let updateRequests = 0;
page.on('request', (request) => {
if (
request.method() === 'PUT' &&
new URL(request.url()).pathname === '/api/application-configuration'
) {
updateRequests++;
}
});
await page.getByLabel('Application Name', { exact: true }).fill('Validated Together');
await page.getByRole('tab', { name: 'Email' }).click();
await page.getByLabel('SMTP Host').fill('smtp.validation.test');
await page.getByLabel('SMTP From').fill('validation@example.com');
await page.getByRole('tab', { name: 'General' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
const emailTab = page.getByRole('tab', { name: 'Email' });
const smtpPort = page.getByLabel('SMTP Port');
await expect(page.getByText('Please fix the highlighted errors before saving')).toBeVisible();
await expect(emailTab).toHaveAttribute('aria-selected', 'true');
await expect(smtpPort).toHaveAttribute('aria-invalid', 'true');
await expect(smtpPort).toBeFocused();
expect(updateRequests).toBe(0);
await smtpPort.fill('587');
await saveUnsavedChanges(page);
await page.reload();
await page.getByRole('tab', { name: 'General' }).click();
await expect(page.getByLabel('Application Name', { exact: true })).toHaveValue(
'Validated Together'
);
await page.getByRole('tab', { name: 'Email' }).click();
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.validation.test');
await expect(page.getByLabel('SMTP Port')).toHaveValue('587');
await expect(page.getByLabel('SMTP From')).toHaveValue('validation@example.com');
});
test.describe('Update user creation configuration', () => {
test.beforeEach(async ({ page }) => {
await page.getByRole('tab', { name: 'User Creation' }).click();
@@ -37,11 +123,7 @@ test.describe('Update user creation configuration', () => {
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await page.getByRole('option', { name: 'Open Signup' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -59,11 +141,7 @@ test.describe('Update user creation configuration', () => {
await expect(designersOption).toBeChecked();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -81,11 +159,7 @@ test.describe('Update user creation configuration', () => {
await page.getByPlaceholder('Key').nth(1).fill('another-claim');
await page.getByPlaceholder('Value').nth(1).fill('another-value');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -114,10 +188,7 @@ test('Update passkey configuration', async ({ page }) => {
await expect(allowSyncedPasskeys).toBeChecked();
await allowSyncedPasskeys.click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Passkey configuration updated successfully'
);
await saveUnsavedChanges(page);
const registrationResponse = await page.request.get('/api/webauthn/register/start');
expect(registrationResponse.ok()).toBeTruthy();
@@ -157,11 +228,7 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('Email Login Code from Admin').click();
await page.getByLabel('API Key Expiration').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Email configuration updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -176,7 +243,52 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
});
test('Save LDAP configuration while LDAP remains disabled', async ({ page }) => {
await page.getByRole('tab', { name: 'LDAP' }).click();
const disableButton = page.getByRole('button', { name: 'Disable', exact: true });
if (await disableButton.isVisible()) {
await disableButton.click();
await expect(page.getByRole('button', { name: 'Enable', exact: true })).toBeVisible();
}
const softDeleteUsers = page.getByRole('switch', { name: 'Keep disabled users from LDAP' });
const originalValue = await softDeleteUsers.isChecked();
await softDeleteUsers.click();
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(softDeleteUsers).toBeChecked({ checked: originalValue });
await softDeleteUsers.click();
await saveUnsavedChanges(page);
await page.reload();
await page.getByRole('tab', { name: 'LDAP' }).click();
await expect(page.getByRole('button', { name: 'Enable', exact: true })).toBeVisible();
await expect(page.getByRole('switch', { name: 'Keep disabled users from LDAP' })).toBeChecked({
checked: !originalValue
});
});
test.describe('Update application images', () => {
test('should detect image resets as unsaved changes', async ({ page }) => {
await page
.getByLabel('Background Image', { exact: true })
.setInputFiles('resources/images/clouds.jpg');
await saveUnsavedChanges(page);
await page
.getByRole('button', { name: 'Reset to default Background Image', exact: true })
.click();
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(
page.getByRole('button', { name: 'Reset to default Background Image', exact: true })
).toBeVisible();
});
test('should upload images and reset custom logos', async ({ page }) => {
await page
.getByLabel('Favicon', { exact: true })
@@ -196,11 +308,7 @@ test.describe('Update application images', () => {
await page
.getByLabel('Background Image', { exact: true })
.setInputFiles('resources/images/clouds.jpg');
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Images updated successfully. It may take a few minutes to update.'
);
await saveUnsavedChanges(page);
await page.request
.get('/api/application-images/favicon')
@@ -234,15 +342,11 @@ test.describe('Update application images', () => {
);
})
);
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();
await saveUnsavedChanges(page);
for (const response of await Promise.all(logoDeleteResponses)) {
expect(response.status()).toBe(204);
}
await expect(page.locator('[data-type="success"]')).toHaveText(
'Images updated successfully. It may take a few minutes to update.'
);
// Without a custom logo the endpoint falls back to the logo bundled with Pocket ID
await page.request
.get('/api/application-images/logo?light=true')
@@ -264,10 +368,8 @@ test.describe('Update application images', () => {
const emailLogoInput = page.getByLabel('Email Logo', { exact: true });
await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg');
await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText(
'File must be of type PNG or JPEG'
);
await expect(page.getByText('File must be of type PNG or JPEG', { exact: true })).toBeVisible();
});
});
+21
View File
@@ -20,3 +20,24 @@ test('keyboard focus stays on sidebar link after navigating', async ({ page }) =
await page.waitForURL('**/settings/audit-log');
await expect(auditLog).toBeFocused();
});
test('unsaved changes block navigation until they are discarded', async ({ page }) => {
await page.goto('/settings/account');
const displayName = page.getByLabel('Display Name');
const originalDisplayName = await displayName.inputValue();
await displayName.fill('Pending navigation');
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
const myApps = page.getByRole('link', { name: 'My Apps' });
await myApps.click();
await expect(page).toHaveURL(/\/settings\/account$/);
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(displayName).toHaveValue(originalDisplayName);
await expect(page.getByText('You have unsaved changes', { exact: true })).toHaveCount(0);
await myApps.click();
await expect(page).toHaveURL(/\/settings\/apps$/);
await expect(page.getByText('You have unsaved changes', { exact: true })).toHaveCount(0);
});
+45 -27
View File
@@ -3,6 +3,7 @@ import * as jose from 'jose';
import { oidcClients, userGroups } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import * as oidcUtil from '../utils/oidc.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -77,12 +78,7 @@ test('Edit OIDC client', async ({ page }) => {
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
const clientForm = page.getByLabel('Name').locator('xpath=ancestor::form');
await clientForm.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'OIDC client updated successfully'
);
await saveUnsavedChanges(page);
await expect(page.getByRole('img', { name: 'Nextcloud updated logo' }).first()).toBeVisible();
await page.request
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
@@ -134,8 +130,7 @@ test('Update OIDC client token lifetimes', async ({ page }) => {
await expect(refreshLifetime).toHaveValue('720');
await refreshLifetime.fill('336');
await card.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await saveUnsavedChanges(page);
await page.reload();
await expect(card.getByLabel('Access token lifetime', { exact: true })).toHaveValue('90');
@@ -146,21 +141,44 @@ test('Update OIDC client token lifetimes', async ({ page }) => {
await expect(card.getByLabel('Refresh token inactivity timeout unit')).toHaveText('Days');
await card.getByLabel('Access token lifetime', { exact: true }).fill('0');
await card.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(card.getByText('Token lifetime must be at least 1 minute.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('525601');
await card.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(card.getByText('Token lifetime cannot exceed 365 days.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('1.5');
await card.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(card.getByText('Token lifetime must use whole-minute increments.')).toBeVisible();
await card.getByLabel('Access token lifetime', { exact: true }).fill('60');
await card.getByLabel('Refresh token inactivity timeout', { exact: true }).fill('30');
await card.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await saveUnsavedChanges(page);
});
test('Save OIDC client details and token lifetimes together', async ({ page }) => {
const client = oidcClients.nextcloud;
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
const name = page.getByLabel('Name');
const accessLifetime = page
.getByTestId('token-lifetimes-card')
.getByLabel('Access token lifetime', { exact: true });
await name.fill('Nextcloud with custom lifetime');
await accessLifetime.fill('2');
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(name).toHaveValue(client.name);
await expect(accessLifetime).toHaveValue('1');
await name.fill('Nextcloud with custom lifetime');
await accessLifetime.fill('2');
await saveUnsavedChanges(page);
await page.reload();
await expect(name).toHaveValue('Nextcloud with custom lifetime');
await expect(accessLifetime).toHaveValue('2');
});
test('Update OIDC client federated credentials', async ({ page }) => {
@@ -178,7 +196,7 @@ test('Update OIDC client federated credentials', async ({ page }) => {
response.request().method() === 'PUT' &&
response.url().endsWith(`/api/oidc/clients/${client.id}`)
);
await card.getByRole('button', { name: 'Save' }).click();
await saveUnsavedChanges(page);
expect((await cardUpdate).ok()).toBeTruthy();
await page.reload();
@@ -186,17 +204,23 @@ test('Update OIDC client federated credentials', async ({ page }) => {
await expect(card.getByLabel('Subject')).toHaveValue('workload-client');
await expect(card.getByLabel('Audience')).toHaveValue('https://pocket-id.example.com');
await card.getByRole('radio', { name: 'Public keys' }).click();
await card.getByRole('button', { name: 'Add another federated client credential' }).click();
await expect(card.getByLabel('Issuer')).toHaveCount(2);
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(card.getByLabel('Issuer')).toHaveCount(1);
await expect(card.getByRole('radio', { name: 'JWKS URL' })).toBeChecked();
// Saving the main client form must preserve credentials managed by the separate card
await page.locator('[role="tab"][data-value="general"]').click();
const description = page.getByLabel('Description');
await description.fill('Updated without replacing federated credentials');
const clientForm = description.locator('xpath=ancestor::form');
const formUpdate = page.waitForResponse(
(response) =>
response.request().method() === 'PUT' &&
response.url().endsWith(`/api/oidc/clients/${client.id}`)
);
await clientForm.getByRole('button', { name: 'Save' }).click();
await saveUnsavedChanges(page);
expect((await formUpdate).ok()).toBeTruthy();
await page.goto(`/settings/admin/oidc-clients/${client.id}#credentials`);
@@ -228,7 +252,7 @@ test('Update OIDC client federated credentials with public keys', async ({ page
const pasteInput = card.getByLabel('Public key', { exact: true });
const addKeyButton = card.getByRole('button', { name: 'Add public key' });
const publicKeys = card.getByTestId('federated-identity-public-key');
const saveButton = card.getByRole('button', { name: 'Save' });
const saveButton = page.getByRole('button', { name: 'Save', exact: true });
const waitForClientUpdate = () =>
page.waitForResponse(
(response) =>
@@ -254,9 +278,7 @@ test('Update OIDC client federated credentials with public keys', async ({ page
const privateKeyUpdate = waitForClientUpdate();
await saveButton.click();
expect((await privateKeyUpdate).status()).toBe(400);
await expect(
page.locator('[data-type="error"]').filter({ hasText: 'private key material' })
).toBeVisible();
await expect(page.getByText(/private key material/)).toBeVisible();
await publicKeys.last().getByRole('button').click();
await expect(publicKeys).toHaveCount(3);
@@ -268,9 +290,7 @@ test('Update OIDC client federated credentials with public keys', async ({ page
const missingKeyIdUpdate = waitForClientUpdate();
await saveButton.click();
expect((await missingKeyIdUpdate).status()).toBe(400);
await expect(
page.locator('[data-type="error"]').filter({ hasText: 'missing the "kid" property' })
).toBeVisible();
await expect(page.getByText(/missing the "kid" property/)).toBeVisible();
await publicKeys.last().getByRole('button').click();
await expect(publicKeys).toHaveCount(3);
@@ -278,7 +298,7 @@ test('Update OIDC client federated credentials with public keys', async ({ page
await expect(publicKeys).toHaveCount(2);
const cardUpdate = waitForClientUpdate();
await saveButton.click();
await saveUnsavedChanges(page);
expect((await cardUpdate).ok()).toBeTruthy();
await page.reload();
@@ -466,9 +486,7 @@ test('Update OIDC client allowed user groups', async ({ page }) => {
await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click();
await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Allowed user groups updated successfully')).toBeVisible();
await saveUnsavedChanges(page);
await page.reload();
+3 -5
View File
@@ -4,6 +4,7 @@ import { cleanupBackend } from '../utils/cleanup.util';
import { generateIdToken } from '../utils/jwt.util';
import * as oidcUtil from '../utils/oidc.util';
import passkeyUtil from '../utils/passkey.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -1626,8 +1627,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
await parToggle.click();
}
await page.getByRole('button', { name: 'Save', exact: true }).first().click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await saveUnsavedChanges(page);
await page.reload();
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
@@ -1676,9 +1676,7 @@ test.describe('OIDC skip consent', () => {
// Disabling it and saving must persist across a reload
await toggle.click();
await expect(toggle).not.toBeChecked();
const clientForm = toggle.locator('xpath=ancestor::form');
await clientForm.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
await saveUnsavedChanges(page);
await page.reload();
await expect(page.getByRole('switch', { name: 'Skip Consent Screen' })).not.toBeChecked();
+9 -9
View File
@@ -1,6 +1,7 @@
import test, { expect, type Page } from '@playwright/test';
import { cleanupBackend, cleanupScimServiceProvider } from 'utils/cleanup.util';
import { oidcClients, userGroups, users } from '../data';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
async function configureOidcClient(page: Page) {
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
@@ -34,11 +35,17 @@ test.describe('SCIM Configuration', () => {
await page.getByLabel('SCIM Endpoint').fill('http://scim.provider/api');
await page.getByLabel('SCIM Token').fill('supersecrettoken');
await expect(page.getByText('You have unsaved changes', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Enable' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('SCIM enabled successfully.');
await page.getByLabel('SCIM Endpoint').fill('http://edited.scim.provider/api');
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Discard', exact: true }).click();
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api');
await page.reload();
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api');
@@ -54,11 +61,7 @@ test.describe('SCIM Configuration', () => {
await page.getByLabel('SCIM Endpoint').fill('http://new.scim.provider/api');
await page.getByLabel('SCIM Token').fill('evenmoresecrettoken');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'SCIM configuration updated successfully.'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -134,10 +137,7 @@ test.describe('SCIM Sync', () => {
await developersCheckbox.click();
await expect(developersCheckbox).toHaveAttribute('data-state', 'unchecked');
await page.getByRole('button', { name: 'Save' }).click();
await expect(
page.getByText('Allowed user groups updated successfully', { exact: true })
).toBeVisible();
await saveUnsavedChanges(page);
await syncScimServiceProvider(page);
+6 -21
View File
@@ -1,6 +1,7 @@
import test, { expect } from '@playwright/test';
import { oidcClients, userGroups, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -34,9 +35,7 @@ test('Edit user group', async ({ page }) => {
await page.getByLabel('Name', { exact: true }).fill('developers_updated');
await page.getByRole('button', { name: 'Save' }).nth(0).click();
await expect(page.locator('[data-type="success"]')).toHaveText('User group updated successfully');
await saveUnsavedChanges(page);
await expect(page.getByLabel('Friendly Name')).toHaveValue('Developers updated');
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('developers_updated');
});
@@ -49,9 +48,7 @@ test('Update user group users', async ({ page }) => {
await page.getByRole('row', { name: users.tim.username }).getByRole('checkbox').click();
await page.getByRole('row', { name: users.craig.username }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('Users updated successfully');
await saveUnsavedChanges(page);
await page.reload();
@@ -90,11 +87,7 @@ test('Update user group custom claims', async ({ page }) => {
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
await page.waitForLoadState('networkidle');
@@ -107,11 +100,7 @@ test('Update user group custom claims', async ({ page }) => {
// Remove one custom claim
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
await page.waitForLoadState('networkidle');
@@ -136,11 +125,7 @@ test('Update user group allowed user groups', async ({ page }) => {
await page.getByRole('row', { name: oidcClients.tailscale.name }).getByRole('checkbox').click();
await page.getByRole('row', { name: oidcClients.immich.name }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Allowed OIDC clients updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
+11 -24
View File
@@ -2,6 +2,7 @@ import test, { expect } from '@playwright/test';
import { userGroups, users } from '../data';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
@@ -128,9 +129,7 @@ test('Update user', async ({ page }) => {
await page.getByLabel('Display Name').fill('Crack Apple');
await page.getByLabel('Email').fill('crack.apple@test.com');
await page.getByLabel('Username').fill('crack');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.locator('[data-type="success"]')).toHaveText('User updated successfully');
await saveUnsavedChanges(page);
});
test('Update user fails with already taken email', async ({ page }) => {
@@ -145,9 +144,9 @@ test('Update user fails with already taken email', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Email').fill(users.tim.email);
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Email is already in use');
await expect(page.getByText('Email is already in use', { exact: true })).toBeVisible();
});
test('Update user fails with already taken username', async ({ page }) => {
@@ -162,9 +161,9 @@ test('Update user fails with already taken username', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Username').fill(users.tim.username);
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
});
test('Update user fails with already taken username in different casing', async ({ page }) => {
@@ -179,9 +178,9 @@ test('Update user fails with already taken username in different casing', async
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).first().click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible();
});
test('Update user custom claims', async ({ page }) => {
@@ -199,11 +198,7 @@ test('Update user custom claims', async ({ page }) => {
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -215,11 +210,7 @@ test('Update user custom claims', async ({ page }) => {
// Remove one custom claim
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
@@ -237,11 +228,7 @@ test('Update user group assignments', async ({ page }) => {
await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click();
await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'User groups updated successfully'
);
await saveUnsavedChanges(page);
await page.reload();
+7 -7
View File
@@ -2,6 +2,7 @@ import test, { expect, type Page } from '@playwright/test';
import { signupTokens, userGroups, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
async function setSignupMode(
page: Page,
@@ -11,13 +12,12 @@ async function setSignupMode(
await page.goto('/settings/admin/application-configuration');
await page.getByRole('tab', { name: 'User Creation' }).click();
await page.getByRole('button', { name: 'Enable User Signups' }).click();
await page.getByRole('option', { name: mode }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="success"]').last()).toHaveText(
'User creation settings updated successfully.'
);
const signupMode = page.getByRole('button', { name: 'Enable User Signups' });
if (!(await signupMode.textContent())?.includes(mode)) {
await signupMode.click();
await page.getByRole('option', { name: mode }).click();
await saveUnsavedChanges(page);
}
if (signout) {
await page.context().clearCookies();
+6
View File
@@ -0,0 +1,6 @@
import { expect, type Page } from '@playwright/test';
export async function saveUnsavedChanges(page: Page) {
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Changes saved successfully', { exact: true })).toBeVisible();
}