feat(frontend): add v3 compositions for pages, settings, tables and identity

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent 82df10f80b
commit 42c81c4687
71 changed files with 2066 additions and 1403 deletions
+54 -15
View File
@@ -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"
}
+16 -44
View File
@@ -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) {
@@ -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<T>[];
label?: string;
label: string;
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
@@ -33,7 +32,10 @@
<span class="sr-only">{label}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#each visibleActions as action (action.label)}
{#each visibleActions as action, i (action.label)}
{#if action.variant === 'danger' && i > 0 && visibleActions[i - 1].variant !== 'danger'}
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item
onclick={() => action.onClick(item)}
disabled={action.disabled}
@@ -41,7 +43,7 @@
>
{#if action.icon}
{@const Icon = action.icon}
<Icon class="mr-2 size-4" />
<Icon />
{/if}
{action.label}
</DropdownMenu.Item>
@@ -0,0 +1,50 @@
<script lang="ts">
import { Badge } from '#lib/components/ui/badge/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { LucideChevronDown } from '@lucide/svelte';
import { Collapsible } from 'bits-ui';
import { untrack, type Snippet } from 'svelte';
let {
summary,
changedCount = 0,
children
}: {
summary: string;
changedCount?: number;
children: Snippet;
} = $props();
const titleId = $props.id();
// Only untouched defaults may hide, so a changed setting opens the disclosure on load
let open = $state(untrack(() => changedCount > 0));
</script>
<Collapsible.Root bind:open>
{#snippet child({ props })}
<section {...props} aria-labelledby={titleId} class="flex flex-col gap-10">
<Collapsible.Trigger
class="group/advanced bg-fill-muted text-control text-fg before:bg-fill-hover focus-visible:outline-ring relative isolate flex min-h-14 w-full items-center gap-4 rounded-lg px-4 py-3 text-start before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:opacity-0 before:transition-opacity before:duration-(--dur-fast) before:ease-out hover:before:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 max-sm:flex-wrap"
>
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span id={titleId} class="text-title">{m.advanced()}</span>
<span class="text-fg-muted">{summary}</span>
</span>
{#if changedCount > 0}
<span class="max-sm:order-last max-sm:basis-full">
<Badge>{m.count_changed({ count: changedCount })}</Badge>
</span>
{/if}
<LucideChevronDown
class="text-fg-muted size-4 shrink-0 transition-transform duration-(--dur-base) ease-in-out group-data-[state=open]/advanced:rotate-180"
/>
</Collapsible.Trigger>
<Collapsible.Content
class="*:transition-motion flex flex-col gap-10 *:duration-(--dur-base) data-starting-style:*:-translate-y-(--rise) data-starting-style:*:opacity-0"
>
{@render children()}
</Collapsible.Content>
</section>
{/snippet}
</Collapsible.Root>
+346 -86
View File
@@ -1,12 +1,24 @@
<script lang="ts">
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
import CopyButton from '#lib/components/copy-button.svelte';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import type { AnyFormattedMessage, AnyMessage } from '#lib/components/formatted-message.ts';
import ListPagination from '#lib/components/list-pagination.svelte';
import * as Avatar from '#lib/components/ui/avatar/index.ts';
import { Badge } from '#lib/components/ui/badge/index.ts';
import { Button } from '#lib/components/ui/button/index.ts';
import { Skeleton } from '#lib/components/ui/skeleton/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { getLocale } from '#lib/paraglide/runtime.js';
import AuditLogService from '#lib/services/audit-log-service.ts';
import type { AdvancedTableColumn } from '#lib/types/advanced-table.type.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { AuditLog, AuditLogFilter } from '#lib/types/audit-log.type.ts';
import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts';
import { translateAuditLogEvent } from '#lib/utils/audit-log-translator.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import { cn } from '#lib/utils/style.ts';
import { LucideChevronDown, LucideMonitor, LucideSmartphone } from '@lucide/svelte';
import { untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
let {
isAdmin = false,
@@ -17,106 +29,354 @@
} = $props();
const auditLogService = new AuditLogService();
let tableRef: AdvancedTable<AuditLog>;
const columns: AdvancedTableColumn<AuditLog>[] = [
{
label: m.time(),
column: 'createdAt',
sortable: true,
value: (item) => new Date(item.createdAt).toLocaleString()
},
{
label: m.username(),
column: 'username',
hidden: !isAdmin,
value: (item) => item.username ?? m.unknown()
},
{
label: m.actor(),
key: 'actorUsername',
hidden: !isAdmin,
value: (item) => item.actorUsername ?? m.unknown()
},
{
label: m.event(),
column: 'event',
sortable: true,
cell: EventCell
},
{
label: m.approximate_location(),
key: 'location',
value: (item) => formatLocation(item)
},
{
label: m.ip_address(),
column: 'ipAddress',
sortable: true
},
{
label: m.device(),
column: 'device',
sortable: true
},
{
label: m.client(),
key: 'client',
value: (item) => item.data?.clientName
}
];
let logs: Paginated<AuditLog> | undefined = $state();
let isRefreshing = $state(false);
const expanded = new SvelteSet<string>();
const requestOptions: ListRequestOptions = {
sort: { column: 'createdAt', direction: 'desc' },
pagination: { page: 1, limit: 20 }
};
// A filter change starts again on the first page, since the old page may no longer exist
$effect(() => {
if (filters) {
void filters.userID;
void filters.event;
void filters.location;
void filters.clientName;
untrack(() => tableRef?.refresh());
}
void filters?.userID;
void filters?.event;
void filters?.location;
void filters?.clientName;
untrack(() => {
requestOptions.pagination!.page = 1;
refresh();
});
});
export async function refresh() {
await tableRef.refresh();
}
function formatLocation(log: AuditLog) {
if (log.city && log.country) {
return `${log.city}, ${log.country}`;
} else if (log.country) {
return log.country;
} else {
return m.unknown();
isRefreshing = true;
try {
logs = isAdmin
? await auditLogService.listAllLogs({ ...requestOptions, filters: wrapFilters(filters) })
: await auditLogService.list(requestOptions);
} finally {
isRefreshing = false;
}
}
function wrapFilters(filters?: Record<string, string>) {
function wrapFilters(filters?: AuditLogFilter) {
if (!filters) return undefined;
return Object.fromEntries(
Object.entries(filters)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.filter(([, value]) => value)
.map(([key, value]) => [key, [value]])
);
}
const days = $derived.by(() => {
const groups: { label: string; logs: AuditLog[] }[] = [];
for (const log of logs?.data ?? []) {
const label = dayLabel(new Date(log.createdAt));
if (groups.at(-1)?.label === label) groups.at(-1)!.logs.push(log);
else groups.push({ label, logs: [log] });
}
return groups;
});
function dayLabel(date: Date) {
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000);
if (daysAgo < 2) {
const relative = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' }).format(
-daysAgo,
'day'
);
return relative.charAt(0).toUpperCase() + relative.slice(1);
}
return date.toLocaleDateString(getLocale(), {
weekday: 'short',
day: 'numeric',
month: 'short',
year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric'
});
}
// Events about an app or a passkey name it in the sentence
const objectSentences: Record<string, AnyMessage | undefined> = {
CLIENT_AUTHORIZATION: m.audit_sentence_signed_in_to,
NEW_CLIENT_AUTHORIZATION: m.audit_sentence_allowed_app,
DEVICE_CODE_AUTHORIZATION: m.audit_sentence_signed_in_on_device,
NEW_DEVICE_CODE_AUTHORIZATION: m.audit_sentence_allowed_on_device,
PASSKEY_ADDED: m.audit_sentence_added_passkey,
PASSKEY_REMOVED: m.audit_sentence_removed_passkey
};
function describe(log: AuditLog): { sentence: AnyFormattedMessage | string; via?: string } {
const signedIn = {
message: m.audit_sentence_signed_in_to,
inputs: { name: $appConfigStore.appName }
};
switch (log.event) {
case 'SIGN_IN':
return { sentence: signedIn, via: m.audit_via_passkey() };
case 'TOKEN_SIGN_IN':
return { sentence: signedIn, via: m.audit_via_login_code() };
case 'REMOTE_SIGN_IN':
return { sentence: signedIn, via: m.audit_via_another_device() };
case 'ACCOUNT_CREATED':
return { sentence: m.audit_sentence_account_created() };
}
const message = objectSentences[log.event];
const name = log.data?.clientName || log.data?.passkeyName;
if (message && name) return { sentence: { message, inputs: { name } } };
return { sentence: translateAuditLogEvent(log.event) };
}
function formatLocation(log: AuditLog) {
return [log.city, log.country].filter(Boolean).join(', ') || m.unknown();
}
const isPhone = (device: string) => /android|iphone|ipad|ios|mobile/i.test(device);
const formatTime = (log: AuditLog) =>
new Date(log.createdAt).toLocaleTimeString(getLocale(), { timeStyle: 'short' });
const formatStamp = (log: AuditLog) =>
new Date(log.createdAt).toLocaleString(getLocale(), {
dateStyle: 'medium',
timeStyle: 'medium'
});
function toggle(id: string) {
if (expanded.has(id)) expanded.delete(id);
else expanded.add(id);
}
const columnsClass = $derived(
isAdmin
? 'sm:grid-cols-[minmax(0,1fr)_minmax(0,150px)_64px_16px] lg:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,160px)_64px_16px] xl:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,200px)_minmax(0,170px)_64px_16px]'
: 'sm:grid-cols-[minmax(0,1fr)_minmax(0,180px)_64px_16px] xl:grid-cols-[minmax(0,1fr)_minmax(0,200px)_minmax(0,170px)_64px_16px]'
);
</script>
{#snippet EventCell({ item }: { item: AuditLog })}
<Badge class="rounded-full" variant="outline">
{translateAuditLogEvent(item.event)}
</Badge>
{#snippet sentenceText(sentence: AnyFormattedMessage | string)}
{#if typeof sentence === 'string'}
{sentence}
{:else}
<FormattedMessage message={sentence.message} inputs={sentence.inputs} />
{/if}
{/snippet}
<AdvancedTable
id="audit-log-list-{isAdmin ? 'admin' : 'user'}"
bind:this={tableRef}
fetchCallback={async (options) =>
isAdmin
? await auditLogService.listAllLogs({
...options,
filters: wrapFilters(filters)
})
: await auditLogService.list(options)}
defaultSort={{ column: 'createdAt', direction: 'desc' }}
withoutSearch
{columns}
/>
<div>
<div
class={cn(
'transition-opacity duration-(--dur-base)',
isRefreshing && logs && 'pointer-events-none opacity-50 delay-(--t-dim-delay)'
)}
aria-busy={isRefreshing}
>
<div
class={cn(
'bg-surface-sunken text-caption text-fg-muted hidden h-10 items-center gap-4 rounded-sm px-4 font-medium sm:grid',
columnsClass
)}
>
{#if isAdmin}<span class="hidden lg:block">{m.username()}</span>{/if}
<span>{m.event()}</span>
<span class="hidden xl:block">{m.device()}</span>
<span>{m.ip_address()}</span>
<span class="text-right">{m.time()}</span>
<span></span>
</div>
{#if !logs}
{#each Array.from({ length: 8 }, (_, i) => i) as i (i)}
<div
class="flex h-(--row-2line) items-center gap-4 border-b px-4 transition-opacity delay-(--t-skeleton-delay) starting:opacity-0"
aria-hidden="true"
>
<Skeleton class="h-3.5 w-1/3" />
<Skeleton class="ml-auto h-3.5 w-12" />
</div>
{/each}
{/if}
{#each days as day (day.label)}
<h2 class="text-caption text-fg-muted flex h-9 items-center border-b px-4 font-medium">
{day.label}
</h2>
<ul>
{#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}
<li class="border-b">
<button
type="button"
aria-expanded={open}
aria-controls={`audit-detail-${log.id}`}
class={cn(
'hover:bg-fill-row-hover focus-visible:bg-fill-row-hover focus-visible:outline-ring grid min-h-(--row-2line) w-full grid-cols-[minmax(0,1fr)_16px] items-center gap-x-4 px-4 py-2 text-start transition-colors duration-(--dur-fast) focus-visible:outline-2 focus-visible:-outline-offset-2',
columnsClass
)}
onclick={() => toggle(log.id)}
>
{#if isAdmin}
<span class="hidden min-w-0 items-center gap-2 lg:flex">
<Avatar.Root class="size-6">
<Avatar.Image src={cachedProfilePicture.getUrl(log.userID)} alt="" />
<Avatar.Fallback>{log.username?.[0]?.toUpperCase()}</Avatar.Fallback>
</Avatar.Root>
<span class="text-control truncate font-medium"
>{log.username || m.unknown()}</span
>
</span>
{/if}
<span class="flex min-w-0 flex-col gap-0.5">
<span class="text-body [&_b]:font-medium">
{#if isAdmin}
<span class="text-control font-medium lg:hidden"
>{log.username || m.unknown()} ·</span
>
{/if}
{@render sentenceText(sentence)}
{#if by}
<span class="text-control text-fg-muted"
>· {m.audit_by_actor({ name: by })}</span
>
{/if}
</span>
<span class="text-caption text-fg-muted flex min-w-0 items-center gap-x-1.5">
{#if isAdmin}
<Badge variant="mono">{log.event}</Badge>
{/if}
{#if via}<span class="shrink-0 max-sm:hidden">{via}</span>{/if}
<!-- Below xl the Device column folds into this line so the sentence keeps its width -->
{#if via || isAdmin}
<span aria-hidden="true" class="hidden sm:inline xl:hidden">·</span>
{/if}
<span class="hidden min-w-0 truncate sm:inline xl:hidden">{log.device}</span>
<span class="truncate tabular-nums sm:hidden">
{formatTime(log)} · {log.device} · {formatLocation(log)}
</span>
</span>
</span>
<span class="text-control hidden min-w-0 items-center gap-1.5 xl:flex">
<DeviceIcon class="text-fg-muted size-4 shrink-0" />
<span class="truncate">{log.device}</span>
</span>
<span class="hidden min-w-0 flex-col sm:flex">
<span class="text-control truncate">{log.ipAddress}</span>
<span class="text-caption text-fg-muted truncate">{formatLocation(log)}</span>
</span>
<time
class="text-control hidden text-right tabular-nums sm:block"
datetime={log.createdAt}
title={formatStamp(log)}
>
{formatTime(log)}
</time>
<LucideChevronDown
class={cn(
'text-fg-muted size-3 justify-self-center transition-transform duration-(--dur-base)',
open && 'rotate-180'
)}
/>
</button>
{#if open}
<div
id={`audit-detail-${log.id}`}
class="transition-motion px-4 pt-2 pb-5 duration-(--dur-base) starting:-translate-y-1 starting:opacity-0"
>
<dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2">
<div class="flex min-w-0 flex-col gap-0.5">
<dt class="text-caption text-fg-muted">{m.event()}</dt>
<dd class="text-control flex flex-wrap items-center gap-1.5 [&_b]:font-medium">
<span>{@render sentenceText(sentence)}</span>
<Badge variant="mono">{log.event}</Badge>
</dd>
</div>
{@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())}
</dl>
<details class="group/raw mt-4">
<summary
class="text-control text-fg-muted hover:text-fg inline-flex cursor-pointer list-none items-center gap-1 rounded-xs"
>
<LucideChevronDown
class="size-3 -rotate-90 transition-transform duration-(--dur-base) group-open/raw:rotate-0"
/>
{m.raw_data()}
</summary>
<div class="bg-surface-sunken relative mt-2 rounded-md border p-4">
<pre
class="text-mono-s font-mono wrap-anywhere whitespace-pre-wrap max-sm:pt-10 sm:pr-28">{JSON.stringify(
log,
null,
2
)}</pre>
<CopyButton
labeled
value={JSON.stringify(log, null, 2)}
label={m.copy_json()}
class="absolute top-3 right-3"
/>
</div>
</details>
</div>
{/if}
</li>
{/each}
</ul>
{/each}
{#if logs?.pagination.totalItems === 0}
<p
class="text-control text-fg-muted flex min-h-(--row-2line) flex-wrap items-center gap-x-3 border-b px-4"
>
{m.no_events_match_filters()}
{#if onClearFilters}
<Button variant="link" onclick={onClearFilters}>{m.clear_filters()}</Button>
{/if}
</p>
{/if}
</div>
<ListPagination
pagination={logs?.pagination.totalItems ? logs.pagination : undefined}
hideWhenSinglePage
onPageChange={(page) => {
requestOptions.pagination!.page = page;
refresh();
}}
onPageSizeChange={(limit) => {
requestOptions.pagination = { page: 1, limit };
refresh();
}}
/>
</div>
{#snippet detailItem(label: string, value: string, copyLabel?: string)}
<div class="flex min-w-0 flex-col gap-0.5">
<dt class="text-caption text-fg-muted">{label}</dt>
<dd class="text-control flex min-w-0 items-center gap-1.5 wrap-anywhere">
{#if copyLabel}
<span class="font-mono">{value}</span>
<CopyButton {value} label={copyLabel} />
{:else}
{value}
{/if}
</dd>
</div>
{/snippet}
@@ -1,12 +1,40 @@
<script lang="ts">
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { LucideTrash2 } from '@lucide/svelte';
import { confirmDialogStore } from '.';
import FormattedMessage from '../formatted-message.svelte';
import Button from '../ui/button/button.svelte';
let busy = $state(false);
let cancelButton = $state<HTMLButtonElement | null>(null);
const confirm = $derived($confirmDialogStore.confirm);
const Icon = $derived(confirm.icon ?? LucideTrash2);
// The dialog stays open while an async action runs, so the confirm button can show its progress
async function onConfirm() {
busy = true;
try {
await confirm.action();
} finally {
busy = false;
$confirmDialogStore.open = false;
}
}
</script>
<AlertDialog.Root bind:open={$confirmDialogStore.open}>
<AlertDialog.Content class="z-9999">
<AlertDialog.Root
bind:open={() => $confirmDialogStore.open, (open) => !busy && ($confirmDialogStore.open = open)}
>
<AlertDialog.Content
class="z-9999"
onOpenAutoFocus={(e) => {
// Cancel takes focus, so Enter never confirms unless the confirm button was focused on purpose
e.preventDefault();
cancelButton?.focus();
}}
>
<AlertDialog.Header>
<AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title>
<AlertDialog.Description>
@@ -18,20 +46,17 @@
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action>
{#snippet child()}
<Button
variant={$confirmDialogStore.confirm.destructive ? 'destructive' : 'default'}
onclick={() => {
$confirmDialogStore.confirm.action();
$confirmDialogStore.open = false;
}}
>
{$confirmDialogStore.confirm.label}
</Button>
{/snippet}
</AlertDialog.Action>
<AlertDialog.Cancel bind:ref={cancelButton} disabled={busy}>{m.cancel()}</AlertDialog.Cancel>
<!-- The label repeats the name, so it truncates and leaves room for Cancel while the title shows the name in full -->
<Button
variant={confirm.destructive ? 'destructive' : 'default'}
class="min-w-0 shrink"
isLoading={busy}
onclick={onConfirm}
>
{#if confirm.destructive}<Icon class="size-3.5" />{/if}
<span class="truncate">{confirm.label}</span>
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -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<TMessage extends AnyMessage = AnyMessage>({
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 };
@@ -0,0 +1,88 @@
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.ts';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import { LucideCheck, LucideCopy } from '@lucide/svelte';
let {
value,
label = m.copy(),
labeled = false,
class: className
}: {
value: string;
// The accessible name, such as "Copy client ID", which stays the same while the button shows "Copied"
label?: string;
// A secondary button that reads "Copy" instead of an icon button with a tooltip
labeled?: boolean;
class?: string;
} = $props();
let copied = $state(false);
let revertTimer: ReturnType<typeof setTimeout> | undefined;
async function copy() {
await navigator.clipboard.writeText(value);
copied = true;
clearTimeout(revertTimer);
revertTimer = setTimeout(() => (copied = false), 1500);
}
const markClass =
'col-start-1 row-start-1 transition-[opacity,scale] duration-(--dur-fast) ease-out';
</script>
{#snippet icon()}
<span class="grid" aria-hidden="true">
<LucideCopy class={cn(markClass, copied && 'scale-60 opacity-0')} />
<LucideCheck
class={cn(markClass, !copied && 'scale-60 opacity-0', copied && 'duration-(--dur-base)')}
/>
</span>
{/snippet}
{#if labeled}
<!-- The label cell holds both words, so the button keeps its width when it reads "Copied" -->
<Button
variant="secondary"
size="sm"
class={className}
aria-label={label === m.copy() ? undefined : label}
onclick={copy}
>
{@render icon()}
<span class="grid">
<span class={cn('col-start-1 row-start-1 transition-opacity', copied && 'opacity-0')}>
{m.copy()}
</span>
<span
class={cn('col-start-1 row-start-1 transition-opacity', !copied && 'opacity-0')}
aria-hidden="true"
>
{m.copied()}
</span>
</span>
</Button>
{:else}
<Tooltip.Provider>
<Tooltip.Root disableCloseOnTriggerClick>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon-xs"
class={className}
aria-label={label}
onclick={copy}
>
{@render icon()}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>{copied ? m.copied() : label}</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
<span class="sr-only" aria-live="polite">{copied ? m.copied() : ''}</span>
@@ -0,0 +1,36 @@
<script lang="ts">
import CopyButton from '#lib/components/copy-button.svelte';
let {
label,
value,
copyLabel,
testId
}: {
label: string;
value: string;
// The copy button's accessible name, such as "Copy API key"
copyLabel: string;
testId?: string;
} = $props();
const id = $props.id();
</script>
<!-- One-time secrets and links are shown in full and wrap, because a truncated value cannot be checked before it is copied -->
<div class="flex w-full min-w-0 flex-col gap-1.5">
<span {id} class="text-control font-semibold">{label}</span>
<div class="flex min-w-0 items-stretch">
<div
role="textbox"
aria-readonly="true"
aria-labelledby={id}
tabindex="0"
data-testid={testId}
class="bg-surface-sunken text-mono focus-visible:outline-ring min-h-(--h-sm) min-w-0 flex-1 rounded-l-sm border border-r-0 px-3 py-1.5 font-mono break-all focus-visible:outline-2 focus-visible:-outline-offset-2"
>
{value}
</div>
<CopyButton labeled {value} label={copyLabel} class="h-auto rounded-l-none" />
</div>
</div>
@@ -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);
}
</script>
<Tooltip.Provider>
<Tooltip.Root {onOpenChange} {open} disableCloseOnTriggerClick>
<Tooltip.Trigger class="text-start" onclick={onClick}>{@render children()}</Tooltip.Trigger>
<Tooltip.Content onclick={copyToClipboard}>
<Tooltip.Trigger
class="hover:bg-fill-hover focus-visible:outline-ring -mx-1 cursor-copy rounded-xs px-1 text-start transition-colors duration-(--dur-fast) focus-visible:outline-2 focus-visible:outline-offset-2"
onclick={copyToClipboard}
>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Content>
{#if copied}
<span class="flex items-center"><LucideCheck class="mr-1 size-4" /> {m.copied()}</span>
<LucideCheck class="size-3.5" />
{m.copied()}
{:else}
<span>{m.click_to_copy()}</span>
{m.copy()}
{/if}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<span class="sr-only" aria-live="polite">{copied ? m.copied() : ''}</span>
@@ -1,6 +1,7 @@
<script lang="ts">
import Input from '#lib/components/ui/input/input.svelte';
import * as Popover from '#lib/components/ui/popover/index.js';
import { cn } from '#lib/utils/style.ts';
let {
value = $bindable(''),
@@ -81,7 +82,7 @@
<Popover.Root open={isOpen}>
<Popover.Trigger tabindex={-1} class="h-0 w-full" aria-hidden />
<Popover.Content
class="gap-0! px-1 py-2"
class="bg-popover/72 border-fg/6 dark:border-fg/10 gap-0! p-1 backdrop-blur-[40px] backdrop-saturate-150"
sameWidth
sideOffset={5}
trapFocus={false}
@@ -99,10 +100,10 @@
onkeydown={(e) => {
if (e.key === 'Enter') handleSuggestionClick(suggestion);
}}
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default items-center rounded-full py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
index
? 'bg-accent text-accent-foreground'
: ''}"
class={cn(
'text-mono hover:bg-fg/6 flex min-h-9 w-full cursor-default items-center rounded-sm px-2.5 font-mono select-none',
selectedIndex === index && 'bg-fg/6 outline-ring outline-2 -outline-offset-2'
)}
>
{suggestion}
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import { Calendar } from '#lib/components/ui/calendar/index.ts';
import { filledFieldClass } from '#lib/components/ui/input/index.ts';
import * as Popover from '#lib/components/ui/popover/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { getLocale } from '#lib/paraglide/runtime.js';
@@ -8,6 +9,7 @@
CalendarDate,
DateFormatter,
getLocalTimeZone,
today,
type DateValue
} from '@internationalized/date';
import CalendarIcon from '@lucide/svelte/icons/calendar';
@@ -70,12 +72,13 @@
<button
{...props}
class={cn(
'bg-input/50 flex h-9 w-full items-center justify-start rounded-full p-3 font-normal',
!value && 'text-muted-foreground'
filledFieldClass,
'text-body flex h-(--h-md) w-full items-center gap-2 rounded-sm px-3.5 max-sm:text-[16px]',
!value && 'text-fg-muted'
)}
aria-label={m.select_a_date()}
>
<CalendarIcon class="mr-2 size-4" />
<CalendarIcon class="text-fg-muted size-4 shrink-0" />
{calendarDisplayDate
? df.format(calendarDisplayDate.toDate(getLocalTimeZone()))
: m.select_a_date()}
@@ -83,9 +86,11 @@
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<!-- Every picked date is an expiry that takes effect at the start of that day, so today is already too late -->
<Calendar
type="single"
bind:value={() => calendarDisplayDate, (newValue) => handleCalendarInteraction(newValue)}
minValue={today(getLocalTimeZone()).add({ days: 1 })}
initialFocus
/>
</Popover.Content>
@@ -20,12 +20,14 @@
id,
label,
description,
input = $bindable()
input = $bindable(),
changed = false
}: {
id: string;
label: string;
description: string;
description?: string;
input: FormInput<number>;
changed?: boolean;
} = $props();
function preferredUnit(minutes: number): DurationUnit {
@@ -59,6 +61,7 @@
amount = (event.currentTarget as HTMLInputElement).value;
displayedMinutes = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
input.value = displayedMinutes;
input.error = null;
}
function updateUnit(value: string | undefined) {
@@ -83,11 +86,14 @@
</script>
<Field.Field>
<div>
<div class="flex items-center gap-2">
<Field.Label for={id}>{label}</Field.Label>
<Field.Description>{description}</Field.Description>
{#if changed}
<span class="bg-fg size-1.5 shrink-0 rounded-full"></span>
<span class="sr-only">{m.changed_from_the_default()}</span>
{/if}
</div>
<div>
<div class="flex flex-col gap-1.5">
<ButtonGroup.Root class="w-full">
<Input
{id}
@@ -118,6 +124,8 @@
</ButtonGroup.Root>
{#if input.error}
<Field.Error>{input.error}</Field.Error>
{:else if description}
<Field.Description>{description}</Field.Description>
{/if}
</div>
</Field.Field>
@@ -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<HTMLDivElement> &
(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, '-');
</script>
<Field.Field
data-disabled={disabled}
class={cn('flex flex-col justify-between', className)}
{...restProps}
>
<div>
{#if label}
<Field.Label required={input?.required} class="mb-0" for={labelFor ?? id}>{label}</Field.Label
>
{/if}
{#if description}
<Field.Description>
{#if typeof description === 'string'}
{description}
{:else}
{@render description()}
{/if}
{#if docsLink}
<a
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:-translate-y-px after:bg-white dark:text-white"
href={docsLink}
target="_blank"
>
{m.docs()}
<LucideExternalLink class="inline size-3 align-text-top" />
</a>
{/if}
</Field.Description>
{/if}
</div>
<div>
<Field.Field data-disabled={disabled} class={className} {...restProps}>
{#if label}
<div class="flex items-center gap-2">
<Field.Label optional={optional && !readonly} for={labelFor ?? id}>{label}</Field.Label>
<!-- Outside the label, so the changed state doesn't become part of the input's name -->
{#if changed}
<span class="bg-fg size-1.5 shrink-0 rounded-full"></span>
<span class="sr-only">{m.changed_from_the_default()}</span>
{/if}
</div>
{/if}
<div class="flex flex-col gap-1.5">
{#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}
<Field.Error class="text-start">{input.error}</Field.Error>
{:else if description}
<Field.Description>
{#if typeof description === 'string'}{description}{:else}{@render description()}{/if}
</Field.Description>
{/if}
</div>
</Field.Field>
@@ -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 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class={cn('w-full px-3 font-normal', className)}>
<!-- Button centers its content in an inner span, so this one spreads the badges and the chevron apart -->
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<!-- The badges stay on one line and are clipped so the trigger keeps the height of the other inputs -->
<span class="flex min-w-0 gap-1 overflow-hidden">
{#each selected as item (item.value)}
<Badge variant="secondary">{item.label}</Badge>
{:else}
<span class="text-muted-foreground">{placeholder}</span>
{/each}
</span>
<LucideChevronDown class="size-4 shrink-0 opacity-50" />
<button
{...props}
type="button"
class={cn(
filledFieldClass,
'text-control aria-expanded:bg-fill-selected dark:aria-expanded:bg-fill-selected flex h-(--h-md) w-full items-center justify-between gap-1.5 rounded-sm pr-2.5 pl-3.5 text-left',
className
)}
>
<!-- The badges stay on one line and are clipped so the trigger keeps the height of the other inputs -->
<span class="flex min-w-0 gap-1 overflow-hidden">
{#each selected as item (item.value)}
<Badge variant="outline" class="bg-surface">{item.label}</Badge>
{:else}
<span class="text-fg-muted truncate">{placeholder}</span>
{/each}
</span>
</Button>
<LucideChevronDown class="text-fg-muted size-3.5 shrink-0" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-[var(--bits-dropdown-menu-anchor-width)]">
@@ -1,6 +1,6 @@
<script lang="ts">
import { Badge } from '#lib/components/ui/badge/index.ts';
import { Button } from '#lib/components/ui/button/index.ts';
import { filledFieldClass } from '#lib/components/ui/input/index.ts';
import * as Command from '#lib/components/ui/command/index.ts';
import * as Popover from '#lib/components/ui/popover/index.ts';
import { Spinner } from '#lib/components/ui/spinner/index.ts';
@@ -76,26 +76,29 @@
<Popover.Root bind:open>
<Popover.Trigger {id}>
{#snippet child({ props })}
<Button
<button
{...props}
variant="outline"
type="button"
role="combobox"
aria-expanded={open}
class="bg-input/30! h-auto min-h-10 w-full"
class={cn(
filledFieldClass,
'text-control aria-expanded:bg-fill-selected dark:aria-expanded:bg-fill-selected flex min-h-(--h-md) w-full items-center justify-between gap-1.5 rounded-sm py-2 pr-2.5 pl-3.5 text-left'
)}
>
<div class="flex w-full items-center justify-between">
<div class="flex flex-wrap items-center gap-1">
{#if selectedItems.length > 0}
{#each selectedLabels as label, i (i)}
<Badge variant="secondary">{label}</Badge>
{/each}
{:else}
<span class="text-muted-foreground font-normal">{m.select_items()}</span>
{/if}
</div>
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</div>
</Button>
<span class="flex min-w-0 flex-wrap items-center gap-1">
{#if selectedItems.length > 0}
{#each selectedLabels as label, i (i)}
<Badge variant="outline" class="bg-surface max-w-full">
<span class="truncate">{label}</span>
</Badge>
{/each}
{:else}
<span class="text-fg-muted">{m.select_items()}</span>
{/if}
</span>
<LucideChevronDown class="text-fg-muted size-3.5 shrink-0" />
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="p-0" sameWidth>
@@ -127,9 +130,9 @@
}}
>
<LucideCheck
class={cn('mr-2 size-4', !selectedItems.includes(item.value) && 'text-transparent')}
class={cn('size-4', !selectedItems.includes(item.value) && 'text-transparent')}
/>
{item.label}
<span class="min-w-0 wrap-anywhere">{item.label}</span>
</Command.Item>
{/each}
</Command.Group>
@@ -1,12 +1,11 @@
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.ts';
import * as Command from '#lib/components/ui/command/index.ts';
import { filledFieldClass } from '#lib/components/ui/input/index.ts';
import * as Popover from '#lib/components/ui/popover/index.ts';
import { Spinner } from '#lib/components/ui/spinner/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import { LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { tick } from 'svelte';
import type { FormEventHandler, HTMLAttributes } from 'svelte/elements';
let {
@@ -33,16 +32,7 @@
let open = $state(false);
let filteredItems = $state(items);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger(triggerId: string) {
open = false;
tick().then(() => {
document.getElementById(triggerId)?.focus();
});
}
const selectedLabel = $derived(items.find((item) => item.value === value)?.label);
function filterItems(searchString: string) {
if (!searchString) {
@@ -65,18 +55,22 @@
<Popover.Root bind:open {...restProps}>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
<button
{...props}
type="button"
role="combobox"
aria-expanded={open}
{...props}
class={restProps.class}
class={cn(
filledFieldClass,
'text-control aria-expanded:bg-fill-selected dark:aria-expanded:bg-fill-selected flex h-(--h-md) w-full items-center justify-between gap-1.5 rounded-sm pr-2.5 pl-3.5 text-left',
restProps.class
)}
>
<span class="flex w-full justify-between">
{items.find((item) => item.value === value)?.label || selectText}
<LucideChevronDown class="size-4 shrink-0 opacity-50" />
<span class={cn('truncate', !selectedLabel && 'text-fg-muted')}>
{selectedLabel || selectText}
</span>
</Button>
<LucideChevronDown class="text-fg-muted size-3.5 shrink-0" />
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="p-0" sameWidth>
@@ -106,11 +100,10 @@
onSelect={() => {
value = item.value;
onSelect?.(item.value);
// If you need to focus the trigger, you may need to refactor to get the trigger id another way
closeAndFocusTrigger('popover-trigger');
open = false;
}}
>
<LucideCheck class={cn('mr-2 size-4', value !== item.value && 'text-transparent')} />
<LucideCheck class={cn('size-4', value !== item.value && 'text-transparent')} />
{item.label}
</Command.Item>
{/each}
@@ -2,6 +2,7 @@
import { Button } from '#lib/components/ui/button/index.ts';
import { Input } from '#lib/components/ui/input/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import { LucidePlus, LucideX } from '@lucide/svelte';
let {
@@ -10,7 +11,8 @@
testIdPrefix = 'url',
disabled = false,
keepAtLeastOne = false,
addLabel
addLabel,
labelId
}: {
urls: string[];
error?: string | null;
@@ -19,6 +21,7 @@
keepAtLeastOne?: boolean;
// Names what the button adds, instead of the generic "Add" and "Add another"
addLabel?: string;
labelId?: string;
} = $props();
function removeUrl(index: number) {
@@ -39,6 +42,7 @@
class="font-mono text-[13px] md:text-[13px]"
aria-invalid={!!error}
data-testid={`${testIdPrefix}-${i + 1}`}
aria-labelledby={labelId}
type="text"
inputmode="url"
autocomplete="url"
@@ -48,7 +52,10 @@
<Button
variant="ghost"
size="icon"
class="text-muted-foreground size-9"
class={cn(
'text-muted-foreground size-9',
keepAtLeastOne && urls.length === 1 && !url && 'invisible'
)}
aria-label={m.remove_url({ identifier: url || i + 1 })}
onclick={() => removeUrl(i)}
{disabled}
@@ -0,0 +1,28 @@
<script lang="ts">
import * as Avatar from '#lib/components/ui/avatar/index.ts';
import type { User } from '#lib/types/user.type.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import type { Snippet } from 'svelte';
let { user, children }: { user: User; children?: Snippet } = $props();
const fullName = $derived(
user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
);
</script>
<div
class="bg-surface flex min-h-15 w-full items-center gap-3 rounded-md border px-4 py-3 text-start"
>
<Avatar.Root class="size-8">
<Avatar.Image src={cachedProfilePicture.getUrl(user.id)} alt="" />
<Avatar.Fallback>{user.firstName?.[0]}</Avatar.Fallback>
</Avatar.Root>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<p class="text-heading truncate font-medium">{fullName || user.email}</p>
{#if fullName && user.email}
<p class="text-control text-fg-muted truncate">{user.email}</p>
{/if}
</div>
{@render children?.()}
</div>
@@ -1,9 +1,12 @@
<script lang="ts">
import * as Pagination from '#lib/components/ui/pagination/index.ts';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Select from '#lib/components/ui/select/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { PaginationResponse } from '#lib/types/list-request.type.ts';
import { cn } from '#lib/utils/style.ts';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
let {
pagination,
@@ -21,62 +24,69 @@
class?: string;
} = $props();
// The controls are useless when every item already fits on a page of the smallest size
const hidden = $derived(
hideWhenSinglePage && (pagination?.totalItems ?? 0) <= Math.min(...pageSizes)
);
const totalItems = $derived(pagination?.totalItems ?? 0);
const currentPage = $derived(pagination?.currentPage ?? 1);
const perPage = $derived(pagination?.itemsPerPage ?? pageSizes[0]);
const totalPages = $derived(pagination?.totalPages ?? 1);
const from = $derived(totalItems ? (currentPage - 1) * perPage + 1 : 0);
const to = $derived(Math.min(totalItems, currentPage * perPage));
// The page size only matters when the items do not all fit on a page of the smallest size
const fitsSmallestPage = $derived(totalItems <= Math.min(...pageSizes));
</script>
{#if !hidden}
<div
class={cn(
'mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row',
className
)}
{#if pagination && !(hideWhenSinglePage && fitsSmallestPage)}
<nav
aria-label={m.pagination()}
class={cn('text-control flex min-h-(--row) items-center gap-3 pt-2', className)}
>
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={pagination?.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="w-20" aria-label={m.items_per_page()}>
{pagination?.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each pageSizes as size (size)}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={pagination?.totalItems || 0}
perPage={pagination?.itemsPerPage}
{onPageChange}
page={pagination?.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={pagination?.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
<span class="text-fg-muted flex min-w-0 flex-wrap items-center gap-1">
<span role="status" class="[&_b]:text-fg pr-1 tabular-nums [&_b]:font-medium">
<FormattedMessage message={m.pagination_range} inputs={{ from, to, total: totalItems }} />
</span>
{#if !fitsSmallestPage}
<span class="flex items-center gap-1 max-sm:hidden">
<span aria-hidden="true">·</span>
<Select.Root
type="single"
value={String(perPage)}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger variant="ghost" class="text-fg-muted" aria-label={m.page_size()}>
{m.per_page({ count: perPage })}
</Select.Trigger>
<Select.Content>
{#each pageSizes as size (size)}
<Select.Item value={String(size)} label={m.per_page({ count: size })} />
{/each}
</Select.Content>
</Select.Root>
</span>
{/if}
</span>
{#if totalPages > 1}
<span class="ml-auto flex gap-2">
<Button
variant="secondary"
size="sm"
class="max-sm:px-2"
disabled={currentPage <= 1}
onclick={() => onPageChange(currentPage - 1)}
>
<ChevronLeftIcon />
<span class="max-sm:sr-only">{m.previous()}</span>
</Button>
<Button
variant="secondary"
size="sm"
class="max-sm:px-2"
disabled={currentPage >= totalPages}
onclick={() => onPageChange(currentPage + 1)}
>
<span class="max-sm:sr-only">{m.next()}</span>
<ChevronRightIcon />
</Button>
</span>
{/if}
</nav>
{/if}
@@ -1,5 +1,25 @@
<script lang="ts" module>
// One uppercase letter, or the initials of the first two words of a multi-word name, never mixed case
export function monogram(name: string) {
const initials = name
.trim()
.split(/\s+/)
.map((word) => word.match(/[\p{L}\p{N}]/u)?.[0].toUpperCase())
.filter(Boolean);
return initials.slice(0, 2).join('') || '?';
}
// Small tiles keep their own radius steps, because the control radii would round them into circles, and circles are people
const sizeClasses = {
20: 'size-5 rounded-[5px] text-[10px]',
24: 'size-6 rounded-[6px] text-[11px]',
32: 'size-8 rounded-[8px] text-[13px]',
40: 'size-10 rounded-[8px] text-[17px]',
56: 'size-14 rounded-[12px] text-[22px]'
};
</script>
<script lang="ts">
import ImageBox from '#lib/components/image-box.svelte';
import { m } from '#lib/paraglide/messages.js';
import { cachedOidcClientLogo } from '#lib/utils/cached-image-util.ts';
import { cn } from '#lib/utils/style.ts';
@@ -9,27 +29,52 @@
id,
name,
hasLogo,
class: className = 'size-9'
size = 32
}: {
id: string;
name: string;
hasLogo: boolean;
class?: string;
size?: keyof typeof sizeClasses;
} = $props();
const isLightMode = $derived(mode.current === 'light');
const src = $derived(cachedOidcClientLogo.getUrl(id, mode.current === 'light'));
const letters = $derived(monogram(name));
// A broken logo falls back to the monogram until the logo URL changes
let failed = $state(false);
$effect(() => {
void src;
failed = false;
});
</script>
{#if hasLogo}
<ImageBox
class={cn('rounded-lg', className)}
src={cachedOidcClientLogo.getUrl(id, isLightMode)}
alt={m.name_logo({ name })}
/>
{:else}
<div
class={cn('bg-muted flex shrink-0 items-center justify-center rounded-lg font-bold', className)}
{#if hasLogo && !failed}
<span
class={cn(
'bg-surface-raised inset-ring-border inline-grid shrink-0 place-items-center overflow-hidden inset-ring',
sizeClasses[size]
)}
>
{name.charAt(0).toUpperCase()}
</div>
<img
{src}
alt={m.name_logo({ name })}
class="size-[70%] object-contain"
onerror={() => (failed = true)}
/>
</span>
{:else}
<!-- A single narrow capital reads as a stroke in a square, so it is set larger, and two initials are set smaller so they fit -->
<span
aria-hidden="true"
class={cn(
'bg-fill-muted text-fg-muted inset-ring-border inline-grid shrink-0 place-items-center leading-none font-medium inset-ring select-none',
sizeClasses[size]
)}
>
<span
class={cn(/^[IJ1]$/.test(letters) && 'text-[1.2em]', letters.length > 1 && 'text-[0.86em]')}
>
{letters}
</span>
</span>
{/if}
@@ -1,17 +1,15 @@
<script lang="ts">
import { page } from '$app/state';
import CopyToClipboard from '#lib/components/copy-to-clipboard.svelte';
import CopyField from '#lib/components/copy-field.svelte';
import Qrcode from '#lib/components/qrcode/qrcode.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Dialog from '#lib/components/ui/dialog/index.ts';
import * as Field from '#lib/components/ui/field/index.ts';
import * as Select from '#lib/components/ui/select/index.js';
import { Separator } from '#lib/components/ui/separator/index.ts';
import { m } from '#lib/paraglide/messages.js';
import UserService from '#lib/services/user-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { mode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
let {
@@ -110,27 +108,15 @@
>
</Dialog.Footer>
{:else}
<div class="flex flex-col items-center gap-2">
<CopyToClipboard value={code!}>
<p class="text-3xl font-bold">{code}</p>
</CopyToClipboard>
<div class="text-muted-foreground my-2 flex items-center justify-center gap-3">
<Separator />
<p class="text-xs text-nowrap">{m.or_visit()}</p>
<Separator />
</div>
<Qrcode
class="mb-2"
<div class="flex flex-col gap-4">
<CopyField label={m.code()} value={code!} copyLabel={m.copy_code()} />
<CopyField
label={m.link()}
value={oneTimeLink}
size={150}
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
backgroundColor={mode.current === 'dark' ? '#000000' : '#FFFFFF'}
copyLabel={m.copy_link()}
testId="login-code-link"
/>
<CopyToClipboard value={oneTimeLink!}>
<p data-testId="login-code-link" class="text-sm">{oneTimeLink!}</p>
</CopyToClipboard>
<Qrcode class="self-center" value={oneTimeLink} size={160} />
</div>
{/if}
</Dialog.Content>
@@ -0,0 +1,151 @@
<script lang="ts">
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import { LucideChevronLeft, LucideChevronRight } from '@lucide/svelte';
import type { Snippet } from 'svelte';
type Crumb = { label: string; href?: string };
type FactBand = { label: string; items: { label: string; value: string | Snippet }[] };
let {
title,
description,
breadcrumbs,
identity,
status,
tags,
actions,
facts
}: {
title: string;
description?: string | Snippet;
// The ancestors of a detail page; passing them turns the header into a detail header with the title as the current item
breadcrumbs?: Crumb[];
identity?: Snippet;
status?: Snippet;
tags?: Snippet;
actions?: Snippet;
// Read-only facts that turn a detail header into the sunken identity hero
facts?: FactBand[];
} = $props();
const backCrumb = $derived(breadcrumbs?.findLast((crumb) => crumb.href));
const hero = $derived(!!facts?.length);
</script>
{#snippet h1()}
<h1 class="font-display text-display-m text-fg max-sm:text-display-s wrap-anywhere">{title}</h1>
{/snippet}
{#snippet text(value: string | Snippet)}
{#if typeof value === 'string'}{value}{:else}{@render value()}{/if}
{/snippet}
{#if breadcrumbs}
<header class={cn(hero && 'sm:bg-surface-sunken sm:rounded-xl sm:px-7 sm:py-6')}>
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2">
<nav aria-label={m.breadcrumb()} class={cn('min-w-0 sm:col-span-2', hero && 'sm:mb-2')}>
<ol class="text-caption text-fg-muted flex flex-wrap items-center gap-1 max-sm:hidden">
{#each breadcrumbs as crumb (crumb.label)}
<li class="inline-flex items-center gap-1">
{#if crumb.href}
<a href={crumb.href} class="hover:text-fg underline-offset-3 hover:underline">
{crumb.label}
</a>
{:else}
{crumb.label}
{/if}
<LucideChevronRight class="size-3" />
</li>
{/each}
<li><span aria-current="page" class="text-fg">{title}</span></li>
</ol>
{#if backCrumb}
<a
href={backCrumb.href}
class="text-caption text-fg-muted hover:text-fg inline-flex items-center gap-1 sm:hidden"
>
<LucideChevronLeft class="size-3" />
{backCrumb.label}
</a>
{/if}
</nav>
<div
class={cn(
'col-span-2 flex min-w-0 items-start gap-3 sm:col-span-1',
hero && 'sm:items-center sm:gap-5'
)}
>
{#if identity}
<div class={cn('flex shrink-0 *:size-10', hero && 'sm:*:size-14')}>
{@render identity()}
</div>
{/if}
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
{@render h1()}
{@render status?.()}
</div>
{#if tags}
<div class="text-control text-fg-muted mt-2 flex flex-wrap items-center gap-2">
{@render tags()}
</div>
{/if}
</div>
</div>
{#if actions}
<div
class={cn(
'col-start-2 row-start-1 flex items-center gap-2 justify-self-end sm:row-start-2',
!hero && 'sm:self-start'
)}
>
{@render actions()}
</div>
{/if}
</div>
{#if facts?.length}
<!-- Narrower windows put each band's label above two columns, so IDs and paths are not cut to a few characters -->
<div class="mt-6 flex flex-col gap-5">
{#each facts as band (band.label)}
<div
class="grid gap-x-6 gap-y-3 pt-5 shadow-[inset_0_1px_0_var(--border)] xl:grid-cols-[120px_minmax(0,1fr)]"
>
<p class="text-caption text-fg-muted font-semibold">{band.label}</p>
<dl
class={cn(
'grid gap-x-6 gap-y-3 sm:grid-cols-2',
band.items.length > 2 && 'xl:grid-cols-3'
)}
>
{#each band.items as item (item.label)}
<div class="min-w-0">
<dt class="text-caption text-fg-muted">{item.label}</dt>
<dd class="text-control text-fg">{@render text(item.value)}</dd>
</div>
{/each}
</dl>
</div>
{/each}
</div>
{/if}
</header>
{:else}
<!-- Actions that leave the title too little room wrap under it instead of squeezing it -->
<header class="flex flex-wrap items-start gap-4">
<div class="flex min-w-40 flex-1 flex-col gap-2">
{@render h1()}
{#if description}
<p class="text-body text-fg-muted">{@render text(description)}</p>
{/if}
</div>
{#if actions}
<!-- The primary action stays on the title row and drops to the small button size on phones -->
<div
class="flex shrink-0 items-center gap-2 max-sm:*:data-[slot=button]:h-(--h-sm) max-sm:*:data-[slot=button]:px-3"
>
{@render actions()}
</div>
{/if}
</header>
{/if}
+55 -78
View File
@@ -1,97 +1,74 @@
<script lang="ts">
import ActionsMenu from '#lib/components/actions-menu.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Item from '#lib/components/ui/item/index.js';
import * as Tooltip from '#lib/components/ui/tooltip/index.js';
import { m } from '#lib/paraglide/messages.js';
import { LucideCalendar, LucidePencil, LucideTrash, type LucideIcon } from '@lucide/svelte';
import { getLocale } from '#lib/paraglide/runtime.js';
import type { Passkey } from '#lib/types/passkey.type.ts';
import { authenticatorIconUrl } from '#lib/utils/cached-image-util.ts';
import { LucideKeyRound, LucidePencil, LucideTrash2 } from '@lucide/svelte';
import { mode } from 'mode-watcher';
let {
icon,
providerIconUrl,
passkey,
onRename,
onDelete,
showRenameAction = true,
label,
description
onDelete
}: {
icon: LucideIcon;
providerIconUrl?: string;
passkey: Passkey;
onRename?: () => void;
onDelete: () => void;
showRenameAction?: boolean;
description?: string;
label?: string;
} = $props();
// Falls back to the generic icon when the authenticator icon cannot be loaded
const iconUrl = $derived(
passkey.hasIcon ? authenticatorIconUrl(passkey.aaguid, mode.current === 'light') : undefined
);
// Falls back to the key glyph when the authenticator icon cannot be loaded
let iconFailed = $state(false);
// The row is reused across passkeys and themes, so a previous failure must not hide an icon that is now a different URL
$effect(() => {
void providerIconUrl;
void iconUrl;
iconFailed = false;
});
const showProviderIcon = $derived(!!providerIconUrl && !iconFailed);
</script>
<Item.Root variant="transparent" class="hover:bg-muted px-0 py-3 transition-colors sm:px-4">
<Item.Media class="bg-muted text-muted-foreground size-11 rounded-xl">
{#if showProviderIcon}
<img
src={providerIconUrl}
alt=""
class="size-7 object-contain"
onerror={() => (iconFailed = true)}
/>
{:else if icon}{@const Icon = icon}
<Icon class="size-6" />
{/if}
</Item.Media>
<Item.Content class="gap-0.5">
<Item.Title>{label}</Item.Title>
{#if description}
<Item.Description class="flex items-center">
<LucideCalendar class="mr-1 size-3" />
{description}
</Item.Description>
{/if}
</Item.Content>
<Item.Actions>
{#if showRenameAction && onRename}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
onclick={onRename}
size="icon"
variant="ghost"
class="size-8"
aria-label={m.rename()}
>
<LucidePencil class="size-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{m.rename()}</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
onclick={onDelete}
size="icon"
variant="ghost"
class="hover:bg-destructive/10 hover:text-destructive size-8"
aria-label={m.delete()}
>
<LucideTrash class="size-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{m.delete()}</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</Item.Actions>
</Item.Root>
<div class="flex min-h-(--row-2line) items-center gap-3 px-4 py-2.5">
{#if iconUrl && !iconFailed}
<span
class="bg-surface-raised inset-ring-border grid size-8 shrink-0 place-items-center rounded-[8px] inset-ring"
>
<img src={iconUrl} alt="" class="size-5 object-contain" onerror={() => (iconFailed = true)} />
</span>
{:else}
<span class="bg-fill-muted text-fg-muted grid size-8 shrink-0 place-items-center rounded-[8px]">
<LucideKeyRound class="size-4" />
</span>
{/if}
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<p class="text-heading truncate">{passkey.name}</p>
<p class="text-caption text-fg-muted">
{m.added_on()}
{new Date(passkey.createdAt).toLocaleDateString(getLocale(), { dateStyle: 'medium' })}
</p>
</div>
{#if onRename}
<ActionsMenu
item={passkey}
size="icon-sm"
label={m.actions_for_name({ name: passkey.name })}
actions={[
{ label: m.rename(), icon: LucidePencil, onClick: onRename },
{ label: m.delete_ellipsis(), icon: LucideTrash2, variant: 'danger', onClick: onDelete }
]}
/>
{:else}
<Button
variant="ghost"
size="icon-xs"
aria-label={m.delete_name_action({ name: passkey.name })}
onclick={onDelete}
>
<LucideTrash2 />
</Button>
{/if}
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import CopyButton from '#lib/components/copy-button.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { DeviceLoginRequest } from '#lib/types/device-login.type.ts';
import { cn } from '#lib/utils/style.ts';
import Qrcode from './qrcode.svelte';
let {
request,
ended = false
}: {
request: DeviceLoginRequest;
// The server can end the request before the local countdown does, and a denied code is just as dead
ended?: boolean;
} = $props();
let showQr = $state(false);
let now = $state(Date.now());
$effect(() => {
const timer = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(timer);
});
const secondsLeft = $derived(
Math.max(0, Math.ceil((new Date(request.expiresAt).getTime() - now) / 1000))
);
const expired = $derived(ended || secondsLeft === 0);
const countdown = $derived(
`${Math.floor(secondsLeft / 60)}:${String(secondsLeft % 60).padStart(2, '0')}`
);
const codeGroups = $derived([request.userCode.slice(0, 4), request.userCode.slice(4)]);
</script>
<div class="w-full rounded-md border p-4">
<div class="flex flex-col items-center gap-4">
<!-- A phone cannot scan its own screen, so on phones the code comes first and the QR waits behind a text action -->
<Qrcode
value={request.verificationUriComplete}
size={144}
role="img"
aria-label={m.device_login_qr_code()}
aria-hidden={expired}
class={cn(
'transition-opacity duration-(--dur-base) max-sm:order-last',
expired && 'opacity-20',
!showQr && 'max-sm:hidden'
)}
/>
<div class="flex w-full min-w-0 flex-col gap-3">
<p class="text-caption text-fg-muted text-center wrap-anywhere">
{m.visit_and_enter({ url: request.verificationUri })}
</p>
<div
class={cn('flex items-center font-mono text-xl font-medium', expired && 'text-fg-muted')}
data-testid="device-login-code"
>
{#each codeGroups as group, i (i)}
{#if i > 0}
<span class="text-fg-muted w-7 shrink-0 text-center text-xs">–</span>
{/if}
<span class="bg-surface-sunken flex min-w-0 flex-1 rounded-sm">
{#each group as char, j (j)}
<span class="grid h-11 min-w-0 flex-1 place-items-center">{char}</span>
{/each}
</span>
{/each}
</div>
<CopyButton
labeled
value={request.userCode}
label={m.copy_code()}
class={cn('self-center', expired && 'invisible')}
/>
</div>
{#if !expired}
<Button
variant="link"
class="h-11 sm:hidden"
aria-expanded={showQr}
onclick={() => (showQr = !showQr)}
>
{showQr ? m.hide_qr_code() : m.show_qr_code()}
</Button>
{/if}
</div>
<p class="text-caption text-fg-muted mt-3 text-center tabular-nums">
{expired ? m.code_expired() : m.expires_in_countdown({ time: countdown })}
</p>
</div>
@@ -1,38 +1,25 @@
<script lang="ts">
import { cn } from '#lib/utils/style.ts';
import QRCode from 'qrcode';
import { onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let canvasEl: HTMLCanvasElement | null;
let {
value,
size = 200,
color = '#000000',
backgroundColor = 'transparent',
...restProps
}: HTMLAttributes<HTMLCanvasElement> & {
value: string | null;
size?: number;
color?: string;
backgroundColor?: string;
} = $props();
onMount(() => {
$effect(() => {
if (value && canvasEl) {
// Convert "transparent" to a valid value for the QR code library
const lightColor = backgroundColor === 'transparent' ? '#00000000' : backgroundColor;
const options = {
QRCode.toCanvas(canvasEl, value, {
width: size,
margin: 0,
color: {
dark: color,
light: lightColor
}
};
QRCode.toCanvas(canvasEl, value, options)
color: { dark: '#121212', light: '#ffffff' }
})
.then(() => {
canvasEl?.style.removeProperty('height');
canvasEl?.style.removeProperty('width');
@@ -44,4 +31,9 @@
});
</script>
<canvas {...restProps} bind:this={canvasEl} class={cn('rounded', restProps.class)}></canvas>
<!-- Scanners need dark modules on a light quiet zone, so the code sits on a white tile in both themes -->
<canvas
{...restProps}
bind:this={canvasEl}
class={cn('bg-qr-bg box-content rounded-md p-2', restProps.class)}
></canvas>
+36 -37
View File
@@ -1,54 +1,53 @@
<script lang="ts">
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
import * as Item from '#lib/components/ui/item/index.js';
import { cn } from '#lib/utils/style.js';
import type { LucideIcon } from '@lucide/svelte';
interface Props {
let {
icon: Icon,
value,
category,
selected = true,
onSelectedChange
}: {
icon: LucideIcon;
name: string;
description: string;
// The person's real value, such as their email address, shown over the category it belongs to
value?: string;
category: string;
selected?: boolean;
onSelectedChange?: (selected: boolean) => void;
}
} = $props();
let { icon, name, description, selected = true, onSelectedChange }: Props = $props();
const SvelteComponent = $derived(icon);
const textId = $props.id();
</script>
{#snippet content()}
<Item.Media class="bg-muted h-full !translate-y-0 !self-center rounded-lg p-2">
<SvelteComponent class="size-5" />
</Item.Media>
<Item.Content class="text-start">
<Item.Title class="font-semibold">{name}</Item.Title>
<Item.Description>{description}</Item.Description>
</Item.Content>
<Icon class="text-fg-muted size-4 shrink-0" />
<span id={textId} class="flex min-w-0 flex-1 flex-col gap-0.5 text-start wrap-anywhere">
{#if value}
<span class="text-body">{value}</span>
<span class="text-control text-fg-muted">{category}</span>
{:else}
<span class="text-body">{category}</span>
{/if}
</span>
{/snippet}
{#if onSelectedChange}
<!-- The whole row is a label so clicking anywhere on it toggles the scope -->
<Item.Root class="px-0 py-1.5">
{#snippet child({ props })}
<label
{...props}
class={cn(props.class as string, 'cursor-pointer', !selected && 'opacity-60')}
data-testid="scope-item"
>
{@render content()}
<Item.Actions>
<Checkbox
aria-label={name}
checked={selected}
onCheckedChange={(checked: boolean) => onSelectedChange(checked)}
/>
</Item.Actions>
</label>
{/snippet}
</Item.Root>
{:else}
<Item.Root class="px-0 py-1.5">
<label
class="hover:bg-fill-row-hover has-focus-visible:bg-fill-row-hover active:bg-fill-hover flex min-h-13 cursor-pointer items-center gap-3 px-4 py-2.5 transition-colors duration-(--dur-fast)"
data-testid="scope-item"
>
{@render content()}
</Item.Root>
<!-- The checkbox is a button, which a wrapping label does not name -->
<Checkbox
aria-labelledby={textId}
checked={selected}
onCheckedChange={(checked: boolean) => onSelectedChange(checked)}
/>
</label>
{:else}
<div class="flex min-h-13 items-center gap-3 px-4 py-2.5">
{@render content()}
</div>
{/if}
+29 -14
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import * as Item from '#lib/components/ui/item/index.js';
import { m } from '#lib/paraglide/messages.js';
import userStore from '#lib/stores/user-store.ts';
import type { InteractionScopeInfo } from '#lib/types/oidc.type.ts';
import { LucideKeyRound, LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
import { LucideKeyRound, LucideMail, LucideUserRound, LucideUsersRound } from '@lucide/svelte';
import ScopeItem from './scope-item.svelte';
let {
@@ -20,6 +20,17 @@
const infoByKey = $derived(new Map((scopeInfo || []).map((info) => [info.key, info])));
const customScopes = $derived((scopes || []).filter((scope) => !standardScopes.includes(scope)));
const profileValue = $derived(
$userStore
? [$userStore.displayName, $userStore.username].filter(Boolean).join(' · ')
: undefined
);
const groupsValue = $derived(
$userStore
? $userStore.userGroups.map((group) => group.friendlyName).join(', ') || m.no_groups_yet()
: undefined
);
function selectionProps(scope: string) {
if (!selectedScopes) {
return {};
@@ -35,37 +46,41 @@
}
</script>
<Item.Group data-testid="scopes" class="gap-1">
<fieldset
data-testid="scopes"
class="bg-surface divide-border w-full divide-y overflow-hidden rounded-md border"
>
<legend class="sr-only">{m.permissions()}</legend>
{#if (scopes || []).includes('email')}
<ScopeItem
icon={LucideMail}
name={m.email()}
description={m.view_your_email_address()}
value={$userStore?.email}
category={m.email_address()}
{...selectionProps('email')}
/>
{/if}
{#if (scopes || []).includes('profile')}
<ScopeItem
icon={LucideUser}
name={m.profile()}
description={m.view_your_profile_information()}
icon={LucideUserRound}
value={profileValue}
category={m.name_username_and_picture()}
{...selectionProps('profile')}
/>
{/if}
{#if (scopes || []).includes('groups')}
<ScopeItem
icon={LucideUsers}
name={m.groups()}
description={m.view_the_groups_you_are_a_member_of()}
icon={LucideUsersRound}
value={groupsValue}
category={m.group_membership()}
{...selectionProps('groups')}
/>
{/if}
{#each customScopes as scope (scope)}
<ScopeItem
icon={LucideKeyRound}
name={infoByKey.get(scope)?.name ?? scope}
description={infoByKey.get(scope)?.description || m.access_an_api_on_your_behalf()}
value={infoByKey.get(scope)?.name ?? scope}
category={infoByKey.get(scope)?.description || m.access_an_api_on_your_behalf()}
{...selectionProps(scope)}
/>
{/each}
</Item.Group>
</fieldset>
@@ -0,0 +1,129 @@
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.ts';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import { LucideInfo, LucideRotateCcw } from '@lucide/svelte';
import { tick, type Component, type Snippet } from 'svelte';
type ControlProps = { id: string; 'aria-describedby': string | undefined };
let {
title,
description,
icon: Icon,
stacked = false,
disabledReason,
consequence,
changed = false,
onReset,
control
}: {
title: string;
description?: string | Snippet;
icon?: Component;
stacked?: boolean;
// Dims a dependent row; the reason ("Requires Allow sign-ups") is announced with the control, which the caller disables
disabledReason?: string;
consequence?: string;
changed?: boolean;
onReset?: () => void;
// Spread the props on the control so the title labels it; leave `id` off for action buttons, whose own text is their name
control: Snippet<[ControlProps]>;
} = $props();
const uid = $props.id();
const controlId = `${uid}-control`;
let controlContainer = $state<HTMLElement>();
const describedBy = $derived(
[
description && `${uid}-desc`,
consequence && `${uid}-consequence`,
disabledReason && `${uid}-reason`,
changed && `${uid}-changed`
]
.filter(Boolean)
.join(' ') || undefined
);
async function reset() {
onReset?.();
// The reset button leaves with the changed state, so focus moves to the control it reset
await tick();
controlContainer?.querySelector<HTMLElement>('button, input, textarea, [tabindex]')?.focus();
}
</script>
<div
class={cn(
'flex min-h-[68px] gap-4 py-4 shadow-[inset_0_-1px_0_var(--border)] transition-opacity duration-(--dur-base) ease-out last:shadow-none',
stacked ? 'items-start' : 'items-center max-sm:items-start',
disabledReason && 'opacity-45'
)}
>
{#if Icon}
<span
aria-hidden="true"
class="bg-surface text-fg grid size-9 shrink-0 place-items-center rounded-full shadow-[inset_0_0_0_1px_var(--border)]"
>
<Icon class="size-[18px]" />
</span>
{/if}
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-3 max-sm:items-start">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<label for={controlId} class="text-heading text-fg">
{title}
{#if changed}
<span class="bg-fg ms-1.5 inline-block size-1.5 rounded-full align-middle"></span>
<span id="{uid}-changed" class="sr-only">{m.changed_from_the_default()}</span>
{/if}
</label>
{#if description}
<p id="{uid}-desc" class="text-caption text-fg-muted max-w-[68ch]">
{#if typeof description === 'string'}{description}{:else}{@render description()}{/if}
</p>
{/if}
{#if consequence}
<p id="{uid}-consequence" class="text-caption text-fg-muted mt-1 flex items-start gap-1.5">
<LucideInfo class="mt-[3px] size-3 shrink-0" />
{consequence}
</p>
{/if}
{#if disabledReason}
<span id="{uid}-reason" class="sr-only">{disabledReason}</span>
{/if}
</div>
<!-- Outside the control's box, so a control that wraps under the text leaves the reset on the title line -->
{#if changed && onReset}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon-xs"
class={cn(!stacked && 'sm:-me-3')}
aria-label={m.reset_setting_to_default({ setting: title })}
onclick={reset}
>
<LucideRotateCcw />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>{m.reset_to_default()}</Tooltip.Content>
</Tooltip.Root>
{/if}
<div
bind:this={controlContainer}
class={cn(
'flex items-center gap-2',
stacked
? 'basis-full'
: 'shrink-0 justify-end max-sm:not-has-[>[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 })}
</div>
</div>
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description,
action,
children
}: {
title: string;
description?: string | Snippet;
action?: Snippet;
children: Snippet;
} = $props();
const headingId = $props.id();
</script>
<section aria-labelledby={headingId} class="flex flex-col">
<div class="flex items-end gap-4 pb-2 max-sm:flex-col max-sm:items-start">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<h2 id={headingId} class="text-title text-fg">{title}</h2>
{#if description}
<p class="text-caption text-fg-muted max-w-[68ch]">
{#if typeof description === 'string'}{description}{:else}{@render description()}{/if}
</p>
{/if}
</div>
{#if action}
<div class="flex shrink-0 items-center gap-2 empty:hidden">{@render action()}</div>
{/if}
</div>
{@render children()}
</section>
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import CopyToClipboard from '#lib/components/copy-to-clipboard.svelte';
import CopyField from '#lib/components/copy-field.svelte';
import FormInput from '#lib/components/form/form-input.svelte';
import UserGroupInput from '#lib/components/form/user-group-input.svelte';
import Qrcode from '#lib/components/qrcode/qrcode.svelte';
@@ -14,7 +14,6 @@
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { preventDefault } from '#lib/utils/event-util.ts';
import { createForm } from '#lib/utils/form-util.ts';
import { mode } from 'mode-watcher';
import { onMount } from 'svelte';
import { z } from 'zod/v4';
@@ -181,20 +180,16 @@
</Dialog.Footer>
</form>
{:else}
<div class="flex flex-col items-center gap-2">
<Qrcode
class="mb-2"
value={signupLink}
size={180}
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
<div class="flex flex-col items-center gap-4">
<Qrcode value={signupLink} size={180} />
<CopyField
label={m.link()}
value={signupLink!}
copyLabel={m.copy_link()}
testId="signup-token-link"
/>
<CopyToClipboard value={signupLink!}>
<p data-testId="signup-token-link" class="px-2 text-center text-sm break-all">
{signupLink!}
</p>
</CopyToClipboard>
<div class="text-muted-foreground mt-2 text-center text-sm">
<div class="text-caption text-fg-muted text-center">
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
</div>
@@ -1,49 +1,63 @@
<script lang="ts" generics="TData extends Record<string, any>">
import { buttonVariants } from '#lib/components/ui/button/index.js';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Popover from '#lib/components/ui/popover/index.js';
import { m } from '#lib/paraglide/messages.js';
import type { AdvancedTableColumn } from '#lib/types/advanced-table.type.ts';
import Settings2Icon from '@lucide/svelte/icons/settings-2';
import CheckIcon from '@lucide/svelte/icons/check';
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
let {
columns,
selectedColumns = $bindable([])
}: { columns: AdvancedTableColumn<TData>[]; selectedColumns: string[] } = $props();
const labelId = $props.id();
const columnKey = (column: AdvancedTableColumn<TData>) => column.column ?? column.key!;
function toggle(key: string) {
selectedColumns = selectedColumns.includes(key)
? selectedColumns.filter((c) => c !== key)
: [...selectedColumns, key];
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={buttonVariants({
variant: 'outline',
size: 'sm',
class: 'ml-auto'
})}
<Popover.Root>
<Popover.Trigger
class={buttonVariants({ variant: 'ghost', size: 'sm', class: 'px-2.5 max-sm:hidden' })}
>
<Settings2Icon />
<span class="hidden md:flex">{m.view()}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.Label>{m.toggle_columns()}</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each columns as column (column)}
{#if column.label}
<DropdownMenu.CheckboxItem
closeOnSelect={false}
checked={selectedColumns.includes(column.column ?? column.key!)}
onCheckedChange={(v) => {
const key = column.column ?? column.key!;
if (v) {
selectedColumns = [...selectedColumns, key];
} else {
selectedColumns = selectedColumns.filter((c) => c !== key);
}
}}
>
{column.label}
</DropdownMenu.CheckboxItem>
{/if}
<SlidersHorizontalIcon />
{m.display()}
</Popover.Trigger>
<Popover.Content align="end" class="w-70 gap-2">
<p id={labelId} class="text-caption text-fg-muted font-medium">
{m.columns()}
</p>
<div role="group" aria-labelledby={labelId} class="flex flex-wrap gap-1.5">
<!-- The first column names the row, so it always shows and has no chip -->
{#each columns.slice(1).filter((c) => c.label) as column (columnKey(column))}
{@const pressed = selectedColumns.includes(columnKey(column))}
<button
type="button"
aria-pressed={pressed}
class="text-control text-fg-muted hover:bg-fill-hover aria-pressed:bg-fill-selected aria-pressed:text-fg focus-visible:outline-ring transition-motion inline-flex h-(--h-sm) items-center gap-1 rounded-sm px-2 shadow-[inset_0_0_0_1px_var(--border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-(--press-scale) aria-pressed:font-medium aria-pressed:shadow-none"
onclick={() => toggle(columnKey(column))}
>
{#if pressed}
<CheckIcon class="size-3" />
{/if}
{column.label}
</button>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div class="flex justify-end">
<Button
variant="link"
class="text-caption text-fg-muted"
onclick={() => (selectedColumns = columns.filter((c) => !c.hidden).map(columnKey))}
>
{m.reset()}
</Button>
</div>
</Popover.Content>
</Popover.Root>
@@ -1,130 +1,63 @@
<script lang="ts">
import { Badge } from '#lib/components/ui/badge/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import * as Command from '#lib/components/ui/command/index.js';
import * as Popover from '#lib/components/ui/popover/index.js';
import { Separator } from '#lib/components/ui/separator/index.js';
import * as Select from '#lib/components/ui/select/index.js';
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import CheckIcon from '@lucide/svelte/icons/check';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import type { Component } from 'svelte';
import XIcon from '@lucide/svelte/icons/x';
let {
title,
options,
selectedValues = new Set<string | boolean>(),
showCheckboxes = true,
onChanged = () => {}
value,
onChange
}: {
title: string;
options: {
label: string;
value: string | boolean;
icon?: Component;
}[];
selectedValues?: Set<string | boolean>;
showCheckboxes?: boolean;
onChanged?: (selected: Set<string | boolean>) => void;
options: { label: string; value: string | boolean }[];
value?: string | boolean;
onChange: (value?: string | boolean) => void;
} = $props();
let open = $state(false);
// Select values are strings, so "Any" needs a sentinel that no option value uses
const ANY = '__any';
const testId = $derived(`facet-${title.toLowerCase()}`);
const selected = $derived(options.find((o) => o.value === value));
const valueLabel = $derived(selected?.label ?? m.any());
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="sm"
class="order-dashed"
data-testid={`facet-${title.toLowerCase()}-trigger`}
>
<span class="flex items-center gap-2">
<ListFilterIcon />
{title}
</span>
{#if selectedValues.size > 0}
<Separator orientation="vertical" class="mx-2 h-4" />
<Badge variant="secondary" class="rounded-sm px-1 font-normal lg:hidden">
{selectedValues.size}
</Badge>
<div class="hidden space-x-1 lg:flex">
{#if selectedValues.size > 2}
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
Count: {selectedValues.size}
</Badge>
{:else}
{#each options.filter((opt) => selectedValues.has(opt.value)) as option (option)}
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
{option.label}
</Badge>
{/each}
{/if}
</div>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content
class="w-[200px] p-0"
align="start"
data-testid={`facet-${title.toLowerCase()}-content`}
<span class="inline-flex shrink-0 items-center gap-0.5">
<Select.Root
type="single"
value={selected ? String(selected.value) : ANY}
onValueChange={(v) => onChange(options.find((o) => String(o.value) === v)?.value)}
>
<Command.Root>
<Command.List>
<Command.Empty>{m.no_items_found()}</Command.Empty>
<Command.Group>
{#each options as option (option)}
{@const isSelected = selectedValues.has(option.value)}
<Command.Item
data-testid={`facet-${title.toLowerCase()}-option-${String(option.value)}`}
onSelect={() => {
if (isSelected) {
selectedValues = new Set([...selectedValues].filter((v) => v !== option.value));
} else {
selectedValues = new Set([...selectedValues, option.value]);
}
onChanged(selectedValues);
}}
>
{#if showCheckboxes}
<div
class={cn(
'border-primary mr-2 flex size-4 items-center justify-center rounded-sm border',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
)}
>
<CheckIcon class="size-4" />
</div>
{/if}
{#if option.icon}
{@const Icon = option.icon}
<Icon class="text-muted-foreground" />
{/if}
<span>{option.label}</span>
</Command.Item>
{/each}
</Command.Group>
{#if selectedValues.size > 0}
<Command.Separator />
<Command.Group>
<Command.Item
onSelect={() => {
selectedValues = new Set();
onChanged(selectedValues);
open = false;
}}
>
{m.clear_filters()}
</Command.Item>
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
<Select.Trigger
variant="ghost"
aria-label={m.facet_filter_label({ name: title, value: valueLabel })}
data-testid={`${testId}-trigger`}
>
<span class="text-fg-muted">{title}:</span>
<span class={selected ? 'text-fg font-medium' : 'text-fg-muted'}>{valueLabel}</span>
</Select.Trigger>
<Select.Content align="start">
<Select.Item value={ANY} label={m.any()} data-testid={`${testId}-option-any`} />
{#each options as option (option.value)}
<Select.Item
value={String(option.value)}
label={option.label}
data-testid={`${testId}-option-${String(option.value)}`}
/>
{/each}
</Select.Content>
</Select.Root>
{#if selected}
<Button
variant="ghost"
size="icon-xs"
class="size-5 max-sm:hidden"
aria-label={m.clear_name_filter({ name: title })}
onclick={() => onChange(undefined)}
>
<XIcon class="size-3" />
</Button>
{/if}
</span>
@@ -1,67 +1,69 @@
<script lang="ts" generics="TData extends Record<string, any>">
import { Input } from '#lib/components/ui/input/index.js';
import * as InputGroup from '#lib/components/ui/input-group/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { AdvancedTableColumn } from '#lib/types/advanced-table.type.ts';
import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import { debounced } from '#lib/utils/debounce-util.ts';
import AdvancedTableColumnSelection from './advanced-table-column-selection.svelte';
import { revealFocused } from '#lib/utils/event-util.ts';
import { LucideSearch } from '@lucide/svelte';
import AdvancedTableFilter from './advanced-table-filter.svelte';
let {
columns,
visibleColumns = $bindable(),
requestOptions,
searchValue = $bindable(),
searchValue,
searchInput = $bindable(null),
withoutSearch = false,
onFilterChange,
refresh
onSearch
}: {
columns: AdvancedTableColumn<TData>[];
visibleColumns: string[];
requestOptions: ListRequestOptions;
searchValue?: string;
searchValue: string;
searchInput?: HTMLInputElement | null;
withoutSearch?: boolean;
onFilterChange?: (selected: Set<string | boolean>, column: string) => void;
refresh: () => Promise<void>;
onFilterChange: (column: string, value?: string | boolean) => void;
onSearch: (search: string) => Promise<void>;
} = $props();
let filterableColumns = $derived(
columns
.filter((c) => c.filterableValues)
.map((c) => ({
name: c.label!,
column: c.column!,
options: c.filterableValues!
}))
);
let filterableColumns = $derived(columns.filter((c) => c.filterableValues));
const onSearch = debounced(async (search: string) => {
requestOptions.search = search;
await refresh();
searchValue = search;
}, 300);
const search = debounced((value: string) => onSearch(value), 300);
</script>
<div class="mb-4 flex flex-wrap items-end justify-between gap-2">
<div class="flex flex-1 items-center gap-2 has-[>:nth-child(3)]:flex-wrap">
{#if !withoutSearch || filterableColumns.length > 0}
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
{#if !withoutSearch}
<Input
value={searchValue}
class="relative z-50 w-full sm:max-w-xs"
placeholder={m.search()}
type="text"
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
/>
<InputGroup.Root class="w-full sm:w-75">
<InputGroup.Input
bind:ref={searchInput}
value={searchValue}
placeholder={m.search()}
aria-label={m.search()}
type="search"
oninput={(e: Event) => search((e.currentTarget as HTMLInputElement).value)}
/>
<InputGroup.Addon><LucideSearch /></InputGroup.Addon>
</InputGroup.Root>
{/if}
{#each filterableColumns as col (col.column)}
<AdvancedTableFilter
title={col.name}
options={col.options}
selectedValues={new Set(requestOptions.filters?.[col.column] || [])}
onChanged={(selected) => onFilterChange?.(selected, col.column)}
/>
{/each}
<AdvancedTableColumnSelection {columns} bind:selectedColumns={visibleColumns} />
{#if filterableColumns.length > 0}
<!-- The padding keeps focus rings visible inside the horizontal phone scroller -->
<div
role="group"
aria-label={m.filters()}
onfocusin={revealFocused}
class="scroll-fade-x -m-1 flex scroll-px-1 items-center gap-1 p-1 max-sm:[scrollbar-width:none] max-sm:overflow-x-auto sm:flex-wrap"
>
{#each filterableColumns as col (col.column)}
<AdvancedTableFilter
title={col.label}
options={col.filterableValues!}
value={requestOptions.filters?.[col.column!]?.[0]}
onChange={(value) => onFilterChange(col.column!, value)}
/>
{/each}
</div>
{/if}
</div>
</div>
{/if}
@@ -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<Paginated<T>>;
defaultSort?: SortRequest;
columns: AdvancedTableColumn<T>[];
@@ -47,6 +54,11 @@
let items: Paginated<T> | undefined = $state();
let searchValue = $state('');
let isRefreshing = $state(false);
let searchInput = $state<HTMLInputElement | null>(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<string | boolean>, 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<T>, 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 @@
<AdvancedTableToolbar
{columns}
bind:visibleColumns={tablePreferences.current.visibleColumns}
{requestOptions}
{searchValue}
bind:searchInput
{withoutSearch}
{refresh}
{onFilterChange}
/>
{#if (items?.pagination.totalItems === 0 && searchValue === '') || tablePreferences.current.length === 0}
<div class="my-5 flex flex-col items-center">
<Empty class="text-muted-foreground h-20" />
<p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
{#if !items}
<!-- Fast responses replace the skeleton before it fades in, so it never flashes -->
<div aria-busy="true" class="transition-opacity delay-(--t-skeleton-delay) starting:opacity-0">
<Skeleton class="h-10 w-full rounded-sm" />
{#each Array.from({ length: tablePreferences.current.length || 5 }, (_, i) => i) as i (i)}
<div class="flex h-(--row-2line) items-center border-b px-4">
<Skeleton class="h-3.5 w-2/5" />
</div>
{/each}
</div>
{:else}
{#if !items}
<div>
{#each Array.from({ length: (tablePreferences.current.length || 10) + 1 }, (_, i) => i) as i (i)}
<div>
<Skeleton class="mt-3 h-[45px] w-full rounded-lg" />
<div aria-busy="true">
<Skeleton class="h-10 w-full rounded-sm" />
{#each Array.from({ length: tablePreferences.current.length || 10 }, (_, i) => i) as i (i)}
<div class="flex h-(--row-2line) items-center border-b px-4">
<Skeleton class="h-3.5 w-2/5" />
</div>
{/each}
</div>
{:else}
<div in:fade>
<!-- Refetches keep the old rows dimmed until the new page arrives, after a short delay so fast responses never flash -->
<div
in:fade={{ duration: 120 }}
aria-busy={isRefreshing}
class={cn(
'@container transition-opacity duration-(--dur-base)',
isRefreshing && 'pointer-events-none opacity-50 delay-(--t-dim-delay)'
)}
>
<Table.Root class="min-w-full table-auto">
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head class="w-12">
<!-- Selecting a row must not also open it -->
<Table.Cell class="w-10" onclick={(e) => e.stopPropagation()}>
<Checkbox
disabled={selectionDisabled}
checked={allChecked}
onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
checked={selected}
aria-labelledby={nameId}
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
/>
</Table.Head>
{/if}
{#each visibleColumns as column (column.key ?? column.column ?? column.label)}
<Table.Head
class={cn(column.sortable && 'p-0')}
aria-sort={column.sortable
? requestOptions.sort?.column === column.column
? requestOptions.sort?.direction === 'asc'
? 'ascending'
: 'descending'
: 'none'
: undefined}
>
{#if column.sortable}
<Button
variant="ghost"
class="h-12 w-full justify-start px-4 font-medium hover:bg-transparent"
onclick={() => onSort(column.column)}
>
<span class="flex w-full items-center">
{column.label}
<ChevronDown
class={cn(
'ml-2 size-4 transition-all',
requestOptions.sort?.column === column.column
? requestOptions.sort?.direction === 'asc'
? 'rotate-180 opacity-100'
: 'opacity-100'
: 'opacity-0'
)}
/>
</span>
</Button>
{:else}
{column.label}
{#each visibleColumns as column, i (column.key ?? column.column ?? column.label)}
<Table.Cell class={columnClass(column, i)} id={i === 0 ? nameId : undefined}>
{#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}
</Table.Head>
{/each}
{#if actions}
<Table.Head align="right" class="bg-card sticky right-0 z-10 w-12">
<span class="sr-only">{m.actions()}</span>
</Table.Head>
{/if}
<!-- Pinned to the right edge so the actions stay reachable when the table scrolls horizontally -->
<Table.Head class="sticky right-0 z-(--z-sticky) w-px pr-2 pl-2 text-right">
<AdvancedTableColumnSelection
{columns}
bind:selectedColumns={tablePreferences.current.visibleColumns}
/>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item (item.id)}
{@const selected = selectedIds?.includes(item.id)}
<Table.Row
class={{
'group/row': true,
'bg-muted/20': selectedIds?.includes(item.id),
'cursor-pointer': getPrimaryAction(item) || onRowClick
}}
data-state={selected ? 'selected' : undefined}
class={cn('group/row', (getPrimaryAction(item) || onRowClick) && 'cursor-pointer')}
onclick={onRowClick ? () => onRowClick(item) : getPrimaryAction(item)}
>
{#if selectedIds}
<Table.Cell class="w-12">
<Table.Cell class="w-10">
<Checkbox
disabled={selectionDisabled || rowSelectionDisabled?.(item)}
checked={selectedIds.includes(item.id)}
checked={selected}
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
/>
</Table.Cell>
{/if}
{#each visibleColumns as column (column.key ?? column.column ?? column.label)}
<Table.Cell>
{#each visibleColumns as column, i (column.key ?? column.column ?? column.label)}
<Table.Cell class={columnClass(column, i)}>
{#if column.value}
{column.value(item)}
{:else if column.cell}
@@ -300,26 +335,41 @@
{/if}
</Table.Cell>
{/each}
{#if actions}
<!-- Pinned to the right edge so the actions stay reachable when the table scrolls horizontally -->
<Table.Cell
align="right"
class="bg-card sticky right-0 z-10 w-12 py-0 transition-colors group-hover/row:bg-[color-mix(in_oklab,var(--color-muted)_50%,var(--color-card))]"
>
<ActionsMenu {item} actions={actions(item)} />
</Table.Cell>
{/if}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<ListPagination
pagination={items?.pagination}
pageSizes={availablePageSizes}
{onPageChange}
{onPageSizeChange}
/>
<!-- The sticky cell is opaque so scrolled cells pass under it, so it repeats the row's hover and selected fills -->
<Table.Cell
class="bg-surface group-hover/row:bg-fill-row-hover group-has-focus-visible/row:bg-fill-row-hover group-data-[state=selected]/row:bg-fill-muted sticky right-0 z-(--z-sticky) w-px py-0 pr-2 pl-2 text-right transition-colors duration-(--dur-fast)"
>
{#if actions}
<ActionsMenu
{item}
actions={actions(item)}
label={m.actions_for_name({ name: String(item[columns[0].column!]) })}
size="icon-sm"
/>
{/if}
</Table.Cell>
{/if}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{#if items.data.length === 0}
<p
class="text-control text-fg-muted flex min-h-(--row-2line) flex-wrap items-center gap-x-3 border-b px-4 wrap-anywhere"
>
{#if query}
{m.no_matches_for_query({ query })}
{:else if narrowed}
{m.no_matches_for_filters()}
{:else}
{m.no_items_found()}
{/if}
{#if narrowed}
<Button variant="link" onclick={clearQuery}>
{query ? m.clear_search() : m.clear_filters()}
</Button>
{/if}
</p>
{/if}
</div>
{/if}
@@ -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
};
@@ -1,20 +0,0 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '#lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-slot="pagination-content"
class={cn('flex items-center gap-1', className)}
{...restProps}
>
{@render children?.()}
</ul>
@@ -1,25 +0,0 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef, type WithoutChildren } from '#lib/utils/style.js';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
aria-hidden="true"
data-slot="pagination-ellipsis"
class={cn(
"text-fg-muted flex size-(--h-sm) items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
<MoreHorizontalIcon />
<span class="sr-only">More pages</span>
</span>
@@ -1,14 +0,0 @@
<script lang="ts">
import type { HTMLLiAttributes } from 'svelte/elements';
import type { WithElementRef } from '#lib/utils/style.js';
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li bind:this={ref} data-slot="pagination-item" {...restProps}>
{@render children?.()}
</li>
@@ -1,42 +0,0 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import { cn } from '#lib/utils/style.js';
import { buttonVariants, type ButtonSize } from '#lib/components/ui/button/index.js';
let {
ref = $bindable(null),
class: className,
size = 'icon-sm',
isActive,
page,
children,
...restProps
}: PaginationPrimitive.PageProps & {
size?: ButtonSize;
isActive: boolean;
} = $props();
</script>
{#snippet Fallback()}
{page.value}
{/snippet}
<PaginationPrimitive.Page
bind:ref
{page}
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
data-size={size}
class={cn(
buttonVariants({ size, variant: isActive ? 'secondary' : 'ghost' }),
'tabular-nums',
className
)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
{@render Fallback()}
{/if}
</PaginationPrimitive.Page>
@@ -1,31 +0,0 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '#lib/utils/style.js';
import { buttonVariants } from '../button/index.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: PaginationPrimitive.NextButtonProps = $props();
</script>
{#snippet Fallback()}
<span class="max-sm:sr-only">Next</span>
<ChevronRightIcon class="size-4" />
{/snippet}
<PaginationPrimitive.NextButton
bind:ref
aria-label="Go to next page"
class={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'max-sm:px-2', className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
{@render Fallback()}
{/if}
</PaginationPrimitive.NextButton>
@@ -1,22 +0,0 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import { cn } from '#lib/utils/style.js';
import { buttonVariants } from '#lib/components/ui/button/index.js';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
let {
ref = $bindable(null),
class: className,
...restProps
}: PaginationPrimitive.NextButtonProps = $props();
</script>
<PaginationPrimitive.NextButton
bind:ref
aria-label="Go to next page"
class={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'max-sm:px-2', className)}
{...restProps}
>
<span class="max-sm:sr-only">Next</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationPrimitive.NextButton>
@@ -1,31 +0,0 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import { cn } from '#lib/utils/style.js';
import { buttonVariants } from '../button/index.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: PaginationPrimitive.PrevButtonProps = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
<span class="max-sm:sr-only">Previous</span>
{/snippet}
<PaginationPrimitive.PrevButton
bind:ref
aria-label="Go to previous page"
class={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'max-sm:px-2', className)}
{...restProps}
>
{#if children}
{@render children?.()}
{:else}
{@render Fallback()}
{/if}
</PaginationPrimitive.PrevButton>
@@ -1,22 +0,0 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import { cn } from '#lib/utils/style.js';
import { buttonVariants } from '#lib/components/ui/button/index.js';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
let {
ref = $bindable(null),
class: className,
...restProps
}: PaginationPrimitive.PrevButtonProps = $props();
</script>
<PaginationPrimitive.PrevButton
bind:ref
aria-label="Go to previous page"
class={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'max-sm:px-2', className)}
{...restProps}
>
<ChevronLeftIcon data-icon="inline-start" />
<span class="max-sm:sr-only">Previous</span>
</PaginationPrimitive.PrevButton>
@@ -1,28 +0,0 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from 'bits-ui';
import { cn } from '#lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
count = 0,
perPage = 10,
page = $bindable(1),
siblingCount = 1,
...restProps
}: PaginationPrimitive.RootProps = $props();
</script>
<PaginationPrimitive.Root
bind:ref
bind:page
role="navigation"
aria-label="pagination"
data-slot="pagination"
{count}
{perPage}
{siblingCount}
class={cn('mx-auto flex w-full justify-center', className)}
{...restProps}
/>
@@ -9,9 +9,12 @@
class: className,
children,
size = 'default',
variant = 'filled',
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: 'sm' | 'default';
// Ghost triggers read as text inside a sentence or a filter row (facet filters, the page size)
variant?: 'filled' | 'ghost';
} = $props();
</script>
@@ -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?.()}
<ChevronDownIcon class="text-fg-muted pointer-events-none size-3.5" />
<ChevronDownIcon
class={cn('text-fg-muted pointer-events-none', variant === 'ghost' ? 'size-3' : 'size-3.5')}
/>
</SelectPrimitive.Trigger>
@@ -1,42 +1,61 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import unsavedChanges from '#lib/stores/unsaved-changes-store.svelte.ts';
import { cn } from '#lib/utils/style.ts';
import { LucideCircleAlert } from '@lucide/svelte';
import { LucideCircleAlert, LucideCircleCheck } from '@lucide/svelte';
import { tick } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { prefersReducedMotion } from 'svelte/motion';
const ROW_HEIGHT = 44;
const BOTTOM_OFFSET = 20;
const ROW_HEIGHT = 52;
const VIEWPORT_MARGIN = 16;
const INSET = 18;
const INSET = 16;
const INSET_BUTTONS = 8;
const MOTION =
'duration-[380ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none';
const MOTION = 'duration-[380ms] ease-[cubic-bezier(0.22,1,0.36,1)]';
const LAYER = 'absolute top-0 flex items-center whitespace-nowrap';
const SLOT_GLYPH = 'col-start-1 row-start-1 size-3.5 transition-opacity duration-(--dur-fast)';
type BloomTint = 'pending' | 'success' | 'error';
let shake = $state(false);
let blocked = $state(false);
let bloom = $state<{ tint: BloomTint }>();
let pillWidth = $state<number>();
let barWidth = $state<number>();
let compact = $state(false);
let column = $state<{ left: number; width: number }>();
let frame = $state<HTMLElement>();
let pendingMessage = $state<HTMLElement>();
let statusMessage = $state<HTMLElement>();
let actions = $state<HTMLElement>();
const animate = $derived(!$appConfigStore?.disableAnimations);
const animate = $derived(!$appConfigStore?.disableAnimations && !prefersReducedMotion.current);
const status = $derived(unsavedChanges.status);
const showingStatus = $derived(!!status);
const showingActions = $derived(unsavedChanges.hasChanges);
const barOpen = $derived(showingActions || showingStatus);
const pendingText = $derived(
unsavedChanges.dirtyCount === 1
? m.one_unsaved_change()
: m.count_unsaved_changes({ count: unsavedChanges.dirtyCount })
);
const announcement = $derived(
status?.message ??
(blocked ? m.save_or_discard_before_leaving() : showingActions ? pendingText : '')
);
const modKey = navigator.userAgent.includes('Mac') ? '⌘' : 'Ctrl+';
// Settled means in view; the other message waits a row height away
// The status line keeps its last message while it slides away, so the bar does not shrink under it
let shownStatus = $state<typeof status>(null);
$effect(() => {
if (status) shownStatus = status;
});
// Settled means in view; the other line waits a row height away
const pendingSettled = $derived(!showingStatus && !compact);
const statusSettled = $derived(showingStatus && !compact);
@@ -53,29 +72,41 @@
if (status) flashBloom(status.type === 'error' ? 'error' : 'success');
});
// The bar is mounted outside the shell, so it follows the page's <main> to center on the content column rather than the window
function measureColumn() {
const rect = document.querySelector('main')?.getBoundingClientRect();
column = rect && { left: rect.left, width: rect.width };
}
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;
// The line carries its own trailing inset; 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 : 0;
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);
barWidth = 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
// Catches what no state change announces: a longer message, a font loading, the viewport resizing, the sidebar collapsing
$effect(() => {
const layers = [pendingMessage, statusMessage, actions, frame].filter((layer) => !!layer);
if (layers.length === 0) return;
if (!barOpen) return;
const main = document.querySelector('main');
const layers = [pendingMessage, statusMessage, actions, main, main?.parentElement].filter(
(layer) => !!layer
);
const observer = new ResizeObserver(measure);
const observer = new ResizeObserver(() => {
measureColumn();
measure();
});
for (const layer of layers) observer.observe(layer);
return () => observer.disconnect();
@@ -98,12 +129,14 @@
if (nav.type === 'leave') return;
shake = true;
blocked = true;
flashBloom('pending');
});
// A fresh edit supersedes any status message left over from a previous save.
$effect(() => {
if (unsavedChanges.hasChanges) unsavedChanges.clearStatus();
else blocked = false;
});
function handleSaveShortcut(event: KeyboardEvent) {
@@ -141,37 +174,38 @@
}
await tick();
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
field.scrollIntoView({ block: 'center', behavior: animate ? 'smooth' : 'instant' });
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 mr-2 ml-2.5 h-4.5 w-px shrink-0"></span>
{/snippet}
<p class="sr-only" aria-live="polite">{announcement}</p>
{#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"
class={cn(
'fixed bottom-5 z-(--z-bar) flex max-sm:bottom-[calc(76px+env(safe-area-inset-bottom))]',
compact ? 'justify-end' : 'justify-center'
)}
style:left="{column?.left ?? 0}px"
style:width={column ? `${column.width}px` : '100%'}
style:padding-inline="{VIEWPORT_MARGIN}px"
transition:rise
class:animate-shake={shake}
onanimationend={() => (shake = false)}
>
<div
role="status"
aria-live="polite"
role="region"
aria-label={m.unsaved_changes()}
class={cn(
'bg-popover text-popover-foreground shadow-raised dark:bg-secondary relative isolate max-w-full overflow-hidden rounded-full border',
'bg-inverse text-inverse-fg shadow-overlay inset-ring-inverse-border relative isolate max-w-full overflow-hidden rounded-lg inset-ring',
animate && `transition-[width] ${MOTION}`
)}
style:height="{ROW_HEIGHT}px"
style:width={pillWidth ? `${pillWidth}px` : undefined}
style:width={barWidth ? `${barWidth}px` : undefined}
>
{#if animate && bloom}
{#key bloom}
@@ -184,44 +218,50 @@
<span
bind:this={pendingMessage}
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
class={cn(LAYER, 'gap-2.5 pr-4', 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 aria-hidden="true" class="grid size-3.5 place-items-center">
<span class="bg-warning-solid size-2 rounded-full"></span>
</span>
<span class="text-control font-medium tabular-nums">{pendingText}</span>
</span>
<span
bind:this={statusMessage}
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
class={cn(LAYER, 'gap-2.5 pr-4', 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 aria-hidden="true" class="grid">
<LucideCircleCheck
class={cn(
SLOT_GLYPH,
'text-success-solid',
shownStatus?.type !== 'success' && 'opacity-0'
)}
/>
<LucideCircleAlert
class={cn(
SLOT_GLYPH,
'text-danger-solid',
shownStatus?.type !== 'error' && 'opacity-0'
)}
/>
</span>
<span class="text-control font-medium">{shownStatus?.message ?? ''}</span>
</span>
<span
bind:this={actions}
class={cn(
LAYER,
'gap-1',
animate && `transition-[opacity,scale] ${MOTION}`,
!showingActions && 'scale-[0.96] opacity-0'
)}
@@ -232,15 +272,34 @@
<Button
variant="ghost"
size="sm"
class="hover:bg-foreground/10 dark:hover:bg-foreground/10"
class="text-inverse-fg before:bg-inverse-hover focus-visible:outline-inverse-fg"
disabled={unsavedChanges.saving}
onclick={() => unsavedChanges.discardAll()}
>
{m.discard()}
</Button>
<Button class="ml-1.5" size="sm" isLoading={unsavedChanges.saving} onclick={saveAll}>
{m.save()}
</Button>
<!-- The bar lives outside the settings shell's tooltip provider -->
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="inverse"
size="sm"
isLoading={unsavedChanges.saving}
onclick={saveAll}
>
{m.save()}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
{m.save()}
<kbd class="text-inverse-muted font-sans">{modKey}S</kbd>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</span>
</div>
</div>
@@ -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);
@@ -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<void>;
discard: () => void;
@@ -19,7 +19,7 @@ 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 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<SaveResult> {
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<SaveResult> {
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();
}
}
@@ -10,9 +10,10 @@ export type AdvancedTableColumn<T extends Record<string, any>> = {
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<T extends Record<string, any>> = (
item: T
+1 -1
View File
@@ -5,7 +5,7 @@ export type AuditLog = {
country?: string;
city?: string;
device: string;
userId: string;
userID: string;
username?: string;
actorUsername?: string;
createdAt: string;
+5
View File
@@ -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' });
}
+9 -38
View File
@@ -5,7 +5,6 @@ import { z } from 'zod/v4';
export type FormInput<T> = {
value: T;
error: string | null;
required: boolean;
};
type FormInputs<T> = {
@@ -26,17 +25,11 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
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 baseline) {
if (Object.prototype.hasOwnProperty.call(baseline, key)) {
const fieldSchema = shape[key];
inputs[key as keyof z.infer<T>] = {
value: deepCopy(baseline[key as keyof z.infer<T>]),
error: null,
required: fieldSchema ? isRequired(fieldSchema) : false
error: null
};
}
}
@@ -84,11 +77,15 @@ export function createForm<T extends z.ZodType<any, any>>(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<T>].value, baseline[key as keyof z.infer<T>])
);
).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<T extends z.ZodType<any, any>>(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<T extends z.ZodType<any, any>>(schema: T, initialValu
setValue,
reset,
clearErrors,
changedCount,
isDirty,
commit
};
@@ -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<void>,
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<T> {
inputs: Readable<unknown>;
isDirty: () => boolean;
changedCount: () => number;
validate: () => T | null;
commit: (data: T) => void;
reset: () => void;
@@ -48,29 +48,28 @@ export function trackFormChanges<T>(
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<T>(
};
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<string, unknown>;
return Object.entries(value).filter(([key, item]) => !deepEqual(item, savedSettings[key]))
.length;
}
return deepEqual(value, saved) ? 0 : 1;
},
async () => {
await save(deepCopy(read()));
markSaved();
+4 -32
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { refreshAll } from '$app/navigation';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import IdentityRow from '#lib/components/identity-row.svelte';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import ScopeList from '#lib/components/scope-list.svelte';
import * as Avatar from '#lib/components/ui/avatar/index.ts';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Card from '#lib/components/ui/card/index.ts';
import { m } from '#lib/paraglide/messages.js';
@@ -12,7 +12,6 @@
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import userStore from '#lib/stores/user-store.ts';
import type { InteractionStep } from '#lib/types/oidc.type.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
import { getAxiosErrorCode, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { startAuthentication } from '@simplewebauthn/browser';
@@ -33,19 +32,6 @@
// Every requested scope starts selected so the user only has to deselect the ones they do not want to grant
let selectedScopes = $state([...(data.interactionSession.scopes ?? [])]);
const fullName = $derived.by(() => {
if (!$userStore) {
return '';
}
if ($userStore.displayName) {
return $userStore.displayName;
}
return [$userStore.firstName, $userStore.lastName].filter(Boolean).join(' ').trim();
});
const primaryName = $derived(fullName || $userStore?.email || '');
async function useDifferentAccount() {
await webauthnService.logout();
await completeInteraction('select_account', true);
@@ -154,23 +140,9 @@
data-testid="account-selection"
>
{#if $userStore}
<Card.Root class="mx-5 mb-2 w-full py-4 sm:min-w-sm">
<Card.Content class="flex items-center gap-4">
<Avatar.Root class="size-11 shrink-0">
<Avatar.Image src={cachedProfilePicture.getUrl($userStore.id)} />
</Avatar.Root>
<div class="flex min-w-0 flex-col text-start">
<p class="truncate text-base leading-tight font-medium">
{primaryName}
</p>
{#if fullName && $userStore.email}
<p class="text-muted-foreground mt-1 truncate text-sm leading-tight">
{$userStore.email}
</p>
{/if}
</div>
</Card.Content>
</Card.Root>
<div class="mx-5 mb-2 w-full sm:min-w-sm">
<IdentityRow user={$userStore} />
</div>
{/if}
<div class="mb-10 flex justify-center">
<button
@@ -1,12 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import CopyToClipboard from '#lib/components/copy-to-clipboard.svelte';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import Qrcode from '#lib/components/qrcode/qrcode.svelte';
import QrPanel from '#lib/components/qrcode/qr-panel.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Card from '#lib/components/ui/card/index.ts';
import { Separator } from '#lib/components/ui/separator/index.ts';
import { Spinner } from '#lib/components/ui/spinner/index.ts';
import { m } from '#lib/paraglide/messages.js';
import DeviceLoginService from '#lib/services/device-login-service.ts';
@@ -14,7 +11,6 @@
import type { DeviceLoginRequest } from '#lib/types/device-login.type.ts';
import { getAxiosErrorMessage } from '#lib/utils/error-util.ts';
import { isAxiosError } from 'axios';
import { mode } from 'mode-watcher';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
@@ -153,33 +149,9 @@
{m.creating_device_login_request()}
</div>
{:else if request && !errorMessage}
<Card.Root class="mt-8 w-full max-w-sm shrink-0">
<Card.Content class="flex flex-col items-center gap-5">
<Qrcode
value={request.verificationUriComplete}
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
aria-label={m.device_login_qr_code()}
class="h-[12dvh]"
/>
<div class="flex w-full items-center gap-3">
<Separator class="flex-1" />
<span class="text-muted-foreground text-xs">{m.or()}</span>
<Separator class="flex-1" />
</div>
<div>
<p class="text-muted-foreground mb-2 text-sm">
{m.visit_and_enter({ url: request.verificationUri })}
</p>
<CopyToClipboard value={request.userCode}>
<p class="text-xl font-bold tracking-wider sm:text-2xl" data-testid="device-login-code">
{request.userCode.substring(0, 4)}
<span class="text-muted-foreground font-normal">-</span>
{request.userCode.substring(4, 8)}
</p>
</CopyToClipboard>
</div>
</Card.Content>
</Card.Root>
<div class="mt-8 w-full max-w-sm shrink-0">
<QrPanel {request} />
</div>
{/if}
<div class="mt-7 flex w-full max-w-112.5 gap-3 md:mt-15">
<Button class="flex-1" href={'/login/alternative' + page.url.search} variant="secondary"
@@ -1,14 +1,10 @@
<script lang="ts">
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
import GlassRowItem from '#lib/components/passkey-row.svelte';
import * as Item from '#lib/components/ui/item/index.js';
import PasskeyRow from '#lib/components/passkey-row.svelte';
import { m } from '#lib/paraglide/messages.js';
import WebauthnService from '#lib/services/webauthn-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';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
@@ -18,14 +14,12 @@
let passkeyToRename: Passkey | null = $state(null);
const isLightMode = $derived(mode.current === 'light');
async function deletePasskey(passkey: Passkey) {
openConfirmDialog({
title: m.delete_passkey_name({ passkeyName: passkey.name }),
message: m.are_you_sure_you_want_to_delete_this_passkey(),
confirm: {
label: m.delete(),
label: m.delete_name_action({ name: passkey.name }),
destructive: true,
action: async () => {
try {
@@ -41,20 +35,15 @@
}
</script>
<Item.Group class="mt-3">
<div class="mt-3 divide-y overflow-hidden rounded-md border">
{#each passkeys as passkey (passkey.id)}
<GlassRowItem
label={passkey.name}
description={m.added_on() + ' ' + new Date(passkey.createdAt).toLocaleDateString()}
icon={LucideKeyRound}
providerIconUrl={passkey.hasIcon
? authenticatorIconUrl(passkey.aaguid, isLightMode)
: undefined}
<PasskeyRow
{passkey}
onRename={() => (passkeyToRename = passkey)}
onDelete={() => deletePasskey(passkey)}
/>
{/each}
</Item.Group>
</div>
<RenamePasskeyModal
bind:passkey={passkeyToRename}
@@ -1,6 +1,6 @@
<script lang="ts">
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}
<ImageBox
class="size-12 rounded-lg"
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
alt={m.name_logo({ name: item.name })}
/>
{:else}
<div class="bg-muted flex size-12 items-center justify-center rounded-lg text-lg font-bold">
{item.name.charAt(0).toUpperCase()}
</div>
{/if}
<OidcClientAvatar id={item.id} name={item.name} hasLogo={item.hasLogo || item.hasDarkLogo} />
{/snippet}
<AdvancedTable
@@ -1,13 +1,11 @@
<script lang="ts">
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<OidcClientWithAllowedGroups>[] = [
{ label: 'ID', column: 'id', hidden: true },
{ label: m.logo(), key: 'logo', cell: LogoCell },
@@ -72,17 +68,7 @@
</script>
{#snippet LogoCell({ item }: { item: OidcClient })}
{#if item.hasLogo || item.hasDarkLogo}
<ImageBox
class="size-12 rounded-lg"
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
alt={m.name_logo({ name: item.name })}
/>
{:else}
<div class="bg-muted flex size-12 items-center justify-center rounded-lg text-lg font-bold">
{item.name.charAt(0).toUpperCase()}
</div>
{/if}
<OidcClientAvatar id={item.id} name={item.name} hasLogo={item.hasLogo || item.hasDarkLogo} />
{/snippet}
<AdvancedTable
@@ -1,14 +1,10 @@
<script lang="ts">
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 @@
}
</script>
<Item.Group class="mt-3">
<div class="mt-3 divide-y overflow-hidden rounded-md border">
{#each [...passkeys].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) as passkey (passkey.id)}
<PasskeyRow
label={passkey.name}
description={m.added_on() + ' ' + new Date(passkey.createdAt).toLocaleDateString()}
icon={LucideKeyRound}
providerIconUrl={passkey.hasIcon
? authenticatorIconUrl(passkey.aaguid, isLightMode)
: undefined}
showRenameAction={false}
onDelete={() => deletePasskey(passkey)}
/>
<PasskeyRow {passkey} onDelete={() => deletePasskey(passkey)} />
{/each}
</Item.Group>
</div>
+25 -9
View File
@@ -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');
+10 -5
View File
@@ -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();
});
});
+5 -1
View File
@@ -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);
+4 -3
View File
@@ -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);
});
+8 -6
View File
@@ -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
+19 -19
View File
@@ -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()
+5 -1
View File
@@ -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');
+3 -3
View File
@@ -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');
+13 -11
View File
@@ -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();
+2 -2
View File
@@ -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();
+5 -1
View File
@@ -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();
}