mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 12:09:05 +01:00
feat(frontend): add v3 compositions for pages, settings, tables and identity
This commit is contained in:
+54
-15
@@ -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
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ export type AuditLog = {
|
||||
country?: string;
|
||||
city?: string;
|
||||
device: string;
|
||||
userId: string;
|
||||
userID: string;
|
||||
username?: string;
|
||||
actorUsername?: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user