diff --git a/frontend/messages/en.json b/frontend/messages/en.json index bdb6cb9f..f234bb3c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -16,13 +16,11 @@ "profile_picture_is_managed_by_ldap_server": "The profile picture is managed by the LDAP server and cannot be changed here.", "click_profile_picture_to_upload_custom": "Click on the profile picture to upload a custom one from your files.", "image_should_be_in_format": "The image should be in PNG, JPEG or WEBP format.", - "items_per_page": "Items per page", "no_items_found": "No items found", "select_items": "Select items...", "search": "Search...", "expand_card": "Expand card", "copied": "Copied", - "click_to_copy": "Click to copy", "something_went_wrong": "Something went wrong", "go_back_to_home": "Go back to home", "alternative_sign_in_methods": "Alternative Sign In Methods", @@ -60,11 +58,7 @@ "client_wants_to_access_the_following_information": "{#b}{client}{/b} wants to access the following information:", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to {#b}{client}{/b} with your {appName} account?", "email": "Email", - "view_your_email_address": "View your email address", - "profile": "Profile", - "view_your_profile_information": "View your profile information", "groups": "Groups", - "view_the_groups_you_are_a_member_of": "View the groups you are a member of", "cancel": "Cancel", "sign_in": "Sign in", "try_again": "Try again", @@ -144,7 +138,6 @@ "username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols", "username_must_start_with": "Username must start with an alphanumeric character", "username_must_end_with": "Username must end with an alphanumeric character", - "or_visit": "or visit", "added_on": "Added on", "rename": "Rename", "delete": "Delete", @@ -345,6 +338,12 @@ "this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image and reset the profile picture to default. Do you want to continue?", "reset": "Reset", "reset_to_default": "Reset to default", + "reset_setting_to_default": "Reset {setting} to default", + "changed_from_the_default": "Changed from the default", + "field_optional": "(optional)", + "advanced": "Advanced", + "count_changed": "{count} changed", + "breadcrumb": "Breadcrumb", "profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.", "select_the_language_you_want_to_use": "Select the language you want to use. Please note that some text may be automatically translated and could be inaccurate.", "contribute_to_translation": "If you find an issue you're welcome to contribute to the translation on {#link href=|https://crowdin.com/project/pocket-id|}Crowdin{/link}.", @@ -514,12 +513,9 @@ "invalid_uri": "Invalid URI", "require_user_email": "Require Email Address", "require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address.", - "view": "View", - "toggle_columns": "Toggle columns", "locale": "Locale", "ldap_id": "LDAP ID", "reauthentication": "Re-authentication", - "clear_filters": "Clear Filters", "default_profile_picture": "Default Profile Picture", "light": "Light", "dark": "Dark", @@ -643,7 +639,6 @@ "review_the_request_before_approving_it": "Review the requesting device before approving this sign in.", "deny": "Deny", "approve": "Approve", - "or": "or", "visit_and_enter": "Visit {url} and enter:", "oidc": "OIDC", "device_login_request_expired": "Your QR-Code has expired. Please start the sign-in process again.", @@ -661,10 +656,14 @@ "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", - "you_have_unsaved_changes": "You have unsaved changes", - "changes_saved_successfully": "Changes saved successfully", + "unsaved_changes": "Unsaved changes", + "one_unsaved_change": "1 unsaved change", + "count_unsaved_changes": "{count} unsaved changes", + "changes_saved": "Changes saved", "discard": "Discard", - "please_fix_the_errors_before_saving": "Please fix the highlighted errors before saving", + "could_not_save_check_the_highlighted_fields": "Couldn’t save. Check the highlighted fields.", + "could_not_save_check_your_connection": "Couldn’t save. Check your connection.", + "save_or_discard_before_leaving": "Save or discard before leaving", "application": "Application", "oidc_client_application_description": "How the client is shown to users on the consent screen and the My Apps page.", "redirects": "Redirects", @@ -722,5 +721,45 @@ "expand_sidebar": "Expand sidebar", "appearance": "Appearance", "account_menu": "Account menu", - "more": "More" + "more": "More", + "any": "Any", + "facet_filter_label": "{name}: {value}, filter", + "clear_name_filter": "Clear {name} filter", + "filters": "Filters", + "display": "Display", + "columns": "Columns", + "select_all_rows_on_this_page": "Select all rows on this page", + "pagination": "Pagination", + "pagination_range": "{#b}{from}–{to}{/b} of {#b}{total}{/b}", + "per_page": "{count} per page", + "page_size": "Page size", + "previous": "Previous", + "next": "Next", + "email_address": "Email address", + "name_username_and_picture": "Name, username and picture", + "group_membership": "Group membership", + "no_groups_yet": "No groups yet", + "actions_for_name": "Actions for {name}", + "copy_code": "Copy code", + "copy_link": "Copy link", + "link": "Link", + "code_expired": "This code expired", + "expires_in_countdown": "Expires in {time}", + "audit_sentence_signed_in_to": "Signed in to {#b}{name}{/b}", + "audit_sentence_allowed_app": "Allowed {#b}{name}{/b} to use the account", + "audit_sentence_signed_in_on_device": "Signed in to {#b}{name}{/b} on a TV or device", + "audit_sentence_allowed_on_device": "Allowed {#b}{name}{/b} on a TV or device", + "audit_sentence_account_created": "Account created", + "audit_sentence_added_passkey": "Added passkey {#b}{name}{/b}", + "audit_sentence_removed_passkey": "Removed passkey {#b}{name}{/b}", + "audit_via_passkey": "via passkey", + "audit_via_login_code": "via login code", + "audit_via_another_device": "via another device", + "audit_by_actor": "by {name}", + "passkey": "Passkey", + "event_id": "Event ID", + "raw_data": "Raw data", + "copy_json": "Copy JSON", + "copy_ip_address": "Copy IP address", + "copy_event_id": "Copy event ID" } diff --git a/frontend/src/app.css b/frontend/src/app.css index e0fc0650..b34eacda 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -567,21 +567,6 @@ html.theme-switching *::after { 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% { @@ -601,43 +586,30 @@ html.theme-switching *::after { 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%); +/* The bar's inverse surface is dark in both themes, so one glow rising from under it reads as light in light and dark mode */ +.bloom-pending, +.bloom-success, +.bloom-error { + background: radial-gradient( + 130% 150% at 50% 130%, + color-mix(in srgb, var(--bloom-tint) 30%, transparent), + transparent 70% + ); } -/* Waits out the bar's 380ms motion so it blooms after the pill settles */ -/* `both` keeps it hidden during that wait */ +.bloom-pending { + --bloom-tint: var(--warning-solid); +} + +/* Waits out the bar's 380ms motion so it blooms after the width settles, and `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%); + --bloom-tint: var(--success-solid); 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% - ); + --bloom-tint: var(--danger-solid); } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/src/lib/components/actions-menu.svelte b/frontend/src/lib/components/actions-menu.svelte index 5db8302d..56ccc354 100644 --- a/frontend/src/lib/components/actions-menu.svelte +++ b/frontend/src/lib/components/actions-menu.svelte @@ -5,20 +5,19 @@ type ButtonVariant } from '#lib/components/ui/button/index.ts'; import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js'; - import { m } from '#lib/paraglide/messages.js'; import type { AdvancedTableAction } from '#lib/types/advanced-table.type.ts'; import { LucideEllipsis } from '@lucide/svelte'; let { item, actions, - label = m.toggle_menu(), + label, variant = 'ghost', size = 'icon' }: { item: T; actions: AdvancedTableAction[]; - label?: string; + label: string; variant?: ButtonVariant; size?: ButtonSize; } = $props(); @@ -33,7 +32,10 @@ {label} - {#each visibleActions as action (action.label)} + {#each visibleActions as action, i (action.label)} + {#if action.variant === 'danger' && i > 0 && visibleActions[i - 1].variant !== 'danger'} + + {/if} action.onClick(item)} disabled={action.disabled} @@ -41,7 +43,7 @@ > {#if action.icon} {@const Icon = action.icon} - + {/if} {action.label} diff --git a/frontend/src/lib/components/advanced-disclosure.svelte b/frontend/src/lib/components/advanced-disclosure.svelte new file mode 100644 index 00000000..b2ccbcfe --- /dev/null +++ b/frontend/src/lib/components/advanced-disclosure.svelte @@ -0,0 +1,50 @@ + + + + {#snippet child({ props })} +
+ + + {m.advanced()} + {summary} + + {#if changedCount > 0} + + {m.count_changed({ count: changedCount })} + + {/if} + + + + {@render children()} + +
+ {/snippet} +
diff --git a/frontend/src/lib/components/audit-log-list.svelte b/frontend/src/lib/components/audit-log-list.svelte index 29d919c6..0b44f1ca 100644 --- a/frontend/src/lib/components/audit-log-list.svelte +++ b/frontend/src/lib/components/audit-log-list.svelte @@ -1,12 +1,24 @@ -{#snippet EventCell({ item }: { item: AuditLog })} - - {translateAuditLogEvent(item.event)} - +{#snippet sentenceText(sentence: AnyFormattedMessage | string)} + {#if typeof sentence === 'string'} + {sentence} + {:else} + + {/if} {/snippet} - - isAdmin - ? await auditLogService.listAllLogs({ - ...options, - filters: wrapFilters(filters) - }) - : await auditLogService.list(options)} - defaultSort={{ column: 'createdAt', direction: 'desc' }} - withoutSearch - {columns} -/> +
+
+ + + {#if !logs} + {#each Array.from({ length: 8 }, (_, i) => i) as i (i)} + + {/each} + {/if} + + {#each days as day (day.label)} +

+ {day.label} +

+
    + {#each day.logs as log (log.id)} + {@const { sentence, via } = describe(log)} + {@const by = + isAdmin && log.actorUsername && log.actorUsername !== log.username + ? log.actorUsername + : undefined} + {@const open = expanded.has(log.id)} + {@const DeviceIcon = isPhone(log.device) ? LucideSmartphone : LucideMonitor} +
  • + + {#if open} +
    +
    +
    +
    {m.event()}
    +
    + {@render sentenceText(sentence)} + {log.event} +
    +
    + {@render detailItem(m.time(), formatStamp(log))} + {#if log.data?.clientName} + {@render detailItem(m.client(), log.data.clientName)} + {/if} + {#if log.data?.passkeyName} + {@render detailItem(m.passkey(), log.data.passkeyName)} + {/if} + {@render detailItem(m.ip_address(), log.ipAddress, m.copy_ip_address())} + {@render detailItem(m.approximate_location(), formatLocation(log))} + {@render detailItem(m.device(), log.device)} + {@render detailItem(m.event_id(), log.id, m.copy_event_id())} +
    +
    + + + {m.raw_data()} + +
    +
    {JSON.stringify(
    +												log,
    +												null,
    +												2
    +											)}
    + +
    +
    +
    + {/if} +
  • + {/each} +
+ {/each} + + {#if logs?.pagination.totalItems === 0} +

+ {m.no_events_match_filters()} + {#if onClearFilters} + + {/if} +

+ {/if} +
+ + { + requestOptions.pagination!.page = page; + refresh(); + }} + onPageSizeChange={(limit) => { + requestOptions.pagination = { page: 1, limit }; + refresh(); + }} + /> +
+ +{#snippet detailItem(label: string, value: string, copyLabel?: string)} +
+
{label}
+
+ {#if copyLabel} + {value} + + {:else} + {value} + {/if} +
+
+{/snippet} diff --git a/frontend/src/lib/components/confirm-dialog/confirm-dialog.svelte b/frontend/src/lib/components/confirm-dialog/confirm-dialog.svelte index 0daac70d..9ffb4f3b 100644 --- a/frontend/src/lib/components/confirm-dialog/confirm-dialog.svelte +++ b/frontend/src/lib/components/confirm-dialog/confirm-dialog.svelte @@ -1,12 +1,40 @@ - - + $confirmDialogStore.open, (open) => !busy && ($confirmDialogStore.open = open)} +> + { + // Cancel takes focus, so Enter never confirms unless the confirm button was focused on purpose + e.preventDefault(); + cancelButton?.focus(); + }} + > {$confirmDialogStore.title} @@ -18,20 +46,17 @@ - Cancel - - {#snippet child()} - - {/snippet} - + {m.cancel()} + + diff --git a/frontend/src/lib/components/confirm-dialog/index.ts b/frontend/src/lib/components/confirm-dialog/index.ts index a3d63f08..ea59de26 100644 --- a/frontend/src/lib/components/confirm-dialog/index.ts +++ b/frontend/src/lib/components/confirm-dialog/index.ts @@ -1,4 +1,5 @@ import { m } from '#lib/paraglide/messages.js'; +import type { Component } from 'svelte'; import { writable } from 'svelte/store'; import type { AnyFormattedMessage, AnyMessage, FormattedMessageProps } from '../formatted-message'; import ConfirmDialog from './confirm-dialog.svelte'; @@ -10,7 +11,9 @@ interface ConfirmDialogState { confirm: { label: string; destructive: boolean; - action: () => void; + // Destructive confirms lead with this icon (a trash can unless given) so they never depend on color alone + icon?: Component; + action: () => unknown; }; } @@ -35,18 +38,21 @@ function openConfirmDialog({ confirm: { label?: string; destructive?: boolean; - action: () => void; + icon?: Component; + action: () => unknown; }; }) { - confirmDialogStore.update((val) => ({ + // Starts from the defaults rather than the previous dialog, so a label, icon or destructive flag never carries over + confirmDialogStore.set({ open: true, title, message, confirm: { - ...val.confirm, + label: m.confirm(), + destructive: false, ...confirm } - })); + }); } export { ConfirmDialog, openConfirmDialog }; diff --git a/frontend/src/lib/components/copy-button.svelte b/frontend/src/lib/components/copy-button.svelte new file mode 100644 index 00000000..0161e176 --- /dev/null +++ b/frontend/src/lib/components/copy-button.svelte @@ -0,0 +1,88 @@ + + +{#snippet icon()} + +{/snippet} + +{#if labeled} + + +{:else} + + + + {#snippet child({ props })} + + {/snippet} + + {copied ? m.copied() : label} + + +{/if} +{copied ? m.copied() : ''} diff --git a/frontend/src/lib/components/copy-field.svelte b/frontend/src/lib/components/copy-field.svelte new file mode 100644 index 00000000..d74bba15 --- /dev/null +++ b/frontend/src/lib/components/copy-field.svelte @@ -0,0 +1,36 @@ + + + +
+ {label} +
+
+ {value} +
+ +
+
diff --git a/frontend/src/lib/components/copy-to-clipboard.svelte b/frontend/src/lib/components/copy-to-clipboard.svelte index f1a1d5c1..b3fa21fc 100644 --- a/frontend/src/lib/components/copy-to-clipboard.svelte +++ b/frontend/src/lib/components/copy-to-clipboard.svelte @@ -9,11 +9,6 @@ let open = $state(false); let copied = $state(false); - function onClick() { - open = true; - copyToClipboard(); - } - function onOpenChange(state: boolean) { open = state; if (!state) { @@ -21,22 +16,30 @@ } } - function copyToClipboard() { - navigator.clipboard.writeText(value); + async function copyToClipboard() { + open = true; + await navigator.clipboard.writeText(value); copied = true; - setTimeout(() => onOpenChange(false), 1000); + setTimeout(() => onOpenChange(false), 1500); } - {@render children()} - + + {@render children()} + + {#if copied} - {m.copied()} + + {m.copied()} {:else} - {m.click_to_copy()} + {m.copy()} {/if} +{copied ? m.copied() : ''} diff --git a/frontend/src/lib/components/form/auto-complete-input.svelte b/frontend/src/lib/components/form/auto-complete-input.svelte index 591a0453..3c0ab423 100644 --- a/frontend/src/lib/components/form/auto-complete-input.svelte +++ b/frontend/src/lib/components/form/auto-complete-input.svelte @@ -1,6 +1,7 @@ -
+
{label} - {description} + {#if changed} + + {m.changed_from_the_default()} + {/if}
-
+
{#if input.error} {input.error} + {:else if description} + {description} {/if}
diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index 57819127..478c0b2a 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -4,10 +4,8 @@ import { Input, type FormInputEvent } from '#lib/components/ui/input/index.ts'; import { m } from '#lib/paraglide/messages.js'; import type { FormInput } from '#lib/utils/form-util.ts'; - import { LucideExternalLink } from '@lucide/svelte'; import type { Snippet } from 'svelte'; - import type { HTMLAttributes } from 'svelte/elements'; - import { cn } from '#lib/utils/style.ts'; + import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements'; type WithoutChildren = { children?: undefined; @@ -24,8 +22,8 @@ input = $bindable(), label, description, - docsLink, placeholder, + autocomplete, disabled = false, type = 'text', children, @@ -33,55 +31,40 @@ labelFor, readonly = false, inputClass, + changed = false, + optional = false, class: className, ...restProps }: HTMLAttributes & (WithChildren | WithoutChildren) & { label?: string; description?: string | Snippet; - docsLink?: string; placeholder?: string; + autocomplete?: HTMLInputAttributes['autocomplete']; disabled?: boolean; inputClass?: string; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date' | 'url'; readonly?: boolean; + changed?: boolean; + optional?: boolean; onInput?: (e: FormInputEvent) => void; } = $props(); const id = label?.toLowerCase().replace(/ /g, '-'); - -
- {#if label} - {label} - {/if} - {#if description} - - {#if typeof description === 'string'} - {description} - {:else} - {@render description()} - {/if} - {#if docsLink} - - {m.docs()} - - - {/if} - - {/if} -
-
+ + {#if label} +
+ {label} + + {#if changed} + + {m.changed_from_the_default()} + {/if} +
+ {/if} +
{#if children} {@render children()} {:else if input} @@ -92,6 +75,7 @@ () => input.value as Date | undefined, (value) => { input.value = value; + input.error = null; } } /> @@ -102,10 +86,12 @@ {id} {placeholder} {type} + {autocomplete} bind:value={ () => input.value as string | number | undefined, (value) => { input.value = value; + input.error = null; } } {disabled} @@ -116,6 +102,10 @@ {/if} {#if input?.error} {input.error} + {:else if description} + + {#if typeof description === 'string'}{description}{:else}{@render description()}{/if} + {/if}
diff --git a/frontend/src/lib/components/form/multi-select.svelte b/frontend/src/lib/components/form/multi-select.svelte index 1c3af6d2..1addd792 100644 --- a/frontend/src/lib/components/form/multi-select.svelte +++ b/frontend/src/lib/components/form/multi-select.svelte @@ -4,7 +4,7 @@ import { cn } from '#lib/utils/style.ts'; import { LucideChevronDown } from '@lucide/svelte'; import { Badge } from '../ui/badge'; - import { Button } from '../ui/button'; + import { filledFieldClass } from '../ui/input'; let { items, @@ -40,20 +40,25 @@ {#snippet child({ props })} - + + {/snippet} diff --git a/frontend/src/lib/components/form/searchable-multi-select.svelte b/frontend/src/lib/components/form/searchable-multi-select.svelte index 9e6b5681..62f78d6f 100644 --- a/frontend/src/lib/components/form/searchable-multi-select.svelte +++ b/frontend/src/lib/components/form/searchable-multi-select.svelte @@ -1,6 +1,6 @@ + +
+ + + {user.firstName?.[0]} + +
+

{fullName || user.email}

+ {#if fullName && user.email} +

{user.email}

+ {/if} +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/list-pagination.svelte b/frontend/src/lib/components/list-pagination.svelte index f256e80c..d9cd2777 100644 --- a/frontend/src/lib/components/list-pagination.svelte +++ b/frontend/src/lib/components/list-pagination.svelte @@ -1,9 +1,12 @@ -{#if !hidden} -
-
-

{m.items_per_page()}

- onPageSizeChange(Number(v))} - > - - {pagination?.itemsPerPage} - - - {#each pageSizes as size (size)} - {size} - {/each} - - -
- - {#snippet children({ pages })} - - - - - {#each pages as page (page.key)} - {#if page.type !== 'ellipsis' && page.value != 0} - - - {page.value} - - - {/if} - {/each} - - - - - {/snippet} - -
+ + + + + {#if !fitsSmallestPage} + + + onPageSizeChange(Number(v))} + > + + {m.per_page({ count: perPage })} + + + {#each pageSizes as size (size)} + + {/each} + + + + {/if} + + {#if totalPages > 1} + + + + + {/if} + {/if} diff --git a/frontend/src/lib/components/oidc-client-avatar.svelte b/frontend/src/lib/components/oidc-client-avatar.svelte index 4ffef648..514068de 100644 --- a/frontend/src/lib/components/oidc-client-avatar.svelte +++ b/frontend/src/lib/components/oidc-client-avatar.svelte @@ -1,5 +1,25 @@ + + -{#if hasLogo} - -{:else} -
- {name.charAt(0).toUpperCase()} -
+ {m.name_logo({ (failed = true)} + /> + +{:else} + + {/if} diff --git a/frontend/src/lib/components/one-time-link-modal.svelte b/frontend/src/lib/components/one-time-link-modal.svelte index 5592909f..08744087 100644 --- a/frontend/src/lib/components/one-time-link-modal.svelte +++ b/frontend/src/lib/components/one-time-link-modal.svelte @@ -1,17 +1,15 @@ + +{#snippet h1()} +

{title}

+{/snippet} + +{#snippet text(value: string | Snippet)} + {#if typeof value === 'string'}{value}{:else}{@render value()}{/if} +{/snippet} + +{#if breadcrumbs} +
+
+ +
+ {#if identity} +
+ {@render identity()} +
+ {/if} +
+
+ {@render h1()} + {@render status?.()} +
+ {#if tags} +
+ {@render tags()} +
+ {/if} +
+
+ {#if actions} +
+ {@render actions()} +
+ {/if} +
+ {#if facts?.length} + +
+ {#each facts as band (band.label)} +
+

{band.label}

+
2 && 'xl:grid-cols-3' + )} + > + {#each band.items as item (item.label)} +
+
{item.label}
+
{@render text(item.value)}
+
+ {/each} +
+
+ {/each} +
+ {/if} +
+{:else} + +
+
+ {@render h1()} + {#if description} +

{@render text(description)}

+ {/if} +
+ {#if actions} + +
+ {@render actions()} +
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/passkey-row.svelte b/frontend/src/lib/components/passkey-row.svelte index ef1146a1..980a3f66 100644 --- a/frontend/src/lib/components/passkey-row.svelte +++ b/frontend/src/lib/components/passkey-row.svelte @@ -1,97 +1,74 @@ - - - {#if showProviderIcon} - (iconFailed = true)} - /> - {:else if icon}{@const Icon = icon} - - {/if} - - - {label} - {#if description} - - - {description} - - {/if} - - - {#if showRenameAction && onRename} - - - - - - {m.rename()} - - - {/if} - - - - - - - {m.delete()} - - - - +
+ {#if iconUrl && !iconFailed} + + (iconFailed = true)} /> + + {:else} + + + + {/if} +
+

{passkey.name}

+

+ {m.added_on()} + {new Date(passkey.createdAt).toLocaleDateString(getLocale(), { dateStyle: 'medium' })} +

+
+ {#if onRename} + + {:else} + + {/if} +
diff --git a/frontend/src/lib/components/qrcode/qr-panel.svelte b/frontend/src/lib/components/qrcode/qr-panel.svelte new file mode 100644 index 00000000..3bd6bc25 --- /dev/null +++ b/frontend/src/lib/components/qrcode/qr-panel.svelte @@ -0,0 +1,90 @@ + + +
+
+ + +
+

+ {m.visit_and_enter({ url: request.verificationUri })} +

+
+ {#each codeGroups as group, i (i)} + {#if i > 0} + – + {/if} + + {#each group as char, j (j)} + {char} + {/each} + + {/each} +
+ +
+ {#if !expired} + + {/if} +
+

+ {expired ? m.code_expired() : m.expires_in_countdown({ time: countdown })} +

+
diff --git a/frontend/src/lib/components/qrcode/qrcode.svelte b/frontend/src/lib/components/qrcode/qrcode.svelte index 91d33b6c..a175c584 100644 --- a/frontend/src/lib/components/qrcode/qrcode.svelte +++ b/frontend/src/lib/components/qrcode/qrcode.svelte @@ -1,38 +1,25 @@ - + + diff --git a/frontend/src/lib/components/scope-item.svelte b/frontend/src/lib/components/scope-item.svelte index 4eba5eae..6976f938 100644 --- a/frontend/src/lib/components/scope-item.svelte +++ b/frontend/src/lib/components/scope-item.svelte @@ -1,54 +1,53 @@ {#snippet content()} - - - - - {name} - {description} - + + + {#if value} + {value} + {category} + {:else} + {category} + {/if} + {/snippet} {#if onSelectedChange} - - {#snippet child({ props })} - - {/snippet} - -{:else} - + + + onSelectedChange(checked)} + /> + +{:else} +
+ {@render content()} +
{/if} diff --git a/frontend/src/lib/components/scope-list.svelte b/frontend/src/lib/components/scope-list.svelte index 6a63ff69..721044d6 100644 --- a/frontend/src/lib/components/scope-list.svelte +++ b/frontend/src/lib/components/scope-list.svelte @@ -1,8 +1,8 @@ - +
+ {m.permissions()} {#if (scopes || []).includes('email')} {/if} {#if (scopes || []).includes('profile')} {/if} {#if (scopes || []).includes('groups')} {/if} {#each customScopes as scope (scope)} {/each} - +
diff --git a/frontend/src/lib/components/setting-row.svelte b/frontend/src/lib/components/setting-row.svelte new file mode 100644 index 00000000..16e9f495 --- /dev/null +++ b/frontend/src/lib/components/setting-row.svelte @@ -0,0 +1,129 @@ + + +
+ {#if Icon} + + {/if} +
+
+ + {#if description} +

+ {#if typeof description === 'string'}{description}{:else}{@render description()}{/if} +

+ {/if} + {#if consequence} +

+ + {consequence} +

+ {/if} + {#if disabledReason} + {disabledReason} + {/if} +
+ + {#if changed && onReset} + + + {#snippet child({ props })} + + {/snippet} + + {m.reset_to_default()} + + {/if} +
[data-slot=switch]:only-child]:basis-full max-sm:not-has-[>[data-slot=switch]:only-child]:justify-start' + )} + > + {@render control({ id: controlId, 'aria-describedby': describedBy })} +
+
+
diff --git a/frontend/src/lib/components/settings-section.svelte b/frontend/src/lib/components/settings-section.svelte new file mode 100644 index 00000000..382b719b --- /dev/null +++ b/frontend/src/lib/components/settings-section.svelte @@ -0,0 +1,34 @@ + + +
+
+
+

{title}

+ {#if description} +

+ {#if typeof description === 'string'}{description}{:else}{@render description()}{/if} +

+ {/if} +
+ {#if action} +
{@render action()}
+ {/if} +
+ {@render children()} +
diff --git a/frontend/src/lib/components/signup/signup-token-modal.svelte b/frontend/src/lib/components/signup/signup-token-modal.svelte index ef08b89f..9ff0b709 100644 --- a/frontend/src/lib/components/signup/signup-token-modal.svelte +++ b/frontend/src/lib/components/signup/signup-token-modal.svelte @@ -1,6 +1,6 @@ - - + - - - - - - {m.toggle_columns()} - - {#each columns as column (column)} - {#if column.label} - { - const key = column.column ?? column.key!; - if (v) { - selectedColumns = [...selectedColumns, key]; - } else { - selectedColumns = selectedColumns.filter((c) => c !== key); - } - }} - > - {column.label} - - {/if} + + {m.display()} + + +

+ {m.columns()} +

+
+ + {#each columns.slice(1).filter((c) => c.label) as column (columnKey(column))} + {@const pressed = selectedColumns.includes(columnKey(column))} + {/each} - - - +
+
+ +
+
+ diff --git a/frontend/src/lib/components/table/advanced-table-filter.svelte b/frontend/src/lib/components/table/advanced-table-filter.svelte index 5a2eba86..db24e851 100644 --- a/frontend/src/lib/components/table/advanced-table-filter.svelte +++ b/frontend/src/lib/components/table/advanced-table-filter.svelte @@ -1,130 +1,63 @@ - - - {#snippet child({ props })} - - {/snippet} - - + onChange(options.find((o) => String(o.value) === v)?.value)} > - - - {m.no_items_found()} - - {#each options as option (option)} - {@const isSelected = selectedValues.has(option.value)} - { - if (isSelected) { - selectedValues = new Set([...selectedValues].filter((v) => v !== option.value)); - } else { - selectedValues = new Set([...selectedValues, option.value]); - } - onChanged(selectedValues); - }} - > - {#if showCheckboxes} -
- -
- {/if} - {#if option.icon} - {@const Icon = option.icon} - - {/if} - - {option.label} -
- {/each} -
- {#if selectedValues.size > 0} - - - { - selectedValues = new Set(); - onChanged(selectedValues); - open = false; - }} - > - {m.clear_filters()} - - - {/if} -
-
-
-
+ + {title}: + {valueLabel} + + + + {#each options as option (option.value)} + + {/each} + + + {#if selected} + + {/if} + diff --git a/frontend/src/lib/components/table/advanced-table-toolbar.svelte b/frontend/src/lib/components/table/advanced-table-toolbar.svelte index e9a7ac58..1ecbcf0b 100644 --- a/frontend/src/lib/components/table/advanced-table-toolbar.svelte +++ b/frontend/src/lib/components/table/advanced-table-toolbar.svelte @@ -1,67 +1,69 @@ -
-
+{#if !withoutSearch || filterableColumns.length > 0} +
{#if !withoutSearch} - onSearch((e.currentTarget as HTMLInputElement).value)} - /> + + search((e.currentTarget as HTMLInputElement).value)} + /> + + {/if} - {#each filterableColumns as col (col.column)} - onFilterChange?.(selected, col.column)} - /> - {/each} - + {#if filterableColumns.length > 0} + +
+ {#each filterableColumns as col (col.column)} + onFilterChange(col.column!, value)} + /> + {/each} +
+ {/if}
-
+{/if} diff --git a/frontend/src/lib/components/table/advanced-table.svelte b/frontend/src/lib/components/table/advanced-table.svelte index cc18e31a..08f07b6b 100644 --- a/frontend/src/lib/components/table/advanced-table.svelte +++ b/frontend/src/lib/components/table/advanced-table.svelte @@ -3,6 +3,7 @@ import { page as currentPage } from '$app/state'; import ActionsMenu from '#lib/components/actions-menu.svelte'; import ListPagination from '#lib/components/list-pagination.svelte'; + import { Button } from '#lib/components/ui/button/index.ts'; import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte'; import * as Table from '#lib/components/ui/table/index.js'; import Empty from '#lib/icons/empty.svelte'; @@ -13,19 +14,22 @@ } from '#lib/types/advanced-table.type.ts'; import type { ListRequestOptions, Paginated, SortRequest } from '#lib/types/list-request.type.ts'; import { cn } from '#lib/utils/style.ts'; - import { ChevronDown } from '@lucide/svelte'; + import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'; + import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'; import { PersistedState } from 'runed'; import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; - import Button from '../ui/button/button.svelte'; import { Skeleton } from '../ui/skeleton'; + import AdvancedTableColumnSelection from './advanced-table-column-selection.svelte'; import AdvancedTableToolbar from './advanced-table-toolbar.svelte'; let { id, selectedIds = $bindable(), withoutSearch = false, - selectionDisabled = false, + query: externalQuery, + filtered = false, + onClearQuery, onRowClick, rowSelectionDisabled, fetchCallback, @@ -36,8 +40,11 @@ id: string; selectedIds?: string[]; withoutSearch?: boolean; - selectionDisabled?: boolean; - rowSelectionDisabled?: (item: T) => boolean; + // The search of a page that brings its own search field, so no matches can be told apart from no data + query?: string; + // Whether a filter the page applies itself, outside the table's own facets, narrows the list + filtered?: boolean; + onClearQuery?: () => void; fetchCallback: (requestOptions: ListRequestOptions) => Promise>; defaultSort?: SortRequest; columns: AdvancedTableColumn[]; @@ -47,6 +54,11 @@ let items: Paginated | undefined = $state(); let searchValue = $state(''); + let isRefreshing = $state(false); + let searchInput = $state(null); + const query = $derived((externalQuery ?? searchValue).trim()); + const hasOptionalColumns = columns.some((c) => c.hidden); + const hasStickyColumn = !!actions || hasOptionalColumns; const availablePageSizes: number[] = [20, 50, 100]; @@ -128,10 +140,10 @@ await refresh(); } - async function onFilterChange(selected: Set, column: string) { + async function onFilterChange(column: string, value?: string | boolean) { requestOptions.filters = { ...requestOptions.filters, - [column]: Array.from(selected) + [column]: value === undefined ? [] : [value] }; tablePreferences.current.filters = requestOptions.filters; await refresh(); @@ -157,6 +169,8 @@ } function updateListLength(totalItems: number) { + // Searches and a page's own filters are not kept between visits, so their results must not decide what the next visit shows while it loads + if (query || filtered) return; tablePreferences.current.length = totalItems > tablePreferences.current.paginationLimit ? tablePreferences.current.paginationLimit @@ -173,14 +187,44 @@ return filters; } + // Phones show only the first column, which takes the free width and truncates inside it, so the table never scrolls sideways + // Wider screens give it the width its content is capped at, so the columns stay put when paging, sorting or searching + // Narrower tables drop the columns that give way first, so the rest still fit beside the names + const columnClass = (column: AdvancedTableColumn, index: number) => + cn( + index === 0 + ? 'max-sm:w-full max-sm:max-w-0 sm:w-[calc(var(--table-id-max)+2rem)]' + : 'max-sm:hidden', + index === 0 && !hasStickyColumn && 'max-sm:rounded-r-sm', + column.hideBelow === 900 && '@max-[900px]:hidden', + column.hideBelow === 760 && '@max-[760px]:hidden', + column.hideBelow === 520 && '@max-[520px]:hidden' + ); + function getPrimaryAction(item: T) { if (!actions) return null; const availableActions = actions(item).filter((a) => a.primary); return availableActions.length > 0 ? () => availableActions[0].onClick(item) : null; } + // One action clears the search and the facets together, and focus moves to the search because the action removes itself + async function clearQuery() { + if (onClearQuery) return onClearQuery(); + searchValue = ''; + requestOptions.search = ''; + requestOptions.filters = initializeFilters(); + tablePreferences.current.filters = requestOptions.filters; + searchInput?.focus(); + await refresh(); + } + export async function refresh() { - items = await fetchCallback(requestOptions); + isRefreshing = true; + try { + items = await fetchCallback(requestOptions); + } finally { + isRefreshing = false; + } await changePageState(items.pagination.currentPage); updateListLength(items.pagination.totalItems); } @@ -188,107 +232,98 @@ -{#if (items?.pagination.totalItems === 0 && searchValue === '') || tablePreferences.current.length === 0} -
- -

{m.no_items_found()}

+{#if !items} + +
+ + {#each Array.from({ length: tablePreferences.current.length || 5 }, (_, i) => i) as i (i)} +
+ +
+ {/each}
{:else} {#if !items} -
- {#each Array.from({ length: (tablePreferences.current.length || 10) + 1 }, (_, i) => i) as i (i)} -
- +
+ + {#each Array.from({ length: tablePreferences.current.length || 10 }, (_, i) => i) as i (i)} +
+
{/each}
{:else} -
+ +
{#if selectedIds} - + + e.stopPropagation()}> onAllCheck(c as boolean)} + checked={selected} + aria-labelledby={nameId} + onCheckedChange={(c: boolean) => onCheck(c, item.id)} /> {/if} - - {#each visibleColumns as column (column.key ?? column.column ?? column.label)} - - {#if column.sortable} - - {:else} - {column.label} + {#each visibleColumns as column, i (column.key ?? column.column ?? column.label)} + + {#if column.value} + {column.value(item)} + {:else if column.cell} + {@render column.cell({ item })} + {:else if column.column && typeof item[column.column] === 'boolean'} + {item[column.column] ? m.yes() : m.no()} + {:else if column.column} + {item[column.column]} {/if} {/each} - {#if actions} - - {m.actions()} - - {/if} + + + + {#each items.data as item (item.id)} + {@const selected = selectedIds?.includes(item.id)} onRowClick(item) : getPrimaryAction(item)} > {#if selectedIds} - + onCheck(c, item.id)} /> {/if} - {#each visibleColumns as column (column.key ?? column.column ?? column.label)} - + {#each visibleColumns as column, i (column.key ?? column.column ?? column.label)} + {#if column.value} {column.value(item)} {:else if column.cell} @@ -300,26 +335,41 @@ {/if} {/each} - {#if actions} - - - - - {/if} - - {/each} - - -
- {/if} - - + + + {#if actions} + + {/if} + + {/if} + + {/each} + + + {#if items.data.length === 0} +

+ {#if query} + {m.no_matches_for_query({ query })} + {:else if narrowed} + {m.no_matches_for_filters()} + {:else} + {m.no_items_found()} + {/if} + {#if narrowed} + + {/if} +

+ {/if} +
{/if} diff --git a/frontend/src/lib/components/ui/pagination/index.ts b/frontend/src/lib/components/ui/pagination/index.ts deleted file mode 100644 index 452fe7e6..00000000 --- a/frontend/src/lib/components/ui/pagination/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import Root from './pagination.svelte'; -import Content from './pagination-content.svelte'; -import Item from './pagination-item.svelte'; -import Link from './pagination-link.svelte'; -import PrevButton from './pagination-prev-button.svelte'; -import NextButton from './pagination-next-button.svelte'; -import Ellipsis from './pagination-ellipsis.svelte'; -import Previous from './pagination-previous.svelte'; -import Next from './pagination-next.svelte'; - -export { - Root, - Content, - Item, - Link, - PrevButton, // old - NextButton, // old - Ellipsis, - Previous, - Next, - // - Root as Pagination, - Content as PaginationContent, - Item as PaginationItem, - Link as PaginationLink, - PrevButton as PaginationPrevButton, // old - NextButton as PaginationNextButton, // old - Ellipsis as PaginationEllipsis, - Previous as PaginationPrevious, - Next as PaginationNext -}; diff --git a/frontend/src/lib/components/ui/pagination/pagination-content.svelte b/frontend/src/lib/components/ui/pagination/pagination-content.svelte deleted file mode 100644 index d2f22b3f..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination-content.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
    - {@render children?.()} -
diff --git a/frontend/src/lib/components/ui/pagination/pagination-ellipsis.svelte b/frontend/src/lib/components/ui/pagination/pagination-ellipsis.svelte deleted file mode 100644 index ada668ed..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination-ellipsis.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/pagination/pagination-item.svelte b/frontend/src/lib/components/ui/pagination/pagination-item.svelte deleted file mode 100644 index beee0596..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination-item.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -
  • - {@render children?.()} -
  • diff --git a/frontend/src/lib/components/ui/pagination/pagination-link.svelte b/frontend/src/lib/components/ui/pagination/pagination-link.svelte deleted file mode 100644 index 3726ad04..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination-link.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - -{#snippet Fallback()} - {page.value} -{/snippet} - - - {#if children} - {@render children?.()} - {:else} - {@render Fallback()} - {/if} - diff --git a/frontend/src/lib/components/ui/pagination/pagination-next-button.svelte b/frontend/src/lib/components/ui/pagination/pagination-next-button.svelte deleted file mode 100644 index b1e1a108..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination-next-button.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -{#snippet Fallback()} - Next - -{/snippet} - - - {#if children} - {@render children?.()} - {:else} - {@render Fallback()} - {/if} - diff --git a/frontend/src/lib/components/ui/pagination/pagination-next.svelte b/frontend/src/lib/components/ui/pagination/pagination-next.svelte deleted file mode 100644 index 458dbb97..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination-next.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - Next - - diff --git a/frontend/src/lib/components/ui/pagination/pagination-prev-button.svelte b/frontend/src/lib/components/ui/pagination/pagination-prev-button.svelte deleted file mode 100644 index f1f37165..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination-prev-button.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -{#snippet Fallback()} - - Previous -{/snippet} - - - {#if children} - {@render children?.()} - {:else} - {@render Fallback()} - {/if} - diff --git a/frontend/src/lib/components/ui/pagination/pagination-previous.svelte b/frontend/src/lib/components/ui/pagination/pagination-previous.svelte deleted file mode 100644 index b2049170..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination-previous.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - - Previous - diff --git a/frontend/src/lib/components/ui/pagination/pagination.svelte b/frontend/src/lib/components/ui/pagination/pagination.svelte deleted file mode 100644 index 12a02e0f..00000000 --- a/frontend/src/lib/components/ui/pagination/pagination.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte index bf06cc47..35619311 100644 --- a/frontend/src/lib/components/ui/select/select-trigger.svelte +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -9,9 +9,12 @@ class: className, children, size = 'default', + variant = 'filled', ...restProps }: WithoutChild & { size?: 'sm' | 'default'; + // Ghost triggers read as text inside a sentence or a filter row (facet filters, the page size) + variant?: 'filled' | 'ghost'; } = $props(); @@ -20,12 +23,19 @@ data-slot="select-trigger" data-size={size} class={cn( - filledFieldClass, - "text-control aria-expanded:bg-fill-selected data-placeholder:text-fg-muted dark:aria-expanded:bg-fill-selected flex w-fit min-w-0 items-center justify-between gap-1.5 rounded-sm text-left whitespace-nowrap data-[size=default]:h-(--h-md) data-[size=default]:pr-2.5 data-[size=default]:pl-3.5 data-[size=sm]:h-(--h-sm) data-[size=sm]:pr-2 data-[size=sm]:pl-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "text-control flex w-fit min-w-0 items-center justify-between gap-1.5 rounded-sm text-left whitespace-nowrap [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + variant === 'filled' + ? cn( + filledFieldClass, + 'aria-expanded:bg-fill-selected data-placeholder:text-fg-muted dark:aria-expanded:bg-fill-selected data-[size=default]:h-(--h-md) data-[size=default]:pr-2.5 data-[size=default]:pl-3.5 data-[size=sm]:h-(--h-sm) data-[size=sm]:pr-2 data-[size=sm]:pl-3' + ) + : 'hover:bg-fill-hover aria-expanded:bg-fill-selected focus-visible:outline-ring h-7 gap-1 rounded-xs pr-1.5 pl-2 transition-colors duration-(--dur-fast) focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-45', className )} {...restProps} > {@render children?.()} - + diff --git a/frontend/src/lib/components/unsaved-changes-bar.svelte b/frontend/src/lib/components/unsaved-changes-bar.svelte index 4cbc79ca..19dc7e29 100644 --- a/frontend/src/lib/components/unsaved-changes-bar.svelte +++ b/frontend/src/lib/components/unsaved-changes-bar.svelte @@ -1,42 +1,61 @@ - -{#snippet divider()} - -{/snippet} +

    {announcement}

    {#if barOpen}
    (shake = false)} >
    {#if animate && bloom} {#key bloom} @@ -184,44 +218,50 @@ - {#if unsavedChanges.dirtyCount > 1} - - {unsavedChanges.dirtyCount} - - {/if} - {m.you_have_unsaved_changes()} - {@render divider()} + + {pendingText} - {#if status?.type === 'error'} - - {/if} - {status?.message ?? ''} - {#if showingActions} - {@render divider()} - {/if} + + {shownStatus?.message ?? ''} unsavedChanges.discardAll()} > {m.discard()} - + + + + + {#snippet child({ props })} + + {/snippet} + + + {m.save()} + {modKey}S + + +
    diff --git a/frontend/src/lib/stores/show-advanced-store.svelte.ts b/frontend/src/lib/stores/show-advanced-store.svelte.ts new file mode 100644 index 00000000..230eb2ce --- /dev/null +++ b/frontend/src/lib/stores/show-advanced-store.svelte.ts @@ -0,0 +1,4 @@ +import { PersistedState } from 'runed'; + +// Per-admin preference that opens every Advanced disclosure; it lives in this browser until it moves to the user's settings +export const showAdvanced = new PersistedState('show-advanced-settings', false); diff --git a/frontend/src/lib/stores/unsaved-changes-store.svelte.ts b/frontend/src/lib/stores/unsaved-changes-store.svelte.ts index 20792feb..aa8372a7 100644 --- a/frontend/src/lib/stores/unsaved-changes-store.svelte.ts +++ b/frontend/src/lib/stores/unsaved-changes-store.svelte.ts @@ -4,7 +4,7 @@ import { isAxiosError } from 'axios'; import { SvelteSet } from 'svelte/reactivity'; export interface UnsavedSection { - readonly dirty: boolean; + readonly changes: number; validate?: () => boolean; save: () => Promise; discard: () => void; @@ -19,7 +19,7 @@ let saving = $state(false); let status = $state<{ type: 'success' | 'error'; message: string } | null>(null); let statusTimeout: ReturnType | undefined; -const dirtyCount = $derived([...sections].filter((section) => section.dirty).length); +const dirtyCount = $derived([...sections].reduce((count, section) => count + section.changes, 0)); const hasChanges = $derived(dirtyCount > 0); function register(section: UnsavedSection) { @@ -42,18 +42,18 @@ function showStatus(type: 'success' | 'error', message: string) { } function errorMessage(e: unknown) { - if (isAxiosError(e)) return getAxiosErrorMessage(e); + if (isAxiosError(e)) return getAxiosErrorMessage(e, m.could_not_save_check_your_connection()); return e instanceof Error ? e.message : m.an_unknown_error_occurred(); } async function saveAll(): Promise { - const dirtySections = [...sections].filter((section) => section.dirty); + const dirtySections = [...sections].filter((section) => section.changes > 0); 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()); + showStatus('error', m.could_not_save_check_the_highlighted_fields()); return 'invalid'; } @@ -73,14 +73,14 @@ async function saveAll(): Promise { showStatus('error', errorMessage(failure.error)); return 'failed'; } - showStatus('success', m.changes_saved_successfully()); + showStatus('success', m.changes_saved()); return 'saved'; } function discardAll() { clearStatus(); for (const section of sections) { - if (section.dirty) section.discard(); + if (section.changes > 0) section.discard(); } } diff --git a/frontend/src/lib/types/advanced-table.type.ts b/frontend/src/lib/types/advanced-table.type.ts index 1a04c42d..6cf2384b 100644 --- a/frontend/src/lib/types/advanced-table.type.ts +++ b/frontend/src/lib/types/advanced-table.type.ts @@ -10,9 +10,10 @@ export type AdvancedTableColumn> = { filterableValues?: { label: string; value: string | boolean; - icon?: Component; }[]; hidden?: boolean; + // The table width in pixels below which the column gives way + hideBelow?: 520 | 760 | 900; }; export type CreateAdvancedTableActions> = ( item: T diff --git a/frontend/src/lib/types/audit-log.type.ts b/frontend/src/lib/types/audit-log.type.ts index d3524c61..2da87e36 100644 --- a/frontend/src/lib/types/audit-log.type.ts +++ b/frontend/src/lib/types/audit-log.type.ts @@ -5,7 +5,7 @@ export type AuditLog = { country?: string; city?: string; device: string; - userId: string; + userID: string; username?: string; actorUsername?: string; createdAt: string; diff --git a/frontend/src/lib/utils/event-util.ts b/frontend/src/lib/utils/event-util.ts index 55bdf531..7d1408ff 100644 --- a/frontend/src/lib/utils/event-util.ts +++ b/frontend/src/lib/utils/event-util.ts @@ -4,3 +4,8 @@ export function preventDefault(fn: (event: Event) => void): (event: Event) => vo fn.call(this, event); }; } + +// Chrome leaves a partly visible control where it is when it takes focus, so horizontal scrollers bring it into view, ring included through their scroll padding +export function revealFocused(event: FocusEvent) { + (event.target as HTMLElement).scrollIntoView({ block: 'nearest', inline: 'nearest' }); +} diff --git a/frontend/src/lib/utils/form-util.ts b/frontend/src/lib/utils/form-util.ts index f78c5fe4..3869307a 100644 --- a/frontend/src/lib/utils/form-util.ts +++ b/frontend/src/lib/utils/form-util.ts @@ -5,7 +5,6 @@ import { z } from 'zod/v4'; export type FormInput = { value: T; error: string | null; - required: boolean; }; type FormInputs = { @@ -26,17 +25,11 @@ export function createForm>(schema: T, initialValu function initializeInputs(): FormInputs> { const inputs: FormInputs> = {} as FormInputs>; - const shape = - schema instanceof z.ZodObject ? (schema.shape as Record) : {}; - for (const key in baseline) { if (Object.prototype.hasOwnProperty.call(baseline, key)) { - const fieldSchema = shape[key]; - inputs[key as keyof z.infer] = { value: deepCopy(baseline[key as keyof z.infer]), - error: null, - required: fieldSchema ? isRequired(fieldSchema) : false + error: null }; } } @@ -84,11 +77,15 @@ export function createForm>(schema: T, initialValu return values; } - function isDirty() { + function changedCount() { const inputs = get(inputsStore); - return Object.keys(inputs).some( + return Object.keys(inputs).filter( (key) => !deepEqual(inputs[key as keyof z.infer].value, baseline[key as keyof z.infer]) - ); + ).length; + } + + function isDirty() { + return changedCount() > 0; } // Moves the baseline forward, e.g. after a successful save whose data isn't reflected back @@ -143,33 +140,6 @@ export function createForm>(schema: T, initialValu return value; } - function isRequired(fieldSchema: z.ZodTypeAny): boolean { - // Handle string allow empty - if (fieldSchema instanceof z.ZodString) { - return fieldSchema.minLength !== null && fieldSchema.minLength > 0; - } - - // Handle unions - // A union that accepts an empty string, like `z.url().or(z.literal(''))`, can be left blank as well - if (fieldSchema instanceof z.ZodUnion) { - return !fieldSchema.def.options.some((o: any) => { - return o.def.type == 'optional' || (o instanceof z.ZodLiteral && o.def.values.includes('')); - }); - } - - // Handle pipes like emptyToUndefined - if (fieldSchema instanceof z.ZodPipe) { - return isRequired(fieldSchema.def.out as z.ZodTypeAny); - } - - // Handle the normal cases - if (fieldSchema instanceof z.ZodOptional || fieldSchema instanceof z.ZodDefault) { - return false; - } - - return true; - } - return { schema, inputs: inputsStore, @@ -179,6 +149,7 @@ export function createForm>(schema: T, initialValu setValue, reset, clearErrors, + changedCount, isDirty, commit }; diff --git a/frontend/src/lib/utils/unsaved-changes-util.svelte.ts b/frontend/src/lib/utils/unsaved-changes-util.svelte.ts index c3217609..ae6e949d 100644 --- a/frontend/src/lib/utils/unsaved-changes-util.svelte.ts +++ b/frontend/src/lib/utils/unsaved-changes-util.svelte.ts @@ -9,15 +9,15 @@ import { fromStore, type Readable } from 'svelte/store'; * The section is unregistered when the calling component is destroyed. */ export function trackUnsavedSection( - dirty: () => boolean, + changes: () => number, save: () => Promise, discard: () => void, options: { validate?: () => boolean } = {} ) { - const isDirty = $derived.by(dirty); + const changeCount = $derived.by(changes); const section: UnsavedSection = { - get dirty() { - return isDirty; + get changes() { + return changeCount; }, validate: options.validate, save, @@ -32,7 +32,7 @@ export function trackUnsavedSection( interface TrackedForm { inputs: Readable; - isDirty: () => boolean; + changedCount: () => number; validate: () => T | null; commit: (data: T) => void; reset: () => void; @@ -48,29 +48,28 @@ export function trackFormChanges( 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. + const changes = $derived.by(() => { + // The inputs are deeply reactive, so edits are tracked through the reads in `changedCount()` + // 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); + if (extra.enabled && !extra.enabled()) return 0; + return getForm().changedCount() + (extra.dirty?.() ? 1 : 0); }); // 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()); + if (changes === 0) untrack(() => getForm().clearErrors()); }); trackUnsavedSection( - () => isDirty, + () => changes, 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()); + if (!data) throw new Error(m.could_not_save_check_the_highlighted_fields()); await onSave(data); form.commit(data); }, @@ -100,7 +99,16 @@ export function trackUnsavedValue( }; trackUnsavedSection( - () => !deepEqual(read(), saved), + () => { + const value = read(); + // An object of settings counts one change per setting that differs + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const savedSettings = saved as Record; + return Object.entries(value).filter(([key, item]) => !deepEqual(item, savedSettings[key])) + .length; + } + return deepEqual(value, saved) ? 0 : 1; + }, async () => { await save(deepCopy(read())); markSaved(); diff --git a/frontend/src/routes/interaction/+page.svelte b/frontend/src/routes/interaction/+page.svelte index a80bd01b..8bc8c9a0 100644 --- a/frontend/src/routes/interaction/+page.svelte +++ b/frontend/src/routes/interaction/+page.svelte @@ -1,9 +1,9 @@ - +
    {#each passkeys as passkey (passkey.id)} - (passkeyToRename = passkey)} onDelete={() => deletePasskey(passkey)} /> {/each} - +
    import { goto } from '$app/navigation'; - import ImageBox from '#lib/components/image-box.svelte'; + import OidcClientAvatar from '#lib/components/oidc-client-avatar.svelte'; import AdvancedTable from '#lib/components/table/advanced-table.svelte'; import { ScrollArea } from '#lib/components/ui/scroll-area/index.ts'; import * as Tooltip from '#lib/components/ui/tooltip/index.ts'; @@ -11,10 +11,8 @@ CreateAdvancedTableActions } from '#lib/types/advanced-table.type.ts'; import type { OidcClientWithAllowedGroups } from '#lib/types/oidc.type.ts'; - import { cachedOidcClientLogo } from '#lib/utils/cached-image-util.ts'; import { encodeClientIdParam } from '#lib/utils/client-id-util.ts'; import { LucidePencil } from '@lucide/svelte'; - import { mode } from 'mode-watcher'; import { deleteClientAction, refreshClientAction } from './oidc-client-actions'; const oidcService = new OIDCService(); @@ -24,8 +22,6 @@ return tableRef?.refresh(); } - const isLightMode = $derived(mode.current === 'light'); - const booleanFilterValues = [ { label: m.yes(), value: true }, { label: m.no(), value: false } @@ -134,17 +130,7 @@ {/snippet} {#snippet LogoCell({ item }: { item: OidcClientWithAllowedGroups })} - {#if item.hasLogo || item.hasDarkLogo} - - {:else} -
    - {item.name.charAt(0).toUpperCase()} -
    - {/if} + {/snippet} - import ImageBox from '#lib/components/image-box.svelte'; + import OidcClientAvatar from '#lib/components/oidc-client-avatar.svelte'; import AdvancedTable from '#lib/components/table/advanced-table.svelte'; import { m } from '#lib/paraglide/messages.js'; import OidcService from '#lib/services/oidc-service.ts'; import type { AdvancedTableColumn } from '#lib/types/advanced-table.type.ts'; import type { ListRequestOptions } from '#lib/types/list-request.type.ts'; import type { OidcClient, OidcClientWithAllowedGroups } from '#lib/types/oidc.type.ts'; - import { cachedOidcClientLogo } from '#lib/utils/cached-image-util.ts'; - import { mode } from 'mode-watcher'; let { selectedGroupIds = $bindable() @@ -32,8 +30,6 @@ return tableRef?.refresh(); } - const isLightMode = $derived(mode.current === 'light'); - const columns: AdvancedTableColumn[] = [ { label: 'ID', column: 'id', hidden: true }, { label: m.logo(), key: 'logo', cell: LogoCell }, @@ -72,17 +68,7 @@ {#snippet LogoCell({ item }: { item: OidcClient })} - {#if item.hasLogo || item.hasDarkLogo} - - {:else} -
    - {item.name.charAt(0).toUpperCase()} -
    - {/if} + {/snippet} import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts'; import PasskeyRow from '#lib/components/passkey-row.svelte'; - import * as Item from '#lib/components/ui/item/index.js'; import { m } from '#lib/paraglide/messages.js'; import UserService from '#lib/services/user-service.ts'; import type { Passkey } from '#lib/types/passkey.type.ts'; - import { authenticatorIconUrl } from '#lib/utils/cached-image-util.ts'; import { axiosErrorToast } from '#lib/utils/error-util.ts'; - import { LucideKeyRound } from '@lucide/svelte'; - import { mode } from 'mode-watcher'; import { toast } from 'svelte-sonner'; let { @@ -21,8 +17,6 @@ const userService = new UserService(); - const isLightMode = $derived(mode.current === 'light'); - async function refreshPasskeys() { passkeys = await userService.listUserPasskeys(userId); } @@ -48,17 +42,8 @@ } - +
    {#each [...passkeys].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) as passkey (passkey.id)} - deletePasskey(passkey)} - /> + deletePasskey(passkey)} /> {/each} - +
    diff --git a/tests/specs/account-settings.spec.ts b/tests/specs/account-settings.spec.ts index 02f89462..1903e94a 100644 --- a/tests/specs/account-settings.spec.ts +++ b/tests/specs/account-settings.spec.ts @@ -3,7 +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'; +import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util'; test.beforeEach(async () => await cleanupBackend()); @@ -41,7 +41,9 @@ test('Failed account update remains dirty and can be retried', async ({ page }) 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( + unsavedChangesBar(page).getByText('Temporary account update failure', { exact: true }) + ).toBeVisible(); await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeVisible(); expect(failedUpdates).toBe(1); @@ -57,7 +59,9 @@ test('Update account details fails with already taken email', async ({ page }) = await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(page.getByText('Email is already in use', { exact: true })).toBeVisible(); + await expect( + unsavedChangesBar(page).getByText('Email is already in use', { exact: true }) + ).toBeVisible(); }); test('Update account details fails with already taken username', async ({ page }) => { @@ -67,7 +71,9 @@ test('Update account details fails with already taken username', async ({ page } await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible(); + await expect( + unsavedChangesBar(page).getByText('Username is already in use', { exact: true }) + ).toBeVisible(); }); test('Update account details fails with already taken username in different casing', async ({ @@ -79,7 +85,9 @@ test('Update account details fails with already taken username in different casi await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible(); + await expect( + unsavedChangesBar(page).getByText('Username is already in use', { exact: true }) + ).toBeVisible(); }); test('Change Locale', async ({ page }) => { @@ -117,24 +125,32 @@ test('Add passkey to an account', async ({ page }) => { await page.getByLabel('Name', { exact: true }).fill('Test Passkey'); await page.getByLabel('Name Passkey').getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('Test Passkey')).toBeVisible(); + await expect(page.getByText('Test Passkey', { exact: true })).toBeVisible(); }); test('Rename passkey', async ({ page }) => { await page.goto('/settings/account'); - await page.getByLabel('Rename').first().click(); + await page + .getByRole('button', { name: /^Actions for / }) + .first() + .click(); + await page.getByRole('menuitem', { name: 'Rename' }).click(); await page.getByLabel('Name', { exact: true }).fill('Renamed Passkey'); await page.getByLabel('Name Passkey').getByRole('button', { name: 'Save' }).click(); - await expect(page.getByText('Renamed Passkey')).toBeVisible(); + await expect(page.getByText('Renamed Passkey', { exact: true })).toBeVisible(); }); test('Delete passkey from account', async ({ page }) => { await page.goto('/settings/account'); - await page.getByLabel('Delete').first().click(); + await page + .getByRole('button', { name: /^Actions for / }) + .first() + .click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByLabel('Delete Passkey').getByRole('button', { name: 'Delete' }).click(); await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully'); diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index b9b261dc..193d9c54 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; import { cleanupBackend } from '../utils/cleanup.util'; -import { saveUnsavedChanges } from '../utils/unsaved-changes.util'; +import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util'; test.beforeEach(async ({ page }) => { await cleanupBackend(); @@ -116,13 +116,16 @@ test('Invalid hidden configuration prevents every section from being saved', asy 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( + unsavedChangesBar(page).getByText('Couldn’t save. Check the highlighted fields.') + ).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 expect(smtpPort).toHaveAttribute('aria-invalid', 'false'); await saveUnsavedChanges(page); await page.reload(); @@ -300,7 +303,7 @@ test('Save LDAP configuration while LDAP remains disabled', async ({ page }) => 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 expect(unsavedChangesBar(page)).toContainText(/\d+ unsaved changes?/); await page.getByRole('button', { name: 'Discard', exact: true }).click(); await expect(softDeleteUsers).toBeChecked({ checked: originalValue }); @@ -326,7 +329,7 @@ test.describe('Update application images', () => { 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 expect(unsavedChangesBar(page)).toContainText(/\d+ unsaved changes?/); await page.getByRole('button', { name: 'Discard', exact: true }).click(); await expect( @@ -415,6 +418,8 @@ test.describe('Update application images', () => { await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg'); await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(page.getByText('File must be of type PNG or JPEG', { exact: true })).toBeVisible(); + await expect( + unsavedChangesBar(page).getByText('File must be of type PNG or JPEG', { exact: true }) + ).toBeVisible(); }); }); diff --git a/tests/specs/device-login.spec.ts b/tests/specs/device-login.spec.ts index 5a9a8c20..0005d54a 100644 --- a/tests/specs/device-login.spec.ts +++ b/tests/specs/device-login.spec.ts @@ -14,7 +14,11 @@ test('approves the QR link after requester review and fresh reauthentication', a const expectedLoginCodeText = `${waiting.request.userCode.substring(0, 4)} - ${waiting.request.userCode.substring(4, 8)}`; try { await (await passkeyUtil.init(page)).addPasskey(); - await expect(waiting.page.getByTestId('device-login-code')).toHaveText(expectedLoginCodeText); + await expect(waiting.page.getByTestId('device-login-code')).toHaveText( + new RegExp( + `${waiting.request.userCode.substring(0, 4)}\\s*–\\s*${waiting.request.userCode.substring(4, 8)}` + ) + ); await expect(waiting.page.getByLabel('Device login QR code')).toBeVisible(); await page.goto(waiting.request.verificationUriComplete); diff --git a/tests/specs/navigation.spec.ts b/tests/specs/navigation.spec.ts index 0314fd37..61e359af 100644 --- a/tests/specs/navigation.spec.ts +++ b/tests/specs/navigation.spec.ts @@ -1,5 +1,6 @@ import test, { expect } from '@playwright/test'; import { cleanupBackend } from '../utils/cleanup.util'; +import { unsavedChangesBar } from '../utils/unsaved-changes.util'; test.beforeEach(async () => await cleanupBackend()); @@ -27,7 +28,7 @@ test('unsaved changes block navigation until they are discarded', async ({ page 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(); + await expect(unsavedChangesBar(page)).toContainText(/\d+ unsaved changes?/); const myApps = page.getByRole('link', { name: 'My Apps' }).first(); await myApps.click(); @@ -35,9 +36,9 @@ test('unsaved changes block navigation until they are discarded', async ({ page 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 expect(unsavedChangesBar(page)).toHaveCount(0); await myApps.click(); await expect(page).toHaveURL(/\/settings\/apps$/); - await expect(page.getByText('You have unsaved changes', { exact: true })).toHaveCount(0); + await expect(unsavedChangesBar(page)).toHaveCount(0); }); diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index 0e15052c..0dc204e5 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -3,7 +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'; +import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util'; test.beforeEach(async () => await cleanupBackend()); @@ -556,7 +556,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.getByText(/private key material/)).toBeVisible(); + await expect(unsavedChangesBar(page).getByText(/private key material/)).toBeVisible(); await publicKeys.last().getByRole('button').click(); await expect(publicKeys).toHaveCount(3); @@ -568,7 +568,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.getByText(/missing the "kid" property/)).toBeVisible(); + await expect(unsavedChangesBar(page).getByText(/missing the "kid" property/)).toBeVisible(); await publicKeys.last().getByRole('button').click(); await expect(publicKeys).toHaveCount(3); @@ -703,7 +703,7 @@ test('Delete OIDC client', async ({ page }) => { await page .getByRole('row', { name: oidcClient.name }) - .getByRole('button', { name: 'Toggle menu' }) + .getByRole('button', { name: /^Actions for / }) .click(); await page.getByRole('menuitem', { name: 'Delete' }).click(); @@ -760,9 +760,11 @@ test('Filter OIDC clients by PAR requirement', async ({ page, request }) => { await expect(page.getByRole('row', { name: parClient.name })).toBeVisible(); await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).not.toBeVisible(); - // Deselect "Yes" and select "No" to invert the filter - await page.getByTestId('facet-par-option-true').click(); + // Reset the filter to "Any", then select "No" to invert it + await page.getByTestId('facet-par-trigger').click(); + await page.getByTestId('facet-par-option-any').click(); await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).toBeVisible(); + await page.getByTestId('facet-par-trigger').click(); await page.getByTestId('facet-par-option-false').click(); // PAR client should be hidden, others visible diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index 59280068..54331604 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -96,7 +96,7 @@ test('Authorize new client', async ({ page }) => { const urlParams = createUrlParams(oidcClient); await page.goto(`/authorize?${urlParams.toString()}`); - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() @@ -111,7 +111,7 @@ test('Authorize client requesting offline_access scope', async ({ page }) => { // offline_access is a valid OIDC scope: the flow must reach the consent screen rather than // being rejected with invalid_scope (offline_access itself has no displayable scope item) - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() @@ -125,10 +125,10 @@ test('Authorize new client with a deselected scope', async ({ page }) => { const urlParams = createUrlParams(oidcClient); await page.goto(`/authorize?${urlParams.toString()}`); - await expectScopes(page, ['Email', 'Profile']); - await page.getByRole('checkbox', { name: 'Email' }).click(); - await expect(page.getByRole('checkbox', { name: 'Email' })).not.toBeChecked(); - await expect(page.getByRole('checkbox', { name: 'Profile' })).toBeChecked(); + await expectScopes(page, ['Email address', 'Name, username and picture']); + await page.getByRole('checkbox', { name: 'Email address' }).click(); + await expect(page.getByRole('checkbox', { name: 'Email address' })).not.toBeChecked(); + await expect(page.getByRole('checkbox', { name: 'Name, username and picture' })).toBeChecked(); const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() @@ -199,7 +199,7 @@ test('Authorize new client while not signed in', async ({ page }) => { await (await passkeyUtil.init(page)).addPasskey(); await page.getByRole('button', { name: 'Sign in' }).click(); - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() @@ -216,7 +216,7 @@ test('Authorize new client shows Pocket ID error when user group not allowed', a await (await passkeyUtil.init(page)).addPasskey('craig'); await page.getByRole('button', { name: 'Sign in' }).click(); - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); await page.getByRole('button', { name: 'Sign in' }).click(); @@ -701,7 +701,7 @@ test('Authorize new client with device authorization flow', async ({ page }) => await page.goto(`/device?user_code=${userCode}`); await page.getByRole('button', { name: 'Authorize' }).click(); - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); await page.getByRole('button', { name: 'Authorize' }).click(); @@ -724,10 +724,10 @@ test('Authorize new client with device authorization flow and a deselected scope await page.goto(`/device?user_code=${device.user_code}`); await page.getByRole('button', { name: 'Authorize' }).click(); - await expectScopes(page, ['Email', 'Profile']); - await page.getByRole('checkbox', { name: 'Email' }).click(); - await expect(page.getByRole('checkbox', { name: 'Email' })).not.toBeChecked(); - await expect(page.getByRole('checkbox', { name: 'Profile' })).toBeChecked(); + await expectScopes(page, ['Email address', 'Name, username and picture']); + await page.getByRole('checkbox', { name: 'Email address' }).click(); + await expect(page.getByRole('checkbox', { name: 'Email address' })).not.toBeChecked(); + await expect(page.getByRole('checkbox', { name: 'Name, username and picture' })).toBeChecked(); await page.getByRole('button', { name: 'Authorize' }).click(); await expect( @@ -763,7 +763,7 @@ test('Authorize new client with device authorization flow while not signed in', await (await passkeyUtil.init(page)).addPasskey(); await page.getByRole('button', { name: 'Authorize' }).click(); - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); await page.getByRole('button', { name: 'Authorize' }).click(); @@ -834,7 +834,7 @@ for (const scenario of [ } else { await page.getByRole('button', { name: 'Authorize', exact: true }).click(); if (scenario.consentRequired) { - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); await page.getByRole('button', { name: 'Authorize', exact: true }).click(); } await expect( @@ -962,7 +962,7 @@ test('Authorize new client with device authorization with user group not allowed await (await passkeyUtil.init(page)).addPasskey('craig'); await page.getByRole('button', { name: 'Authorize' }).click(); - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); await page.getByRole('button', { name: 'Authorize' }).click(); @@ -1241,7 +1241,7 @@ test.describe('OIDC prompt parameter', () => { await page.goto(`/authorize?${urlParams.toString()}`); // Should show consent UI even though client was already authorized - await expectScopes(page, ['Profile', 'Email']); + await expectScopes(page, ['Name, username and picture', 'Email address']); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() @@ -1532,7 +1532,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => { await page.goto(`/authorize?${urlParams.toString()}`); // Consent screen with the requested scope (resolved from the PAR) must be shown - await expectScopes(page, ['Groups']); + await expectScopes(page, ['Group membership']); // Confirming proceeds with the authorization await expectCallbackRedirect(page, client.callbackUrl, () => @@ -1685,7 +1685,7 @@ test.describe('OIDC skip consent', () => { await page.goto(`/authorize?${urlParams.toString()}`); // prompt=consent overrides the client's skip-consent setting, so the scopes must still be shown - await expectScopes(page, ['Email', 'Profile']); + await expectScopes(page, ['Email address', 'Name, username and picture']); const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() diff --git a/tests/specs/one-time-access-token.spec.ts b/tests/specs/one-time-access-token.spec.ts index ed0796f2..348db690 100644 --- a/tests/specs/one-time-access-token.spec.ts +++ b/tests/specs/one-time-access-token.spec.ts @@ -23,7 +23,11 @@ test('Sign in with login code without a passkey shows passkey setup', async ({ p await (await passkeyUtil.init(page)).addPasskey(); await page.getByRole('button', { name: 'Authenticate' }).click(); await page.waitForURL('/settings/account'); - await page.getByLabel('Delete').first().click(); + await page + .getByRole('button', { name: /^Actions for / }) + .first() + .click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByLabel('Delete Passkey').getByRole('button', { name: 'Delete' }).click(); await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully'); diff --git a/tests/specs/scim.spec.ts b/tests/specs/scim.spec.ts index 3904d138..eacaf480 100644 --- a/tests/specs/scim.spec.ts +++ b/tests/specs/scim.spec.ts @@ -1,7 +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'; +import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util'; async function configureOidcClient(page: Page) { await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`); @@ -36,14 +36,14 @@ 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 expect(unsavedChangesBar(page)).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 expect(unsavedChangesBar(page)).toContainText(/\d+ unsaved changes?/); await page.getByRole('button', { name: 'Discard', exact: true }).click(); await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api'); diff --git a/tests/specs/user-settings.spec.ts b/tests/specs/user-settings.spec.ts index 64bbd1cf..4a1d1cef 100644 --- a/tests/specs/user-settings.spec.ts +++ b/tests/specs/user-settings.spec.ts @@ -2,7 +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'; +import { saveUnsavedChanges, unsavedChangesBar } from '../utils/unsaved-changes.util'; test.beforeEach(async () => await cleanupBackend()); @@ -177,7 +177,9 @@ test('Update user fails with already taken email', async ({ page }) => { await page.getByLabel('Email').fill(users.tim.email); await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(page.getByText('Email is already in use', { exact: true })).toBeVisible(); + await expect( + unsavedChangesBar(page).getByText('Email is already in use', { exact: true }) + ).toBeVisible(); }); test('Update user fails with already taken username', async ({ page }) => { @@ -194,7 +196,9 @@ test('Update user fails with already taken username', async ({ page }) => { await page.getByLabel('Username').fill(users.tim.username); await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible(); + await expect( + unsavedChangesBar(page).getByText('Username is already in use', { exact: true }) + ).toBeVisible(); }); test('Update user fails with already taken username in different casing', async ({ page }) => { @@ -211,7 +215,9 @@ test('Update user fails with already taken username in different casing', async await page.getByLabel('Username').fill(users.tim.username.toUpperCase()); await page.getByRole('button', { name: 'Save', exact: true }).click(); - await expect(page.getByText('Username is already in use', { exact: true })).toBeVisible(); + await expect( + unsavedChangesBar(page).getByText('Username is already in use', { exact: true }) + ).toBeVisible(); }); test('Update user custom claims', async ({ page }) => { @@ -275,7 +281,7 @@ test('Admin can view another user passkeys', async ({ page }) => { await page.goto(`/settings/admin/users/${users.craig.id}`); await page.getByRole('tab', { name: 'Passkeys' }).click(); - await expect(page.getByText('Passkey 2')).toBeVisible(); + await expect(page.getByText('Passkey 2', { exact: true })).toBeVisible(); await expect(page.getByText(/Added on/)).toBeVisible(); }); @@ -283,15 +289,11 @@ test('Admin can delete another user passkey and audit log is created', async ({ await page.goto(`/settings/admin/users/${users.craig.id}`); await page.getByRole('tab', { name: 'Passkeys' }).click(); - await page - .locator('[data-slot="item"]') - .filter({ hasText: 'Passkey 2' }) - .getByLabel('Delete') - .click(); + await page.getByRole('button', { name: 'Delete Passkey 2' }).click(); await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click(); await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully'); - await expect(page.getByText('Passkey 2')).not.toBeVisible(); + await expect(page.getByText('Passkey 2', { exact: true })).not.toBeVisible(); const auditLogResponse = await page.request.get('/api/audit-logs/all'); expect(auditLogResponse.ok()).toBeTruthy(); diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts index ecd95bd1..54154c92 100644 --- a/tests/specs/user-signup.spec.ts +++ b/tests/specs/user-signup.spec.ts @@ -52,9 +52,9 @@ test.describe('Signup Token Creation', () => { await page.getByRole('button', { name: 'Create options' }).click(); await page.getByRole('menuitem', { name: 'View Active Signup Tokens' }).click(); - await page.getByLabel('Manage Signup Tokens').getByRole('button', { name: 'View' }).click(); + await page.getByLabel('Manage Signup Tokens').getByRole('button', { name: 'Display' }).click(); - await page.getByRole('menuitemcheckbox', { name: 'User Groups' }).click(); + await page.getByRole('button', { name: 'User Groups', exact: true }).click(); const row = page.getByRole('row').last(); await expect(row.getByRole('cell', { name: '0 of 8' })).toBeVisible(); diff --git a/tests/utils/unsaved-changes.util.ts b/tests/utils/unsaved-changes.util.ts index 1d5eff4f..217dfb90 100644 --- a/tests/utils/unsaved-changes.util.ts +++ b/tests/utils/unsaved-changes.util.ts @@ -1,6 +1,10 @@ import { expect, type Page } from '@playwright/test'; +export function unsavedChangesBar(page: Page) { + return page.getByRole('region', { name: 'Unsaved changes' }); +} + 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(); + await expect(unsavedChangesBar(page).getByText('Changes saved', { exact: true })).toBeVisible(); }