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