feat(frontend): rebuild screens with the v3 design

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent 42c81c4687
commit a962c88a5c
215 changed files with 12558 additions and 10161 deletions
@@ -44,6 +44,7 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
},
"background.webp": {
mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
mustDecodeHex("fc9ea4a7d9ac36321d9d5a34e7f0c346f5cb47254e1ee8fb75aac8b58db04230"),
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

+766 -426
View File
File diff suppressed because it is too large Load Diff
+59 -43
View File
@@ -10,7 +10,6 @@
--h-xs: 26px;
--h-sm: 34px;
--h-md: 42px;
--h-lg: 48px;
--h-xl: 48px;
--row-dense: 36px;
--row: 44px;
@@ -463,20 +462,64 @@ html.theme-switching *::after {
transition-timing-function: var(--ease-out);
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
opacity: 0;
/* A scroller fades at the edge that has more content; without overflow its scroll timeline is inactive, so nothing fades */
@property --fade-start {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
@property --fade-end {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
@supports (animation-timeline: scroll()) {
.scroll-fade-x,
.scroll-fade-y {
animation:
scroll-fade-start linear both,
scroll-fade-end linear both;
animation-range:
0 24px,
calc(100% - 24px) 100%;
}
.scroll-fade-x {
mask-image: linear-gradient(
to right,
transparent,
#000 var(--fade-start),
#000 calc(100% - var(--fade-end)),
transparent
);
animation-timeline: scroll(self inline), scroll(self inline);
}
.scroll-fade-y {
mask-image: linear-gradient(
transparent,
#000 var(--fade-start),
#000 calc(100% - var(--fade-end)),
transparent
);
animation-timeline: scroll(self), scroll(self);
}
}
@keyframes scroll-fade-start {
to {
opacity: 1;
transform: translateY(0);
--fade-start: 24px;
}
}
@keyframes scroll-fade-end {
from {
--fade-end: 24px;
}
to {
--fade-end: 0px;
}
}
@@ -489,11 +532,15 @@ html.theme-switching *::after {
animation: none;
}
::view-transition-group(settings-content) {
/* The phone bars swap instantly and stay above the content, whose new snapshot is otherwise painted last because only the new page has its name */
::view-transition-group(phone-top-bar),
::view-transition-group(phone-nav),
::view-transition-group(phone-search) {
z-index: 1;
animation: none;
}
::view-transition-old(settings-content) {
::view-transition-old(settings-content-old) {
animation: 120ms ease-out both vt-fade-out;
}
@@ -513,37 +560,6 @@ html.theme-switching *::after {
}
}
@keyframes bg-zoom {
0% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
}
.animate-bg-zoom {
transform-origin: center;
will-change: transform;
animation: bg-zoom 0.7s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
}
@keyframes delayed-fade {
0%,
40% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.animate-delayed-fade {
animation: delayed-fade 0.5s ease-out forwards;
}
@keyframes shake {
0%,
100% {
@@ -1,21 +0,0 @@
<script lang="ts">
import { m } from '#lib/paraglide/messages.js';
let {
hasAccess,
granted,
total
}: {
hasAccess: boolean;
granted: number;
total: number;
} = $props();
</script>
{#if !hasAccess}
<span class="text-muted-foreground text-sm">{m.no_access()}</span>
{:else}
<span class="text-sm">
{m.permissions_granted_count({ granted: String(granted), total: String(total) })}
</span>
{/if}
@@ -1,14 +1,14 @@
<script lang="ts">
import SwitchWithLabel from '#lib/components/form/switch-with-label.svelte';
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
import SettingRow from '#lib/components/setting-row.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
import { Checkbox } from '#lib/components/ui/checkbox/index.ts';
import * as Dialog from '#lib/components/ui/dialog/index.ts';
import { Switch } from '#lib/components/ui/switch/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { AdvancedTableColumn } from '#lib/types/advanced-table.type.ts';
import type { Api, ApiClientGrant, ApiPermission } from '#lib/types/api.type.ts';
import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts';
import type { Api, ApiClientGrant } from '#lib/types/api.type.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { LucideInfo } from '@lucide/svelte';
import { untrack } from 'svelte';
let {
open = $bindable(),
@@ -30,100 +30,33 @@
onSave: (grant: ApiClientGrant) => Promise<void>;
} = $props();
let workingUserAccess = $state(false);
let workingClientAccess = $state(false);
let workingUser = $state<string[]>([]);
let workingClient = $state<string[]>([]);
let userAccess = $state(false);
let clientAccess = $state(false);
let userIds = $state<string[]>([]);
let clientIds = $state<string[]>([]);
let saving = $state(false);
$effect(() => {
if (open) {
workingUserAccess = grant.userDelegatedAccess;
workingClientAccess = grant.clientAccess;
workingUser = [...grant.userDelegatedPermissionIds];
workingClient = [...grant.clientPermissionIds];
}
if (!open) return;
untrack(() => {
userAccess = grant.userDelegatedAccess;
clientAccess = grant.clientAccess;
userIds = [...grant.userDelegatedPermissionIds];
clientIds = [...grant.clientPermissionIds];
});
});
const columns: AdvancedTableColumn<ApiPermission>[] = $derived([
{ label: m.name(), column: 'name', sortable: true },
{ label: m.key(), key: 'key', cell: KeyCell },
{ label: m.description(), key: 'description', value: (p) => p.description ?? '' },
{ label: m.user_delegated_access(), key: 'userDelegated', cell: UserDelegatedCell },
...(showClientAccess
? [{ label: m.client_access(), key: 'clientAccess', cell: ClientAccessCell }]
: [])
]);
function toggle(ids: string[], id: string, checked: boolean) {
if (checked) {
return ids.includes(id) ? ids : [...ids, id];
}
return ids.filter((existing) => existing !== id);
}
// A permission only makes sense together with access to the API, so checking one turns the access on and turning access off drops the selection
function toggleUserPermission(id: string, checked: boolean) {
workingUser = toggle(workingUser, id, checked);
if (checked) {
workingUserAccess = true;
}
}
function toggleClientPermission(id: string, checked: boolean) {
workingClient = toggle(workingClient, id, checked);
if (checked) {
workingClientAccess = true;
}
}
function fetchCallback(options: ListRequestOptions): Promise<Paginated<ApiPermission>> {
let data = api.permissions;
const search = options.search?.toLowerCase();
if (search) {
data = data.filter(
(p) =>
p.key.toLowerCase().includes(search) ||
p.name.toLowerCase().includes(search) ||
(p.description ?? '').toLowerCase().includes(search)
);
}
const column = options.sort?.column;
if (column) {
const direction = options.sort?.direction === 'desc' ? -1 : 1;
data = [...data].sort(
(a, b) =>
String((a as Record<string, unknown>)[column] ?? '').localeCompare(
String((b as Record<string, unknown>)[column] ?? '')
) * direction
);
}
const page = options.pagination?.page ?? 1;
const limit = options.pagination?.limit ?? 20;
const start = (page - 1) * limit;
return Promise.resolve({
data: data.slice(start, start + limit),
pagination: {
totalPages: Math.max(1, Math.ceil(data.length / limit)),
totalItems: data.length,
currentPage: page,
itemsPerPage: limit
}
});
}
const toggle = (ids: string[], id: string, checked: boolean) =>
checked ? [...ids, id] : ids.filter((existing) => existing !== id);
async function save() {
saving = true;
try {
await onSave({
userDelegatedAccess: workingUserAccess,
clientAccess: showClientAccess && workingClientAccess,
userDelegatedPermissionIds: workingUserAccess ? workingUser : [],
clientPermissionIds: showClientAccess && workingClientAccess ? workingClient : []
userDelegatedAccess: userAccess,
clientAccess: showClientAccess && clientAccess,
userDelegatedPermissionIds: userAccess ? userIds : [],
clientPermissionIds: showClientAccess && clientAccess ? clientIds : []
});
open = false;
} catch (e) {
@@ -134,87 +67,124 @@
}
</script>
{#snippet KeyCell({ item }: { item: ApiPermission })}
<span class="font-mono text-xs">{item.key}</span>
{/snippet}
{#snippet UserDelegatedCell({ item }: { item: ApiPermission })}
{@const implicit = implicitUserIds.includes(item.id)}
<Checkbox
aria-label={`${m.user_delegated_access()}: ${item.name}`}
checked={implicit || workingUser.includes(item.id)}
disabled={implicit}
title={implicit ? m.granted_through_cimd_access() : undefined}
onCheckedChange={(checked: boolean) => toggleUserPermission(item.id, checked)}
/>
{/snippet}
{#snippet ClientAccessCell({ item }: { item: ApiPermission })}
<Checkbox
aria-label={`${m.client_access()}: ${item.name}`}
checked={workingClient.includes(item.id)}
onCheckedChange={(checked: boolean) => toggleClientPermission(item.id, checked)}
/>
{/snippet}
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-250">
<!-- The scroll padding matches the sticky footer's height, so keyboard focus never lands under it -->
<Dialog.Content class="max-w-(--dialog-lg) scroll-pb-20 max-sm:scroll-pb-32">
<Dialog.Header>
<Dialog.Title>{title ?? api.name}</Dialog.Title>
<Dialog.Description>
{m.select_the_access_this_client_may_request()}
{#if !showClientAccess}
{m.client_access_unavailable_for_public_clients()}
{/if}
{#if implicitUserAccess}
{m.access_granted_through_cimd_access()}
{/if}
{m.choose_what_the_client_may_ask_for()}
{#if !showClientAccess}{m.public_clients_cant_act_as_themselves()}{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-4 sm:flex-row sm:gap-10">
<SwitchWithLabel
id={`api-user-access-${api.id}`}
label={m.user_delegated_access()}
description={m.user_delegated_access_description()}
checked={implicitUserAccess || workingUserAccess}
disabled={implicitUserAccess}
onCheckedChange={(checked) => {
workingUserAccess = checked;
if (!checked) {
workingUser = [];
}
}}
/>
<div>
<SettingRow title={m.on_behalf_of_people()} description={m.on_behalf_of_people_description()}>
{#snippet control(props)}
<Switch
{...props}
checked={implicitUserAccess || userAccess}
disabled={implicitUserAccess}
onCheckedChange={(checked) => {
userAccess = checked;
if (!checked) userIds = [];
}}
/>
{/snippet}
</SettingRow>
{#if showClientAccess}
<SwitchWithLabel
id={`api-client-access-${api.id}`}
label={m.client_access()}
description={m.client_access_description()}
bind:checked={workingClientAccess}
onCheckedChange={(checked) => {
if (!checked) {
workingClient = [];
}
}}
/>
<SettingRow title={m.as_itself()} description={m.as_itself_description()}>
{#snippet control(props)}
<Switch
{...props}
checked={clientAccess}
onCheckedChange={(checked) => {
clientAccess = checked;
if (!checked) clientIds = [];
}}
/>
{/snippet}
</SettingRow>
{/if}
</div>
{#if api.permissions.length > 0}
<div class="overflow-auto">
<AdvancedTable
id={`api-access-grants-${api.id}`}
{columns}
{fetchCallback}
defaultSort={{ column: 'name', direction: 'asc' }}
/>
</div>
{#if implicitUserAccess}
<p class="text-caption text-fg-muted flex items-start gap-1.5">
<LucideInfo class="mt-[3px] size-3 shrink-0" />
{m.access_granted_through_cimd_access()}
</p>
{/if}
<div class="mt-4 flex justify-end gap-2">
<Button variant="secondary" onclick={() => (open = false)}>{m.cancel()}</Button>
<Button isLoading={saving} onclick={save}>{m.save()}</Button>
</div>
{#if api.permissions.length > 0}
<table class="text-control w-full">
<thead>
<tr class="text-caption text-fg-muted border-b text-start">
<th scope="col" class="py-2 pe-3 text-start font-medium">{m.permission()}</th>
<th
scope="col"
class="w-22 py-1.5 pe-3 text-start font-medium sm:w-36 sm:whitespace-nowrap"
>
{m.on_behalf_of_people()}
</th>
{#if showClientAccess}
<th
scope="col"
class="w-22 py-1.5 text-start font-medium sm:w-24 sm:whitespace-nowrap"
>
{m.as_itself()}
</th>
{/if}
</tr>
</thead>
<tbody>
{#each api.permissions as permission (permission.id)}
{@const implicit = implicitUserIds.includes(permission.id)}
<tr class="border-b">
<td class="py-2 pe-3">
<span class="text-fg block font-medium wrap-anywhere">{permission.name}</span>
<span class="text-mono-s text-fg-muted block font-mono wrap-anywhere">
{permission.key}
</span>
</td>
<td>
<Checkbox
aria-label={`${m.on_behalf_of_people()}: ${permission.name}`}
checked={implicit || userIds.includes(permission.id)}
disabled={implicit}
title={implicit ? m.granted_through_cimd_access() : undefined}
onCheckedChange={(checked) => {
userIds = toggle(userIds, permission.id, checked);
if (checked) userAccess = true;
}}
/>
</td>
{#if showClientAccess}
<td>
<Checkbox
aria-label={`${m.as_itself()}: ${permission.name}`}
checked={clientIds.includes(permission.id)}
onCheckedChange={(checked) => {
clientIds = toggle(clientIds, permission.id, checked);
if (checked) clientAccess = true;
}}
/>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="text-caption text-fg-muted flex items-start gap-1.5">
<LucideInfo class="mt-[3px] size-3 shrink-0" />
{m.api_has_no_permissions_yet({ name: api.name })}
</p>
{/if}
<!-- The permission list can outgrow the dialog, so the actions stick to its bottom edge and cover its padding, keeping the usual 20px above them -->
<Dialog.Footer class="bg-popover sticky -bottom-5 -mx-6 -mt-2 -mb-5 px-6 pt-3 pb-5">
<Button variant="ghost" onclick={() => (open = false)}>{m.cancel()}</Button>
<Button isLoading={saving} onclick={save}>{m.save_access()}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -51,7 +51,7 @@
});
});
export async function refresh() {
async function refresh() {
isRefreshing = true;
try {
logs = isAdmin
@@ -355,7 +355,6 @@
<ListPagination
pagination={logs?.pagination.totalItems ? logs.pagination : undefined}
hideWhenSinglePage
onPageChange={(page) => {
requestOptions.pagination!.page = page;
refresh();
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<!-- On phones the decision sticks to the bottom of a sheet taller than the window, reaching the sheet's edges through the padding variables login-wrapper sets on its column -->
<!-- Its hairline shows only while the column reports content below the fold, and data-action-panel gives the column the scroll padding that keeps keyboard focus from hiding under it -->
<div
class="max-lg:bg-surface max-lg:group-data-content-below/sheet:border-border max-lg:sticky max-lg:-bottom-(--sheet-b) max-lg:-mx-(--sheet-x) max-lg:-mt-3 max-lg:-mb-(--sheet-b) max-lg:border-t max-lg:border-transparent max-lg:px-(--sheet-x) max-lg:pt-3 max-lg:pb-(--sheet-b) max-lg:transition-colors max-lg:duration-(--dur-fast)"
data-action-panel
>
{@render children()}
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import { cn } from '#lib/utils/style.ts';
import { LucideCircleAlert, LucideInfo } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let {
title,
tone,
children
}: {
title: string | Snippet;
// A reason replaces the subtitle in the text color, so a failure never shifts what is below it
tone?: 'info' | 'danger';
children?: Snippet;
} = $props();
</script>
<h1 class="font-display text-display-m sm:text-display-l mb-2 wrap-anywhere">
{#if typeof title === 'string'}
{title}
{:else}
{@render title()}
{/if}
</h1>
<p
class={cn('text-body-l mb-6 wrap-anywhere', tone ? 'text-fg' : 'text-fg-muted')}
aria-live="polite"
>
{#if tone === 'danger'}
<LucideCircleAlert class="text-danger-solid mr-1.5 inline size-3.5 align-[-1px]" />
{:else if tone === 'info'}
<LucideInfo class="text-fg-muted mr-1.5 inline size-3.5 align-[-1px]" />
{/if}
{@render children?.()}
</p>
@@ -0,0 +1,26 @@
<script lang="ts">
import Logo from '#lib/components/logo.svelte';
import { cn } from '#lib/utils/style.ts';
import { LucideCheck } from '@lucide/svelte';
let { success = false }: { success?: boolean } = $props();
</script>
<!-- The tile holds still while only its logo crossfades to the check, so the surface never dips -->
<div
class="bg-surface-raised inset-ring-border mb-6 grid size-14 shrink-0 place-items-center rounded-md inset-ring"
aria-hidden="true"
>
<Logo
class={cn(
'col-start-1 row-start-1 size-8 transition-opacity duration-(--dur-base) ease-out',
success && 'opacity-0'
)}
/>
<LucideCheck
class={cn(
'text-success-solid transition-motion col-start-1 row-start-1 size-5 duration-(--dur-base)',
!success && 'scale-60 opacity-0'
)}
/>
</div>
@@ -0,0 +1,41 @@
import { WebAuthnError } from '@simplewebauthn/browser';
import { page } from '$app/state';
import { m } from '#lib/paraglide/messages.js';
import { cssMs } from '#lib/utils/style.ts';
// The one filled button of an auth step, full width and taller in the desktop card
export const authPrimaryClass = 'h-(--h-xl) w-full text-heading lg:h-13';
// Every other path on an auth step is a quiet text action with a full-height hit area
export const authQuietClass =
'h-(--h-xl) px-3 text-body font-normal text-fg-muted hover:text-fg aria-busy:text-fg-muted';
// Other ways to sign in carries the current page, so signing in there returns to the app that asked
export function alternativeSignInHref() {
return page.url.pathname === '/login'
? `/login/alternative${page.url.search}`
: `/login/alternative?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`;
}
// Closing the browser's passkey prompt is the person's choice, so it reads as information rather than an error
export function isPasskeyPromptClosed(e: unknown) {
return (
e instanceof WebAuthnError &&
(e.code === 'ERROR_CEREMONY_ABORTED' ||
(e.cause as Error | undefined)?.name === 'NotAllowedError')
);
}
export function takingYouMessage(redirect?: string | null) {
return !redirect || redirect.startsWith('/settings')
? m.auth_taking_you_to_your_account()
: m.auth_taking_you_back();
}
// The success tile shows for a moment before the page moves on
export function successHold() {
return new Promise((resolve) => setTimeout(resolve, cssMs('--t-success-hold')));
}
// The address a login code was just emailed to, so the code page can say where to look and resend it
export const emailedLoginCode = { sentTo: '' };
@@ -1,13 +1,11 @@
<script lang="ts">
import Logo from '#lib/components/logo.svelte';
import OidcClientAvatar from '#lib/components/oidc-client-avatar.svelte';
import CheckmarkAnimated from '#lib/icons/checkmark-animated.svelte';
import ConnectArrow from '#lib/icons/connect-arrow.svelte';
import CrossAnimated from '#lib/icons/cross-animated.svelte';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { InteractionClient } from '#lib/types/oidc.type.ts';
import { cachedOidcClientLogo } from '#lib/utils/cached-image-util.ts';
import { mode } from 'mode-watcher';
import { untrack } from 'svelte';
import { cubicIn, cubicOut } from 'svelte/easing';
import { prefersReducedMotion, Tween } from 'svelte/motion';
@@ -24,11 +22,13 @@
client?: InteractionClient;
} = $props();
// Each tile travels this far so they meet in the middle
const TRAVEL = 108;
// The tiles start 152px apart, so they touch once each has covered this share of its travel
const CONTACT = 152 / (2 * TRAVEL);
// Kept below the previous 500ms slide so the animation never delays the redirect
// The 56px tiles start this far apart, with the 100px arrow and 12px on each side of it
const GAP = 124;
// The client tile travels onto the operator tile, so the merged tile sits on the stack's left edge like every other auth tile
const TRAVEL = 56 + GAP;
// The tiles touch once the client tile has covered this share of its travel
const CONTACT = GAP / TRAVEL;
// Kept short so the animation never delays the redirect
const PULL_MS = 350;
const SQUASH = [
@@ -47,9 +47,7 @@
{ transform: 'translateX(0)' }
];
const isLightMode = $derived(mode.current === 'light');
// The tiles accelerate into each other while the arrow snaps taut during the first third of the pull
// The client tile accelerates into the operator tile while the arrow snaps taut during the first third of the pull
const pull = new Tween(0, { duration: PULL_MS });
const travelled = $derived(cubicIn(pull.current));
const taut = $derived(cubicOut(Math.min(1, pull.current / 0.32)));
@@ -94,54 +92,52 @@
}
</script>
<div class="flex justify-center gap-3">
<div class="mb-6 flex items-center gap-3" aria-hidden="true">
<div
class="bg-muted rounded-2xl p-3"
style:transform="translateX({TRAVEL * travelled}px) scale({1 - 0.15 * travelled})"
class="bg-surface-raised inset-ring-border grid size-14 shrink-0 place-items-center rounded-md inset-ring"
style:transform="scale({1 - 0.15 * travelled})"
style:opacity={Math.min(1, (1 - travelled) / 0.15)}
>
<Logo class="size-10" animate={false} />
<Logo class="size-8" animate={false} />
</div>
<ConnectArrow
class="w-32 {travelled >= CONTACT ? 'invisible' : ''}"
class="w-25 shrink-0 {travelled >= CONTACT ? 'invisible' : ''}"
{taut}
squeeze={Math.min(1, travelled / CONTACT)}
/>
<div style:transform="translateX({-TRAVEL * travelled}px)">
<div class="shrink-0" style:transform="translateX({-TRAVEL * travelled}px)">
<div
bind:this={resultTile}
class="bg-muted relative overflow-hidden rounded-2xl p-3"
class="relative isolate size-14 overflow-hidden rounded-md"
class:animated
>
<!-- The outcome colour floods in from the edge where the tiles collided -->
<div
class="fill absolute inset-0 {result === 'error' ? 'bg-red-200' : 'bg-green-200'}"
class:flooded={result}
></div>
<div class="client relative size-10" class:faded={result}>
{#if client?.hasLogo || client?.hasDarkLogo}
<img
class="aspect-square size-10 object-contain"
src={cachedOidcClientLogo.getUrl(client.id, isLightMode)}
draggable={false}
alt={m.client_logo()}
<div class="client" class:faded={result}>
{#if client}
<OidcClientAvatar
id={client.id}
name={client.name}
hasLogo={client.hasLogo || client.hasDarkLogo}
size={56}
/>
{:else if client?.name}
<div class="flex size-10 items-center justify-center text-3xl font-bold">
{client.name.charAt(0).toUpperCase()}
</div>
{:else}
<div class="bg-fill-muted inset-ring-border size-14 rounded-md inset-ring"></div>
{/if}
</div>
<!-- The outcome color floods in from the edge where the tiles collided -->
<div
class="fill absolute inset-0 {result === 'error' ? 'bg-danger-soft' : 'bg-success-soft'}"
class:flooded={result}
></div>
{#if result}
<div class="absolute inset-0 flex items-center justify-center">
<div class="glyph absolute inset-0 grid place-items-center">
{#if result === 'success'}
<CheckmarkAnimated class="size-7" />
<CheckmarkAnimated class="stroke-success-solid! size-7" />
{:else}
<CrossAnimated class="size-5" />
<CrossAnimated class="stroke-danger-solid! size-5" />
{/if}
</div>
{/if}
@@ -173,4 +169,15 @@
opacity 160ms 40ms,
scale 160ms 40ms;
}
/* The global motion safety net zeroes animation durations but not delays, so the glyph strokes would still appear late and in steps */
:global(html.no-motion) .glyph :global(path) {
animation-delay: 0s;
}
@media (prefers-reduced-motion: reduce) {
.glyph :global(path) {
animation-delay: 0s;
}
}
</style>
@@ -0,0 +1,68 @@
<script lang="ts">
import * as InputOTP from '#lib/components/ui/input-otp/index.ts';
import { cn } from '#lib/utils/style.ts';
let {
value = $bindable(''),
id,
groups,
invalid = false,
expired = false,
readonly = false,
// Login codes are case-sensitive, device codes are not
uppercase = false,
onComplete,
...restProps
}: {
value?: string;
id: string;
groups: [number, number];
invalid?: boolean;
// An expired code keeps its characters in a muted treatment, because it is not a typo
expired?: boolean;
readonly?: boolean;
uppercase?: boolean;
onComplete?: (value: string) => void;
'aria-describedby'?: string;
autocomplete?: 'one-time-code';
autofocus?: boolean;
} = $props();
const slotClass = $derived(
cn('h-13 max-w-11 max-lg:h-11', !uppercase && 'normal-case', expired && 'text-fg-muted')
);
</script>
<!-- Pasting anywhere fills from the first cell, without the spaces and dashes people copy along -->
<!-- bits-ui's onComplete reruns whenever readonly or aria-invalid change, so a failed check would retry forever -->
<!-- The root ends where six or eight 44px cells and the 28px dash end, so the group outlines never stretch past the last cell -->
<InputOTP.Root
inputId={id}
class={groups[0] + groups[1] === 6 ? 'max-w-73' : 'max-w-95'}
maxlength={groups[0] + groups[1]}
bind:value
onValueChange={(v) => {
value = uppercase ? v.toUpperCase() : v;
if (value.length === groups[0] + groups[1]) onComplete?.(value);
}}
pasteTransformer={(text) => text.replace(/[^a-zA-Z0-9]/g, '')}
readonly={readonly || expired}
aria-invalid={invalid || undefined}
autocapitalize={uppercase ? 'characters' : 'off'}
spellcheck={false}
{...restProps}
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, groups[0]) as cell, i (i)}
<InputOTP.Slot {cell} class={slotClass} aria-invalid={invalid || undefined} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(groups[0]) as cell, i (i)}
<InputOTP.Slot {cell} class={slotClass} aria-invalid={invalid || undefined} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
@@ -0,0 +1,35 @@
<script lang="ts">
import { Spinner } from '#lib/components/ui/spinner/index.ts';
import { cn } from '#lib/utils/style.ts';
import { LucideCircleAlert, LucideInfo } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let {
id,
tone,
children
}: {
id: string;
tone?: 'checking' | 'error' | 'info';
children?: Snippet;
} = $props();
</script>
<!-- The line is always present, so a status appearing under the code never moves what is below it -->
<p
{id}
aria-live="polite"
class={cn(
'text-control mt-2 flex min-h-5 items-start gap-1.5',
tone === 'error' ? 'text-danger-fg' : tone === 'info' ? 'text-fg' : 'text-fg-muted'
)}
>
{#if tone === 'checking'}
<Spinner class="mt-1 size-3" />
{:else if tone === 'error'}
<LucideCircleAlert class="mt-1 size-3 shrink-0" />
{:else if tone === 'info'}
<LucideInfo class="mt-1 size-3 shrink-0" />
{/if}
<span>{@render children?.()}</span>
</p>
@@ -0,0 +1,24 @@
<script lang="ts">
import { m } from '#lib/paraglide/messages.js';
import type { InteractionClient } from '#lib/types/oidc.type.ts';
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
import { LucideCheck, LucideTriangleAlert } from '@lucide/svelte';
let { client }: { client: InteractionClient } = $props();
// Metadata-document clients registered themselves, so their name proves nothing
const host = $derived(getClientIDHost(client));
</script>
<div class="text-caption text-fg-muted -mt-4 mb-6 flex items-start gap-1.5">
{#if host}
<LucideTriangleAlert class="text-warning-solid mt-0.5 size-3 shrink-0" />
<div>
<p class="text-fg font-medium">{m.auth_not_registered_by_admin()}</p>
<p class="wrap-anywhere">{m.auth_not_registered_by_admin_description({ host })}</p>
</div>
{:else}
<LucideCheck class="mt-0.5 size-3 shrink-0" />
<p>{m.auth_registered_by_admin()}</p>
{/if}
</div>
@@ -1,97 +0,0 @@
<script lang="ts">
import { m } from '#lib/paraglide/messages.js';
import { cn } from '#lib/utils/style.ts';
import { LucideChevronDown, type LucideIcon } from '@lucide/svelte';
import { onMount, type Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { Button } from './ui/button';
import * as Card from './ui/card';
let {
id,
title,
description,
defaultExpanded = false,
forcedExpanded,
button,
icon,
children
}: {
id: string;
title: string;
description?: string;
defaultExpanded?: boolean;
forcedExpanded?: boolean;
icon?: LucideIcon;
button?: Snippet;
children: Snippet;
} = $props();
let expanded = $state(defaultExpanded);
function loadExpandedState() {
const state = JSON.parse(localStorage?.getItem('collapsible-cards-expanded') || '{}');
expanded = state[id] || false;
}
function saveExpandedState() {
const state = JSON.parse(localStorage?.getItem('collapsible-cards-expanded') || '{}');
state[id] = expanded;
localStorage?.setItem('collapsible-cards-expanded', JSON.stringify(state));
}
function toggleExpanded() {
expanded = !expanded;
saveExpandedState();
}
onMount(() => {
if (defaultExpanded) {
saveExpandedState();
}
loadExpandedState();
});
$effect(() => {
if (forcedExpanded !== undefined) {
expanded = forcedExpanded;
}
});
</script>
<Card.Root>
<Card.Header class="bg-card cursor-pointer" onclick={toggleExpanded}>
<div class="flex items-center justify-between">
<div>
<Card.Title class="flex items-center gap-2 text-xl font-semibold">
{#if icon}{@const Icon = icon}
<Icon class="text-primary/80 size-5" />
{/if}
{title}
</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
</div>
{#if button}
{@render button()}
{:else}
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
<LucideChevronDown
class={cn(
'size-5 transition-transform duration-200',
expanded && 'rotate-180 transform'
)}
/>
</Button>
{/if}
</div>
</Card.Header>
{#if expanded}
<div transition:slide={{ duration: 200 }}>
<Card.Content>
{@render children()}
</Card.Content>
</div>
{/if}
</Card.Root>
@@ -1,45 +0,0 @@
<script lang="ts">
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { LucideCheck } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let { value, children }: { value: string; children: Snippet } = $props();
let open = $state(false);
let copied = $state(false);
function onOpenChange(state: boolean) {
open = state;
if (!state) {
setTimeout(() => (copied = false), 500);
}
}
async function copyToClipboard() {
open = true;
await navigator.clipboard.writeText(value);
copied = true;
setTimeout(() => onOpenChange(false), 1500);
}
</script>
<Tooltip.Provider>
<Tooltip.Root {onOpenChange} {open} disableCloseOnTriggerClick>
<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}
<LucideCheck class="size-3.5" />
{m.copied()}
{:else}
{m.copy()}
{/if}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<span class="sr-only" aria-live="polite">{copied ? m.copied() : ''}</span>
@@ -0,0 +1,11 @@
<script lang="ts">
import { m } from '#lib/paraglide/messages.js';
import { LucideArrowUpRight } from '@lucide/svelte';
let { href }: { href: string } = $props();
</script>
<a {href} target="_blank" rel="noreferrer" class="inline-link inline-flex items-center gap-1">
{m.docs()}
<LucideArrowUpRight class="size-3" />
</a>
@@ -2,36 +2,21 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import * as Alert from '#lib/components/ui/alert/index.ts';
import { Button } from '#lib/components/ui/button/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 userStore from '#lib/stores/user-store.ts';
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { LucideAlertTriangle, LucideCheckCircle2, LucideCircleX } from '@lucide/svelte';
import { LucideCircleCheck, LucideCircleX, LucideTriangleAlert } from '@lucide/svelte';
import { formatDistanceToNow } from 'date-fns';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { get } from 'svelte/store';
const userService = new UserService();
let emailVerificationState = $state(page.url.searchParams.get('emailVerificationState'));
const emailVerificationRequired = $derived(
isEmailVerificationPending($userStore, $appConfigStore)
);
async function sendEmailVerification() {
await userService
.sendEmailVerification()
.then(() => {
toast.success(m.email_verification_sent());
})
.catch(axiosErrorToast);
}
async function onDismiss() {
const url = new URL(page.url.href);
url.searchParams.delete('emailVerificationState');
@@ -48,51 +33,39 @@
});
</script>
{#if emailVerificationState}
{#if emailVerificationState === 'success'}
<Alert.Root variant="success" {onDismiss}>
<LucideCheckCircle2 class="size-4" />
<Alert.Title class="font-semibold">{m.email_verification_success_title()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_success_description()}
</Alert.Description>
</Alert.Root>
{:else}
<Alert.Root variant="destructive" {onDismiss}>
<LucideCircleX class="size-4" />
<Alert.Title class="font-semibold">{m.email_verification_error_title()}</Alert.Title>
<Alert.Description class="text-sm">
{emailVerificationState}
</Alert.Description>
</Alert.Root>
{/if}
{:else if $userStore && $appConfigStore.emailVerification !== 'disabled' && !$userStore.emailVerified}
<Alert.Root variant={emailVerificationRequired ? 'destructive' : 'warning'} class="flex gap-3">
<LucideAlertTriangle class="size-4 shrink-0" />
<div class="md:flex md:w-full md:place-content-between">
<div>
{#if emailVerificationRequired}
<Alert.Title class="font-semibold">{m.email_verification_required_warning()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_required_warning_description()}
{#if $userStore.emailVerificationDeadline}
{m.email_verification_deadline_description({
time: formatDistanceToNow($userStore.emailVerificationDeadline, { addSuffix: true })
})}
{/if}
</Alert.Description>
{:else}
<Alert.Title class="font-semibold">{m.email_verification_warning()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_warning_description()}
</Alert.Description>
{#if emailVerificationState === 'success'}
<Alert.Root variant="success" {onDismiss}>
<LucideCircleCheck />
<Alert.Title class="wrap-anywhere"
>{m.email_verified_title({ email: $userStore?.email ?? '' })}</Alert.Title
>
</Alert.Root>
{:else if emailVerificationState}
<Alert.Root variant="destructive" {onDismiss}>
<LucideCircleX />
<Alert.Title>{m.email_verification_failed_title()}</Alert.Title>
<Alert.Description>{emailVerificationState}</Alert.Description>
</Alert.Root>
{:else if $userStore && $appConfigStore.emailVerification !== 'disabled' && !$userStore.emailVerified && (emailVerificationRequired || $userStore.email)}
<Alert.Root variant={emailVerificationRequired ? 'destructive' : 'warning'}>
<LucideTriangleAlert />
{#if emailVerificationRequired}
<Alert.Title>{m.email_verification_required_warning()}</Alert.Title>
<Alert.Description>
{m.email_verification_only_page()}
{#if $userStore.emailVerificationDeadline}
<strong class="font-semibold">
{m.email_verification_deadline_description({
time: formatDistanceToNow($userStore.emailVerificationDeadline, { addSuffix: true })
})}
</strong>
{/if}
</div>
<div>
<Button class="mt-2 md:mt-0" usePromiseLoading onclick={sendEmailVerification}>
{m.send_email()}
</Button>
</div>
</div>
</Alert.Description>
{:else}
<Alert.Title>{m.email_verification_warning()}</Alert.Title>
<Alert.Description class="wrap-anywhere">
{m.email_verification_send_hint({ email: $userStore.email ?? '' })}
</Alert.Description>
{/if}
</Alert.Root>
{/if}
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Table from './ui/table/index.ts';
let {
columns,
title,
description,
action,
avatar = true
}: {
// The list's own column headers, so the empty page already has the shape the list will have
columns: string[];
title: string;
description: Snippet;
action: Snippet;
avatar?: boolean;
} = $props();
const rows = [
{ name: 'w-30', detail: 'w-50', value: 'w-6' },
{ name: 'w-24', detail: 'w-42', value: 'w-4' },
{ name: 'w-34', detail: 'w-48', value: 'w-5' }
];
</script>
<div class="mt-10">
<div aria-hidden="true" class="pointer-events-none mask-b-from-30%">
<Table.Root>
<Table.Header>
<Table.Row>
{#each columns as column, i (column)}
<Table.Head class={i > 0 ? 'w-[30%] max-sm:hidden' : 'max-sm:rounded-r-sm'}
>{column}</Table.Head
>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row, r (r)}
<Table.Row>
<Table.Cell>
<div class="flex items-center gap-3">
{#if avatar}
<div class="bg-fill-selected size-7 rounded-sm"></div>
{/if}
<div class="flex flex-col gap-1.5">
<div class="bg-fill-selected h-2 rounded-xs {row.name}"></div>
<div class="bg-fill-selected h-1.5 rounded-xs {row.detail}"></div>
</div>
</div>
</Table.Cell>
{#each columns.slice(1) as column (column)}
<Table.Cell class="max-sm:hidden">
<div class="bg-fill-selected h-2 rounded-xs {row.value}"></div>
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="-mt-6 flex flex-col items-center gap-2 pb-12 text-center">
<h2 class="text-title">{title}</h2>
<p class="text-body text-fg-muted max-w-[380px]">{@render description()}</p>
<div class="mt-4 flex flex-wrap items-center justify-center gap-4">{@render action()}</div>
</div>
</div>
+23 -7
View File
@@ -1,16 +1,32 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Empty from '#lib/components/ui/empty/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { LucideXCircle } from '@lucide/svelte';
import { LucideCompass, LucideTriangleAlert } from '@lucide/svelte';
let { message, showButton = true }: { message: string; showButton?: boolean } = $props();
const title = $derived(page.status === 404 ? m.page_not_found_title() : m.something_went_wrong());
</script>
<div class="mt-[20%] flex flex-col items-center">
<LucideXCircle class="text-muted-foreground size-12" />
<h1 class="mt-3 text-2xl font-semibold">{m.something_went_wrong()}</h1>
<p class="text-muted-foreground">{message}</p>
<svelte:head>
<title>{title}</title>
</svelte:head>
<Empty.Root class="mx-auto min-h-[70dvh] items-center justify-center px-4 text-center">
<Empty.Header class="items-center">
<Empty.Media variant="icon">
{#if page.status === 404}
<LucideCompass />
{:else}
<LucideTriangleAlert />
{/if}
</Empty.Media>
<h1 class="font-display text-display-s text-fg">{title}</h1>
<Empty.Description class="wrap-anywhere">{message}</Empty.Description>
</Empty.Header>
{#if showButton}
<Button size="sm" class="mt-5" href="/">{m.go_back_to_home()}</Button>
<Button variant="secondary" href="/">{m.go_back_to_home()}</Button>
{/if}
</div>
</Empty.Root>
@@ -1,29 +1,20 @@
<script lang="ts">
import FormInput from '#lib/components/form/form-input.svelte';
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 CustomClaimService from '#lib/services/custom-claim-service.ts';
import type { CustomClaim } from '#lib/types/custom-claim.type.ts';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
import { onMount, type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { LucidePlus, LucideX } from '@lucide/svelte';
import { onMount, tick } from 'svelte';
import AutoCompleteInput from './auto-complete-input.svelte';
import { m } from '#lib/paraglide/messages.js';
let {
customClaims = $bindable(),
error = $bindable(null),
...restProps
}: HTMLAttributes<HTMLDivElement> & {
customClaims: CustomClaim[];
error?: string | null;
children?: Snippet;
} = $props();
let { customClaims = $bindable() }: { customClaims: CustomClaim[] } = $props();
const limit = 20;
const customClaimService = new CustomClaimService();
let editor = $state<HTMLElement>();
let suggestions: string[] = $state([]);
let filteredSuggestions: string[] = $derived(
suggestions.filter(
@@ -34,43 +25,68 @@
onMount(() => {
customClaimService.getSuggestions().then((data) => (suggestions = data));
});
// The empty state's button disappears once a row exists, so focus moves to the new row's key instead of falling back to the page
async function add() {
customClaims = [...customClaims, { key: '', value: '' }];
await tick();
const keyInputs = editor?.querySelectorAll<HTMLInputElement>('[data-claim-key] input');
keyInputs?.item(keyInputs.length - 1).focus();
}
</script>
<div {...restProps}>
<FormInput>
<div class="flex flex-col gap-y-2">
<div class="flex flex-col gap-2">
<div bind:this={editor} class="rounded-md border">
{#if customClaims.length === 0}
<div class="text-control text-fg-muted flex min-h-(--row-2line) items-center gap-3 px-4">
{m.no_custom_claims()}
<Button variant="ghost" size="sm" onclick={add}>
<LucidePlus />
{m.add_custom_claim()}
</Button>
</div>
{:else}
<div
aria-hidden="true"
class="text-caption text-fg-muted grid grid-cols-[1fr_1fr_var(--h-xs)] gap-2 px-3 pt-2.5 font-medium max-sm:hidden"
>
<span>{m.key()}</span>
<span>{m.value()}</span>
</div>
{#each customClaims as customClaim, i (customClaim)}
<div class="flex gap-x-2">
<AutoCompleteInput
placeholder={m.key()}
suggestions={filteredSuggestions}
bind:value={customClaim.key}
<div
class="grid grid-cols-[1fr_var(--h-xs)] items-center gap-2 border-b px-3 py-2 sm:grid-cols-[1fr_1fr_var(--h-xs)]"
>
<div data-claim-key class="[&_input]:h-(--h-sm) [&_input]:font-mono">
<AutoCompleteInput
placeholder={m.key()}
suggestions={filteredSuggestions}
bind:value={customClaim.key}
/>
</div>
<Input
class="h-(--h-sm) max-sm:col-start-1 max-sm:row-start-2"
placeholder={m.value()}
bind:value={customClaim.value}
/>
<Input placeholder={m.value()} bind:value={customClaim.value} />
<Button
variant="outline"
size="sm"
variant="ghost"
size="icon-xs"
class="max-sm:col-start-2 max-sm:row-start-1"
aria-label={m.remove_custom_claim()}
onclick={() => (customClaims = customClaims.filter((_, index) => index !== i))}
>
<LucideMinus class="size-4" />
<LucideX />
</Button>
</div>
{/each}
</div>
</FormInput>
{#if error}
<p class="text-destructive mt-1 text-xs">{error}</p>
{/if}
{#if customClaims.length < limit}
<Button
class="mt-2"
variant="secondary"
size="sm"
onclick={() => (customClaims = [...customClaims, { key: '', value: '' }])}
>
<LucidePlus class="mr-1 size-4" />
{customClaims.length === 0 ? m.add_custom_claim() : m.add_another()}
</Button>
{/if}
<div class="px-2 py-1.5">
<Button variant="ghost" size="sm" disabled={customClaims.length >= limit} onclick={add}>
<LucidePlus />
{m.add_another()}
</Button>
</div>
{/if}
</div>
<p class="text-caption text-fg-muted">{m.up_to_count_claims({ count: limit })}</p>
</div>
@@ -1,28 +0,0 @@
<script lang="ts">
import { cn } from '#lib/utils/style.ts';
import type { HTMLInputAttributes } from 'svelte/elements';
import type { VariantProps } from 'tailwind-variants';
import type { buttonVariants } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
let {
id,
...restProps
}: HTMLInputAttributes & {
id: string;
variant?: VariantProps<typeof buttonVariants>['variant'];
} = $props();
</script>
<button
type="button"
onclick={() => document.getElementById(id)?.click()}
class={cn(restProps.class)}
>
{#if restProps.children}
{@render restProps.children()}
{:else}
{m.select_file()}
{/if}
</button>
<input {id} {...restProps} type="file" class="hidden" />
@@ -0,0 +1,194 @@
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.ts';
import * as Dialog from '#lib/components/ui/dialog/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { LucideZoomIn, LucideZoomOut } from '@lucide/svelte';
let {
file = $bindable(),
onSave
}: {
file: File | null;
onSave: (image: File) => Promise<void>;
} = $props();
const OUTPUT_SIZE = 512;
const MAX_ZOOM = 4;
const CIRCLE_INSET = 24;
let img = $state<HTMLImageElement>();
let natural = $state({ w: 0, h: 0 });
let stage = $state({ w: 0, h: 0 });
let zoom = $state(1);
let offset = $state({ x: 0, y: 0 });
let drag: { x: number; y: number; ox: number; oy: number } | null = null;
let saving = $state(false);
const src = $derived(file ? URL.createObjectURL(file) : '');
$effect(() => {
const url = src;
return () => URL.revokeObjectURL(url);
});
const diameter = $derived(Math.max(0, Math.min(stage.w, stage.h) - CIRCLE_INSET * 2));
const scale = $derived(natural.w ? (diameter / Math.min(natural.w, natural.h)) * zoom : 0);
// The offset is clamped on read so the photo always covers the circle, whatever the zoom or stage size
const maxX = $derived(Math.max(0, (natural.w * scale - diameter) / 2));
const maxY = $derived(Math.max(0, (natural.h * scale - diameter) / 2));
const x = $derived(Math.max(-maxX, Math.min(maxX, offset.x)));
const y = $derived(Math.max(-maxY, Math.min(maxY, offset.y)));
function reset() {
natural = { w: 0, h: 0 };
zoom = 1;
offset = { x: 0, y: 0 };
}
function setZoom(value: number) {
zoom = Math.max(1, Math.min(MAX_ZOOM, value));
}
function onKeydown(e: KeyboardEvent) {
const step = e.shiftKey ? 40 : 8;
const moves: Record<string, [number, number]> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step]
};
const move = moves[e.key];
if (!move) return;
e.preventDefault();
offset = { x: x + move[0], y: y + move[1] };
}
async function save() {
if (!img || !scale) return;
// Map the circle's bounding square from stage pixels back to source pixels
const sx = (natural.w * scale - diameter) / 2 - x;
const sy = (natural.h * scale - diameter) / 2 - y;
const size = diameter / scale;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = OUTPUT_SIZE;
canvas
.getContext('2d')!
.drawImage(img, sx / scale, sy / scale, size, size, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) return;
saving = true;
try {
await onSave(new File([blob], 'profile-picture.png', { type: 'image/png' }));
file = null;
} finally {
saving = false;
}
}
// Chromium and Firefox cannot decode HEIC, so those photos skip the crop step and upload as they are
async function onDecodeError() {
const original = file;
file = null;
if (original) await onSave(original);
}
</script>
<Dialog.Root
open={!!file}
onOpenChange={(open) => {
if (!open && !saving) file = null;
}}
onOpenChangeComplete={(open) => !open && reset()}
>
<Dialog.Content class="max-w-(--dialog-md)">
<Dialog.Header>
<Dialog.Title>{m.adjust_your_photo()}</Dialog.Title>
<Dialog.Description>{m.adjust_your_photo_description()}</Dialog.Description>
</Dialog.Header>
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div
role="application"
aria-label={m.photo_crop_area()}
tabindex="0"
class="bg-well-dark focus-visible:outline-ring relative h-72 cursor-grab touch-none overflow-hidden rounded-lg select-none focus-visible:outline-2 focus-visible:outline-offset-2 active:cursor-grabbing"
bind:clientWidth={stage.w}
bind:clientHeight={stage.h}
onpointerdown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY, ox: x, oy: y };
}}
onpointermove={(e) => {
if (drag) offset = { x: drag.ox + e.clientX - drag.x, y: drag.oy + e.clientY - drag.y };
}}
onpointerup={() => (drag = null)}
onpointercancel={() => (drag = null)}
onwheel={(e) => {
e.preventDefault();
setZoom(zoom - e.deltaY * 0.002);
}}
onkeydown={onKeydown}
>
{#if src}
<img
bind:this={img}
{src}
alt=""
draggable="false"
class="absolute top-1/2 left-1/2 max-w-none transition-opacity duration-(--dur-base)"
class:opacity-0={!scale}
style:width="{natural.w * scale}px"
style:transform="translate(calc(-50% + {x}px), calc(-50% + {y}px))"
onload={() => (natural = { w: img!.naturalWidth, h: img!.naturalHeight })}
onerror={onDecodeError}
/>
{/if}
<!-- The spread shadow dims the photo outside the circle while keeping it visible for context -->
<div
class="pointer-events-none absolute top-1/2 left-1/2 -translate-1/2 rounded-full shadow-[0_0_0_9999px_rgb(0_0_0/0.6)] ring-2 ring-white/80"
style:width="{diameter}px"
style:height="{diameter}px"
></div>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
aria-label={m.zoom_out()}
disabled={zoom <= 1}
onclick={() => setZoom(zoom - 0.25)}
>
<LucideZoomOut />
</Button>
<input
type="range"
min="1"
max={MAX_ZOOM}
step="0.01"
aria-label={m.zoom()}
class="accent-fg h-1 flex-1 cursor-pointer"
bind:value={zoom}
/>
<Button
variant="ghost"
size="icon-sm"
aria-label={m.zoom_in()}
disabled={zoom >= MAX_ZOOM}
onclick={() => setZoom(zoom + 0.25)}
>
<LucideZoomIn />
</Button>
</div>
<Dialog.Footer>
<Button variant="secondary" disabled={saving} onclick={() => (file = null)}>
{m.cancel()}
</Button>
<Button isLoading={saving} disabled={!scale} onclick={save}>{m.save()}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,14 +1,16 @@
<script lang="ts">
import FileInput from '#lib/components/form/file-input.svelte';
import * as Avatar from '#lib/components/ui/avatar/index.ts';
import Button from '#lib/components/ui/button/button.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { Spinner } from '#lib/components/ui/spinner/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 { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import { LucideRefreshCw, LucideUpload } from '@lucide/svelte';
import { Spinner } from '#lib/components/ui/spinner/index.ts';
import { cn } from '#lib/utils/style.ts';
import { LucideRotateCcw, LucideUpload } from '@lucide/svelte';
import { onMount } from 'svelte';
import { openConfirmDialog } from '../confirm-dialog';
import ProfilePictureCropDialog from './profile-picture-crop-dialog.svelte';
let {
userId,
@@ -22,6 +24,11 @@
updateCallback: (image: File) => Promise<void>;
} = $props();
const managedByLdap = $derived(isLdapUser && $appConfigStore.ldapEnabled);
const labelId = $props.id();
let fileInput = $state<HTMLInputElement>();
let pickedFile = $state<File | null>(null);
let isLoading = $state(false);
let imageDataURL = $state('');
onMount(() => {
@@ -30,17 +37,14 @@
imageDataURL = cachedProfilePicture.getUrl(userId);
});
async function onImageChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] || null;
if (!file) return;
async function onSave(image: File) {
isLoading = true;
imageDataURL = URL.createObjectURL(file);
await updateCallback(file).catch(() => {
imageDataURL = cachedProfilePicture.getUrl(userId);
});
isLoading = false;
try {
await updateCallback(image);
imageDataURL = URL.createObjectURL(image);
} finally {
isLoading = false;
}
}
function onReset() {
@@ -67,58 +71,70 @@
}
</script>
<div class="flex flex-col items-center gap-6 sm:flex-row">
<div class="shrink-0">
{#if isLdapUser && $appConfigStore.ldapEnabled}
<Avatar.Root class="size-24">
<Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root>
{:else}
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg, image/webp, image/avif, image/heic"
onchange={onImageChange}
>
<div class="group relative size-24 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-30 {isLoading ? 'opacity-30' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<Spinner class="size-5" />
{:else}
<LucideUpload class="size-5 opacity-0 transition-opacity group-hover:opacity-100" />
{/if}
</div>
</div>
</FileInput>
<div class="flex flex-wrap items-center gap-x-4 gap-y-3">
<div class="relative size-18 shrink-0">
<Avatar.Root class="size-full">
<Avatar.Image
class={cn(
'object-cover transition-opacity duration-(--dur-base)',
isLoading && 'opacity-50'
)}
src={imageDataURL}
alt=""
/>
</Avatar.Root>
{#if isLoading}
<Spinner class="text-fg absolute inset-0 m-auto size-4" />
{/if}
</div>
<div class="grow">
<h3 class="font-medium">{m.profile_picture()}</h3>
{#if isLdapUser && $appConfigStore.ldapEnabled}
<p class="text-muted-foreground text-sm">
{m.profile_picture_is_managed_by_ldap_server()}
</p>
{:else}
<p class="text-muted-foreground text-sm">
{m.click_profile_picture_to_upload_custom()}
</p>
<p class="text-muted-foreground mb-2 text-sm">{m.image_should_be_in_format()}</p>
<div class="flex min-w-0 flex-1 basis-48 flex-col gap-0.5">
<span id={labelId} class="text-heading text-fg">{m.profile_picture()}</span>
<p class="text-caption text-fg-muted">
{managedByLdap ? m.profile_picture_from_directory() : m.profile_picture_formats()}
</p>
</div>
{#if !managedByLdap}
<div class="flex items-center gap-1">
<Button
variant="outline"
variant="secondary"
size="sm"
onclick={onReset}
disabled={isLoading || (isLdapUser && $appConfigStore.ldapEnabled)}
aria-label={m.upload_profile_picture()}
disabled={isLoading}
onclick={() => fileInput?.click()}
>
<LucideRefreshCw class="mr-2 size-4" />
{m.reset_to_default()}
<LucideUpload />
{m.upload()}
</Button>
{/if}
</div>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon-sm"
aria-label={m.reset_to_default()}
disabled={isLoading}
onclick={onReset}
>
<LucideRotateCcw />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>{m.reset_to_default()}</Tooltip.Content>
</Tooltip.Root>
</div>
<input
bind:this={fileInput}
type="file"
hidden
aria-labelledby={labelId}
accept="image/png, image/jpeg, image/webp, image/avif, image/heic"
onchange={(e) => {
pickedFile = e.currentTarget.files?.[0] ?? null;
// Clearing the input lets the same file be picked again after cancelling the crop
e.currentTarget.value = '';
}}
/>
<ProfilePictureCropDialog bind:file={pickedFile} {onSave} />
{/if}
</div>
@@ -1,42 +0,0 @@
<script lang="ts">
import { Label } from '#lib/components/ui/label/index.ts';
import { Switch } from '#lib/components/ui/switch/index.js';
import { cn } from '#lib/utils/style.ts';
let {
id,
checked = $bindable(),
label,
description,
disabled = false,
class: className,
onCheckedChange
}: {
id: string;
checked: boolean;
label: string;
description?: string;
disabled?: boolean;
class?: string;
onCheckedChange?: (checked: boolean) => void;
} = $props();
</script>
<div class={cn('items-top flex space-x-2', className)}>
<Switch
{id}
{disabled}
onCheckedChange={(v) => onCheckedChange && onCheckedChange(v == true)}
bind:checked
/>
<div class="grid gap-1.5 leading-none">
<Label for={id} class="mb-0 text-sm leading-none font-medium">
{label}
</Label>
{#if description}
<p class="text-muted-foreground text-[0.8rem]">
{description}
</p>
{/if}
</div>
</div>
@@ -16,16 +16,18 @@
let isLoading = $state(false);
async function loadUserGroups(search?: string) {
userGroups = (await userGroupService.list({ search })).data.map((group) => ({
userGroups = (
await userGroupService.list({ search, sort: { column: 'friendlyName', direction: 'asc' } })
).data.map((group) => ({
value: group.id,
label: group.name
label: group.friendlyName
}));
// Ensure selected groups are still in the list
for (const selectedGroupId of selectedGroupIds) {
if (!userGroups.some((g) => g.value === selectedGroupId)) {
const group = await userGroupService.get(selectedGroupId);
userGroups.push({ value: group.id, label: group.name });
userGroups.push({ value: group.id, label: group.friendlyName });
}
}
}
@@ -1,21 +0,0 @@
<script lang="ts">
import { cn } from '#lib/utils/style.ts';
import { LucideImageOff } from '@lucide/svelte';
import type { HTMLImgAttributes } from 'svelte/elements';
let props: HTMLImgAttributes & {} = $props();
let error = $state(false);
$effect(() => {
void props.src;
error = false;
});
</script>
<div class={cn('bg-muted flex items-center justify-center rounded-2xl p-3', props.class)}>
{#if error}
<LucideImageOff class="text-muted-foreground p-5" />
{:else}
<img {...props} class="aspect-square object-contain" onerror={() => (error = true)} />
{/if}
</div>
@@ -13,14 +13,12 @@
onPageChange,
onPageSizeChange,
pageSizes = [20, 50, 100],
hideWhenSinglePage = false,
class: className
}: {
pagination?: PaginationResponse;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
pageSizes?: number[];
hideWhenSinglePage?: boolean;
class?: string;
} = $props();
@@ -31,11 +29,10 @@
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 pagination && !(hideWhenSinglePage && fitsSmallestPage)}
{#if pagination && !fitsSmallestPage}
<nav
aria-label={m.pagination()}
class={cn('text-control flex min-h-(--row) items-center gap-3 pt-2', className)}
@@ -44,25 +41,23 @@
<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 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>
</span>
{#if totalPages > 1}
<span class="ml-auto flex gap-2">
+220 -96
View File
@@ -1,123 +1,247 @@
<script module lang="ts">
let backgroundImageExists = $state<boolean | undefined>(undefined);
// Where the card was when the last auth page left, so the next step grows or slides from there instead of jumping
let previousCard: { rect: DOMRect; at: number } | undefined;
</script>
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { beforeNavigate } from '$app/navigation';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { cachedBackgroundImage } from '#lib/utils/cached-image-util.ts';
import { cn } from '#lib/utils/style.ts';
import { onMount, type Snippet } from 'svelte';
import { cn, cssMs, cssVar } from '#lib/utils/style.ts';
import { LucideChevronLeft } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import { MediaQuery } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
import * as Card from './ui/card';
let {
children,
showAlternativeSignInMethodButton = false
back,
wide = false,
columns = true
}: {
children: Snippet;
showAlternativeSignInMethodButton?: boolean;
back?: string;
// Steps with scopes or technical details grow the desktop card to two columns
wide?: boolean;
// A wide step with choices that need the whole width keeps one column
columns?: boolean;
} = $props();
let isInitialLoad = $state(false);
let animate = $derived(isInitialLoad && !$appConfigStore.disableAnimations);
onMount(async () => {
fetch(cachedBackgroundImage.getUrl(), {
method: 'HEAD'
})
.then(async (res) => (backgroundImageExists = res.ok))
.catch(() => (backgroundImageExists = false));
});
afterNavigate((e) => {
if (e.shallow) return;
isInitialLoad = !e?.from?.url;
});
const isDesktop = new MediaQuery('(min-width: 1024px)');
let alternativeSignInButton = $derived({
href:
page.url.pathname === '/login'
? `/login/alternative${page.url.search}`
: `/login/alternative?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`,
label: m.alternative_sign_in_methods()
const photo = $derived(backgroundImageExists !== false);
let card = $state<HTMLDivElement>();
let column = $state<HTMLDivElement>();
let stack = $state<HTMLDivElement>();
// The strip starts at the previous step's height so it follows the sheet instead of zooming out from full height
let cardHeight = $state(previousCard?.rect.height ?? 0);
let innerHeight = $state(0);
const fullHeight = $derived(cardHeight >= innerHeight - 1);
let photoLoaded = $state(backgroundImageExists === true);
let last: { rect: DOMRect; width: number; height: number } | undefined;
let resizing = $state.raw<Animation>();
let contentBelow = $state(false);
beforeNavigate(() => {
if (card) previousCard = { rect: card.getBoundingClientRect(), at: performance.now() };
});
// The stack keeps its final layout while the card animates, so a step that changes mid-animation is still noticed
$effect(() => {
if (!card || !column || !stack) return;
const observer = new ResizeObserver(() => onResize(card!, column!));
observer.observe(stack);
return () => observer.disconnect();
});
function onResize(card: HTMLDivElement, column: HTMLDivElement) {
// An interrupted animation continues from where the card is drawn right now
// The new layout already applies here, so the sheet is drawn at its previous layout shifted by the running translate
let drawn: DOMRect | undefined;
if (resizing && last && !isDesktop.current) {
const dy = parseFloat(getComputedStyle(card).translate.split(' ')[1] ?? '0');
drawn = new DOMRect(last.rect.x, last.rect.y + dy, last.rect.width, last.rect.height);
} else if (resizing) {
drawn = card.getBoundingClientRect();
}
settleResize(column);
cardHeight = card.offsetHeight;
updateContentBelow();
// A new page starts from the previous page's card when it left a moment ago
const entering = !last;
const from =
drawn ??
last?.rect ??
(previousCard && performance.now() - previousCard.at < 5000 ? previousCard.rect : undefined);
previousCard = undefined;
const windowResized = last && (last.width !== innerWidth || last.height !== innerHeight);
const to = card.getBoundingClientRect();
last = { rect: to, width: innerWidth, height: innerHeight };
const reducedMotion =
document.documentElement.classList.contains('no-motion') || prefersReducedMotion.current;
if (!from || windowResized || reducedMotion) return;
if (isDesktop.current) growCard(card, column, from, to, entering || to.width > from.width);
else if (photo) slideSheet(card, column, from.top - to.top, entering);
else if (entering) fadeIn(column, 0);
}
// The content keeps its final size throughout, so the card's edge reveals it instead of it reflowing on every frame
function growCard(
card: HTMLDivElement,
column: HTMLDivElement,
from: DOMRect,
to: DOMRect,
newStep: boolean
) {
const sizeChanged =
Math.abs(to.width - from.width) >= 1 || Math.abs(to.height - from.height) >= 1;
if (!sizeChanged) {
if (newStep) fadeIn(column, 0);
return;
}
column.style.width = `${to.width}px`;
column.style.height = `${to.height}px`;
column.style.placeSelf = 'center';
resizing = card.animate(
[
{ width: `${from.width}px`, height: `${from.height}px` },
{ width: `${to.width}px`, height: `${to.height}px` }
],
{ duration: cssMs('--dur-slow'), easing: cssVar('--ease-in-out') }
);
resizing.onfinish = () => settleResize(column);
// A new step fades in once the card is halfway open, while a height change or a retract inside a step only moves the card's edges so the outcome's own animation stays visible
if (newStep) fadeIn(column, cssMs('--dur-slow') / 2);
}
// The sheet's top edge travels from where it was, and a surface extension below it covers the photo while it moves up
function slideSheet(card: HTMLDivElement, column: HTMLDivElement, dy: number, entering: boolean) {
if (Math.abs(dy) >= 0.5) {
resizing = card.animate([{ translate: `0 ${dy}px` }, { translate: '0 0' }], {
duration: cssMs('--dur-slow'),
easing: cssVar('--ease-in-out')
});
resizing.onfinish = () => settleResize(column);
}
if (entering) fadeIn(column, 0);
}
function fadeIn(column: HTMLDivElement, delay: number) {
column.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: cssMs('--dur-base'),
delay,
easing: cssVar('--ease-out'),
fill: 'backwards'
});
}
function settleResize(column: HTMLDivElement) {
resizing?.cancel();
resizing = undefined;
column.style.width = '';
column.style.height = '';
column.style.placeSelf = '';
}
function updateContentBelow() {
if (column) contentBelow = column.scrollHeight - column.scrollTop - column.clientHeight > 1;
}
</script>
{#if backgroundImageExists === undefined}
<div class="bg-background h-screen"></div>
{:else if isDesktop.current}
<svelte:window bind:innerHeight onresize={updateContentBelow} />
<!-- Beside a tablet sheet the photo strip ends above the page, so the rest holds the photo's placeholder fill instead of the bare page -->
<div
class={cn('lg:bg-sidebar fixed inset-0 isolate overflow-hidden', photo && 'max-lg:bg-fill-muted')}
>
{#if photo}
<!-- On phones the photo fills the strip above the sheet and follows its top edge, on desktop it is the backdrop behind the card -->
<!-- Its height only transitions while the sheet slides, so a page that opens without a slide does not zoom the photo -->
<img
src={cachedBackgroundImage.getUrl()}
alt=""
class={cn(
'pointer-events-none absolute inset-x-0 top-0 -z-1 h-(--strip) w-full object-cover opacity-0 transition-opacity duration-(--dur-slow) ease-in-out lg:h-full',
resizing && 'transition-[opacity,height]',
photoLoaded && 'opacity-100'
)}
style:--strip="{fullHeight ? 0 : innerHeight - cardHeight + 12}px"
onload={() => (backgroundImageExists = photoLoaded = true)}
onerror={() => (backgroundImageExists = false)}
/>
{/if}
<div
in:fade={{ duration: 150 }}
class="relative flex h-screen w-full items-center overflow-hidden text-center {backgroundImageExists
? 'justify-start'
: 'justify-center'}"
bind:this={card}
class={cn(
'grid grid-rows-[minmax(0,1fr)]',
'lg:bg-surface lg:inset-ring-border lg:absolute lg:inset-0 lg:m-auto lg:h-fit lg:max-h-[calc(100%-64px)] lg:w-[min(520px,100%-64px)] lg:overflow-hidden lg:rounded-2xl lg:inset-ring',
photo && 'lg:shadow-overlay',
wide && 'lg:w-[min(1040px,100%-64px)]',
photo
? 'max-lg:bg-surface max-lg:shadow-overlay max-lg:after:bg-surface max-lg:absolute max-lg:inset-x-0 max-lg:bottom-0 max-lg:mx-auto max-lg:max-h-full max-lg:w-[calc(var(--col-auth)+48px)] max-lg:max-w-full max-lg:rounded-t-md max-lg:after:absolute max-lg:after:inset-x-0 max-lg:after:top-full max-lg:after:h-dvh max-sm:w-full'
: 'max-lg:h-full',
photo && fullHeight && 'max-lg:rounded-none'
)}
>
<!-- The scroll padding keeps keyboard focus clear of the sticky action panel on phones, sized for its two buttons and a footnote of up to two lines -->
<!-- Without the photo the side padding itself centres the stack, so the action panel reaches the page edges through it -->
<div
class="relative z-10 flex h-full w-full max-w-[650px] p-16 2xl:max-w-[800px] {cn(
showAlternativeSignInMethodButton && 'pb-0'
)}"
bind:this={column}
class={cn(
'group/sheet flex min-h-0 flex-col overflow-y-auto max-lg:px-(--sheet-x) max-lg:pb-(--sheet-b) max-lg:has-data-action-panel:scroll-pb-[calc(176px+var(--sheet-b))] lg:px-12',
photo
? '[--sheet-b:calc(24px+env(safe-area-inset-bottom))] [--sheet-x:24px] max-[360px]:[--sheet-x:16px] max-lg:pt-6'
: '[--sheet-b:24px] [--sheet-x:max(16px,calc((100vw_-_var(--col-auth))/2))]'
)}
data-content-below={contentBelow || undefined}
onscroll={updateContentBelow}
>
<div class="flex h-full w-full flex-col overflow-hidden">
<div class="relative flex grow flex-col items-center justify-center overflow-auto p-1">
<div
class={cn(
'mx-auto flex w-full shrink-0 items-center lg:min-h-14 lg:pt-4',
photo
? 'max-lg:-mt-3 max-lg:min-h-11'
: 'max-lg:min-h-17 max-lg:max-w-(--col-auth) max-lg:pt-6',
photo && !back && 'max-lg:hidden',
!wide && 'lg:max-w-(--col-auth)'
)}
>
{#if back}
<Button variant="link" href={back} class="text-body -ml-1 gap-1">
<LucideChevronLeft />
{m.back()}
</Button>
{/if}
</div>
<main class="flex flex-[1_0_auto] items-center justify-center lg:pt-4 lg:pb-8">
<div
bind:this={stack}
class={cn(
'flex w-full flex-col',
photo ? 'lg:max-w-(--col-auth)' : 'max-w-(--col-auth)',
wide && 'lg:max-w-none',
wide &&
columns &&
'lg:block lg:columns-2 lg:gap-x-24 lg:[column-rule:1px_solid_var(--border)] lg:*:break-inside-avoid'
)}
>
{@render children()}
</div>
{#if showAlternativeSignInMethodButton}
<div class="mb-4 flex items-center justify-center">
<a
href={alternativeSignInButton.href}
class="text-muted-foreground text-xs transition-colors hover:underline"
>
{alternativeSignInButton.label}
</a>
</div>
{/if}
</div>
</div>
</main>
{#if backgroundImageExists}
<!-- Background image -->
<div
class="absolute top-0 right-0 bottom-0 left-[650px] z-0 m-6 overflow-hidden rounded-[40px] 2xl:left-[800px]"
>
<img
src={cachedBackgroundImage.getUrl()}
class="{cn(
animate && 'animate-bg-zoom'
)} h-screen w-[calc(100vw-650px)] object-cover 2xl:w-[calc(100vw-800px)]"
alt={m.login_background()}
/>
</div>
{/if}
<!-- Balances the top bar so the stack stays centered in the desktop card -->
<div class="shrink-0 max-lg:hidden lg:min-h-12 lg:pb-4"></div>
</div>
</div>
{:else}
<div
class="flex min-h-dvh items-center justify-center bg-cover bg-center text-center"
style="background-image: url({cachedBackgroundImage.getUrl()});"
>
<Card.Root
class={{
'mx-3 w-full max-w-md': true,
'border-0 bg-transparent': !backgroundImageExists
}}
>
<Card.CardContent
class="px-4 py-10 sm:p-10 {showAlternativeSignInMethodButton ? 'pb-3 sm:pb-3' : ''} "
>
{@render children()}
{#if showAlternativeSignInMethodButton}
<a
href={alternativeSignInButton.href}
class="text-muted-foreground mt-7 flex justify-center text-xs transition-colors hover:underline"
>
{alternativeSignInButton.label}
</a>
{/if}
</Card.CardContent>
</Card.Root>
</div>
{/if}
</div>
+13 -2
View File
@@ -37,6 +37,7 @@
import { cachedApplicationLogo } from '#lib/utils/cached-image-util.ts';
import { cn } from '#lib/utils/style.ts';
import { mode } from 'mode-watcher';
import { prefersReducedMotion } from 'svelte/motion';
let {
class: className,
@@ -82,7 +83,13 @@
}
function claimDefaultLogoAnimation() {
if (!animate || defaultOnly || defaultLogoHasAnimated || $appConfigStore?.disableAnimations) {
if (
!animate ||
defaultOnly ||
defaultLogoHasAnimated ||
$appConfigStore?.disableAnimations ||
prefersReducedMotion.current
) {
return false;
}
@@ -93,7 +100,11 @@
{#if useDefaultLogo}
<svg
class={cn('aspect-square', isLightMode ? 'text-black' : 'text-white', className)}
class={cn(
'aspect-square',
colorScheme ? (isLightMode ? 'text-black' : 'text-white') : 'text-fg',
className
)}
viewBox="-346.211 -706.48 712.96 712.96"
role="img"
aria-label={logoAlt}
@@ -1,29 +1,36 @@
<script lang="ts">
import { navigating } from '$app/state';
import { fade } from 'svelte/transition';
import { cssMs } from '#lib/utils/style.ts';
import { cubicOut } from 'svelte/easing';
// Skip the fade when the bar never became visible, otherwise it would start growing during the fade and flash on fast navigations
function fadeIfVisible(node: HTMLElement) {
const visible = new DOMMatrixReadOnly(getComputedStyle(node).transform).a > 0;
return fade(node, { duration: visible ? 200 : 0 });
// Run the bar to the end in the first half and fade it out in the second, so a finished navigation reads as complete
// Skip it when the bar never became visible, otherwise it would start growing during the fade and flash on fast navigations
function finish(node: HTMLElement) {
const from = new DOMMatrixReadOnly(getComputedStyle(node).transform).a;
return {
duration: from > 0 ? cssMs('--dur-slow') : 0,
easing: cubicOut,
css: (t: number, u: number) =>
`transform: scaleX(${from + (1 - from) * Math.min(1, u * 2)}); opacity: ${Math.min(1, t * 2)}`
};
}
</script>
{#if navigating.to && !navigating.shallow}
<div class="nav-progress" aria-hidden="true" out:fadeIfVisible></div>
<div class="nav-progress" aria-hidden="true" out:finish></div>
{/if}
<style>
.nav-progress {
position: fixed;
inset: 0 0 auto 0;
z-index: 100;
z-index: var(--z-progress);
height: 2px;
background: var(--primary);
background: linear-gradient(to right, transparent, var(--primary));
transform: scaleX(0);
transform-origin: left;
pointer-events: none;
animation: progress 3s cubic-bezier(0.1, 0.6, 0.3, 1) 100ms forwards;
animation: progress var(--t-progress) cubic-bezier(0.1, 0.6, 0.3, 1) 100ms forwards;
}
@keyframes progress {
@@ -32,7 +39,7 @@
}
to {
transform: scaleX(0.85);
transform: scaleX(0.9);
}
}
</style>
@@ -1,123 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
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 { 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 { toast } from 'svelte-sonner';
let {
userId = $bindable()
}: {
userId: string | null;
} = $props();
const userService = new UserService();
let oneTimeLink: string | null = $state(null);
let code: string | null = $state(null);
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_hour());
let availableExpirations = {
[m.one_hour()]: 60 * 60,
[m.twelve_hours()]: 60 * 60 * 12,
[m.one_day()]: 60 * 60 * 24,
[m.one_week()]: 60 * 60 * 24 * 7,
[m.one_month()]: 60 * 60 * 24 * 30
};
async function createLoginCode() {
try {
code = await userService.createOneTimeAccessToken(
userId!,
availableExpirations[selectedExpiration]
);
oneTimeLink = `${page.url.origin}/lc/${code}`;
} catch (e) {
axiosErrorToast(e);
}
}
async function sendLoginCodeEmail() {
try {
await userService.requestOneTimeAccessEmailAsAdmin(
userId!,
availableExpirations[selectedExpiration]
);
toast.success(m.login_code_email_success());
onOpenChange(false);
} catch (e) {
axiosErrorToast(e);
}
}
function onOpenChange(open: boolean) {
if (!open) {
oneTimeLink = null;
code = null;
userId = null;
}
}
</script>
<Dialog.Root open={!!userId} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{m.login_code()}</Dialog.Title>
<Dialog.Description
>{m.create_a_login_code_to_sign_in_without_a_passkey_once()}</Dialog.Description
>
</Dialog.Header>
{#if oneTimeLink === null}
<Field.Field>
<Field.Label for="expiration">{m.expiration()}</Field.Label>
<Select.Root
type="single"
value={Object.keys(availableExpirations)[0]}
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
>
<Select.Trigger id="expiration" class="w-full">
{selectedExpiration}
</Select.Trigger>
<Select.Content>
{#each Object.keys(availableExpirations) as key (key)}
<Select.Item value={key}>{key}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Dialog.Footer class="mt-2">
{#if $appConfigStore.emailOneTimeAccessAsAdminEnabled}
<Button
onclick={() => sendLoginCodeEmail()}
variant="secondary"
disabled={!selectedExpiration}
>
{m.send_email()}
</Button>
{/if}
<Button onclick={() => createLoginCode()} disabled={!selectedExpiration}
>{m.show_code()}</Button
>
</Dialog.Footer>
{:else}
<div class="flex flex-col gap-4">
<CopyField label={m.code()} value={code!} copyLabel={m.copy_code()} />
<CopyField
label={m.link()}
value={oneTimeLink}
copyLabel={m.copy_link()}
testId="login-code-link"
/>
<Qrcode class="self-center" value={oneTimeLink} size={160} />
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -1,7 +1,6 @@
import { m } from '#lib/paraglide/messages.js';
import {
Blocks,
Braces,
FileText,
History,
IdCard,
@@ -57,11 +56,6 @@ export function navGroups({
{ href: '/settings/admin/users', label: m.users(), icon: User },
{ href: '/settings/admin/user-groups', label: m.groups(), icon: UsersRound },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients(), icon: Blocks },
{
href: '/settings/admin/oidc-claim-mapping-policies',
label: m.claim_mappings(),
icon: Braces
},
{ href: '/settings/admin/apis', label: m.apis(), icon: Server },
{ href: '/settings/admin/api-keys', label: m.api_keys(), icon: Key },
{ href: '/settings/audit-log/global', label: m.audit_log(), icon: FileText },
@@ -1,5 +1,7 @@
<script lang="ts">
import { authPrimaryClass } from '#lib/components/auth/auth-util.ts';
import FormInput from '#lib/components/form/form-input.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { UserSignUp } from '#lib/types/user.type.ts';
@@ -10,9 +12,11 @@
import { z } from 'zod/v4';
let {
callback
callback,
submitLabel
}: {
callback: (user: UserSignUp) => Promise<boolean>;
callback: (user: UserSignUp) => Promise<void>;
submitLabel: string;
} = $props();
const initialData: UserSignUp = {
@@ -32,22 +36,49 @@
const { inputs, ...form } = createForm<FormSchema>(formSchema, initialData);
let isLoading = $state(false);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data);
isLoading = false;
}
</script>
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
<div class="mt-7 space-y-4">
<FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div>
<form onsubmit={preventDefault(onSubmit)} class="flex flex-col gap-4" novalidate>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormInput
label={m.first_name()}
optional
autocomplete="given-name"
inputClass="h-(--h-xl)"
bind:input={$inputs.firstName}
/>
<FormInput
label={m.last_name()}
optional
autocomplete="family-name"
inputClass="h-(--h-xl)"
bind:input={$inputs.lastName}
/>
</div>
<FormInput
label={m.username()}
description={m.username_rule()}
autocomplete="username"
inputClass="h-(--h-xl)"
bind:input={$inputs.username}
/>
<FormInput
label={m.email_address()}
optional={!$appConfigStore.requireUserEmail}
type="email"
autocomplete="email"
inputClass="h-(--h-xl)"
bind:input={$inputs.email}
/>
<Button type="submit" class={authPrimaryClass + ' mt-2'} {isLoading}>{submitLabel}</Button>
</form>
@@ -1,187 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
import { Badge, type BadgeVariant } from '#lib/components/ui/badge/index.ts';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Dialog from '#lib/components/ui/dialog/index.ts';
import { m } from '#lib/paraglide/messages.js';
import UserService from '#lib/services/user-service.ts';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} from '#lib/types/advanced-table.type.ts';
import type { SignupToken } from '#lib/types/signup-token.type.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { Copy, Trash2 } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
let {
open = $bindable()
}: {
open: boolean;
} = $props();
const userService = new UserService();
let tableRef: AdvancedTable<SignupToken>;
function formatDate(dateStr: string | undefined) {
if (!dateStr) return m.never();
return new Date(dateStr).toLocaleString();
}
async function deleteToken(token: SignupToken) {
openConfirmDialog({
title: m.delete_signup_token(),
message: m.are_you_sure_you_want_to_delete_this_signup_token(),
confirm: {
label: m.delete(),
destructive: true,
action: async () => {
try {
await userService.deleteSignupToken(token.id);
await tableRef.refresh();
toast.success(m.signup_token_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
function onOpenChange(isOpen: boolean) {
open = isOpen;
}
function isTokenExpired(expiresAt: string) {
return new Date(expiresAt) < new Date();
}
function isTokenUsedUp(token: SignupToken) {
return token.usageCount >= token.usageLimit;
}
function getTokenStatus(token: SignupToken) {
if (isTokenExpired(token.expiresAt)) return 'expired';
if (isTokenUsedUp(token)) return 'used-up';
return 'active';
}
function getStatusBadge(status: string): { variant: BadgeVariant; text: string } {
switch (status) {
case 'expired':
return { variant: 'destructive', text: m.expired() };
case 'used-up':
return { variant: 'secondary', text: m.used_up() };
default:
return { variant: 'default', text: m.active() };
}
}
function copySignupLink(token: SignupToken) {
const signupLink = `${page.url.origin}/st/${token.token}`;
navigator.clipboard
.writeText(signupLink)
.then(() => {
toast.success(m.copied());
})
.catch((err) => {
axiosErrorToast(err);
});
}
const columns: AdvancedTableColumn<SignupToken>[] = [
{ label: m.token(), column: 'token', cell: TokenCell },
{ label: m.status(), key: 'status', cell: StatusCell },
{
label: m.usage(),
column: 'usageCount',
sortable: true,
cell: UsageCell
},
{
label: m.expires(),
column: 'expiresAt',
sortable: true,
value: (item) => formatDate(item.expiresAt)
},
{
key: 'userGroups',
label: m.user_groups(),
value: (item) => item.userGroups.map((g) => g.name).join(', '),
hidden: true
},
{
label: m.created(),
column: 'createdAt',
sortable: true,
hidden: true,
value: (item) => formatDate(item.createdAt)
}
];
const actions: CreateAdvancedTableActions<SignupToken> = () => [
{
label: m.copy(),
icon: Copy,
onClick: (token) => copySignupLink(token)
},
{
label: m.delete(),
icon: Trash2,
variant: 'danger',
onClick: (token) => deleteToken(token)
}
];
</script>
{#snippet TokenCell({ item }: { item: SignupToken })}
<span class="font-mono text-xs">
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
</span>
{/snippet}
{#snippet StatusCell({ item }: { item: SignupToken })}
{@const status = getTokenStatus(item)}
{@const statusBadge = getStatusBadge(status)}
<Badge class="rounded-full" variant={statusBadge.variant}>
{statusBadge.text}
</Badge>
{/snippet}
{#snippet UsageCell({ item }: { item: SignupToken })}
<div class="flex items-center gap-1">
{item.usageCount}
{m.of()}
{item.usageLimit}
</div>
{/snippet}
<Dialog.Root {open} {onOpenChange}>
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
<Dialog.Header>
<Dialog.Title>{m.manage_signup_tokens()}</Dialog.Title>
<Dialog.Description>
{m.view_and_manage_active_signup_tokens()}
</Dialog.Description>
</Dialog.Header>
<div class="flex-1 overflow-hidden">
<AdvancedTable
id="signup-token-list"
withoutSearch={true}
fetchCallback={userService.listSignupTokens}
defaultSort={{ column: 'createdAt', direction: 'asc' }}
bind:this={tableRef}
{columns}
{actions}
/>
</div>
<Dialog.Footer class="mt-3">
<Button onclick={() => (open = false)}>
{m.close()}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,199 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
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';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Dialog from '#lib/components/ui/dialog/index.ts';
import { Input } from '#lib/components/ui/input/index.ts';
import * as Select from '#lib/components/ui/select/index.js';
import { m } from '#lib/paraglide/messages.js';
import AppConfigService from '#lib/services/app-config-service.ts';
import UserService from '#lib/services/user-service.ts';
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 { onMount } from 'svelte';
import { z } from 'zod/v4';
let {
open = $bindable()
}: {
open: boolean;
} = $props();
const userService = new UserService();
const appConfigService = new AppConfigService();
const DEFAULT_TTL_SECONDS = 60 * 60 * 24;
const availableExpirations = [
{ label: m.one_hour(), value: 60 * 60 },
{ label: m.twelve_hours(), value: 60 * 60 * 12 },
{ label: m.one_day(), value: DEFAULT_TTL_SECONDS },
{ label: m.one_week(), value: DEFAULT_TTL_SECONDS * 7 },
{ label: m.one_month(), value: DEFAULT_TTL_SECONDS * 30 }
] as const;
const defaultExpiration =
availableExpirations.find((exp) => exp.value === DEFAULT_TTL_SECONDS)?.value ??
availableExpirations[0].value;
type SignupTokenForm = {
ttl: number;
usageLimit: number;
userGroupIds: string[];
};
const initialFormValues: SignupTokenForm = {
ttl: defaultExpiration,
usageLimit: 1,
userGroupIds: []
};
const formSchema = z.object({
ttl: z.number(),
usageLimit: z.number().min(1).max(100),
userGroupIds: z.array(z.string()).default([])
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, initialFormValues);
let signupToken: string | null = $state(null);
let signupLink: string | null = $state(null);
let createdSignupData: SignupTokenForm | null = $state(null);
let isLoading = $state(false);
let defaultUserGroupIds: string[] = [];
function getExpirationLabel(ttl: number) {
return availableExpirations.find((exp) => exp.value === ttl)?.label ?? '';
}
function resetForm() {
form.reset();
form.setValue('userGroupIds', defaultUserGroupIds);
}
async function createSignupToken() {
const data = form.validate();
if (!data) return;
isLoading = true;
try {
signupToken = await userService.createSignupToken(
data.ttl,
data.usageLimit,
data.userGroupIds
);
signupLink = `${page.url.origin}/st/${signupToken}`;
createdSignupData = data;
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
function onOpenChange(isOpen: boolean) {
open = isOpen;
if (!isOpen) {
signupToken = null;
signupLink = null;
createdSignupData = null;
resetForm();
}
}
onMount(() => {
appConfigService
.list(true)
.then((response) => {
const responseGroupIds = response.signupDefaultUserGroupIDs || [];
defaultUserGroupIds = responseGroupIds;
initialFormValues.userGroupIds = responseGroupIds;
form.setValue('userGroupIds', responseGroupIds);
})
.catch(axiosErrorToast);
});
</script>
<Dialog.Root {open} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{m.signup_token()}</Dialog.Title>
<Dialog.Description
>{m.create_a_signup_token_to_allow_new_user_registration()}</Dialog.Description
>
</Dialog.Header>
{#if signupToken === null}
<form class="space-y-4" onsubmit={preventDefault(createSignupToken)}>
<FormInput labelFor="expiration" label={m.expiration()} input={$inputs.ttl}>
<Select.Root
type="single"
value={$inputs.ttl.value.toString()}
onValueChange={(v) => v && form.setValue('ttl', Number(v))}
>
<Select.Trigger id="expiration" class="w-full">
{getExpirationLabel($inputs.ttl.value)}
</Select.Trigger>
<Select.Content>
{#each availableExpirations as expiration (expiration.value)}
<Select.Item value={expiration.value.toString()}>
{expiration.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if $inputs.ttl.error}
<p class="text-destructive mt-1 text-xs">{$inputs.ttl.error}</p>
{/if}
</FormInput>
<FormInput
labelFor="usage-limit"
label={m.usage_limit()}
description={m.number_of_times_token_can_be_used()}
input={$inputs.usageLimit}
>
<Input
id="usage-limit"
type="number"
bind:value={$inputs.usageLimit.value}
aria-invalid={$inputs.usageLimit.error ? 'true' : undefined}
class="h-9"
/>
</FormInput>
<FormInput
labelFor="default-groups"
label={m.user_groups()}
description={m.signup_token_user_groups_description()}
input={$inputs.userGroupIds}
>
<UserGroupInput bind:selectedGroupIds={$inputs.userGroupIds.value} />
</FormInput>
<Dialog.Footer class="mt-4">
<Button type="submit" {isLoading}>
{m.create()}
</Button>
</Dialog.Footer>
</form>
{:else}
<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"
/>
<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>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -6,14 +6,13 @@
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';
import { m } from '#lib/paraglide/messages.js';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} 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 { cn, cssMs } from '#lib/utils/style.ts';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import { PersistedState } from 'runed';
@@ -31,7 +30,6 @@
filtered = false,
onClearQuery,
onRowClick,
rowSelectionDisabled,
fetchCallback,
defaultSort,
columns,
@@ -82,12 +80,22 @@
filters: tablePreferences.current.filters
});
// The identity column always shows, and so does every column of a table without a Display popover, even when an older saved preference hid them
let visibleColumns = $derived(
columns.filter(
(c) => tablePreferences.current.visibleColumns?.includes(c.column ?? c.key!) ?? []
(c, i) =>
i === 0 ||
!hasOptionalColumns ||
(tablePreferences.current.visibleColumns?.includes(c.column ?? c.key!) ?? true)
)
);
const narrowed = $derived(
!!query ||
filtered ||
Object.values(requestOptions.filters ?? {}).some((values) => values.length > 0)
);
onMount(async () => {
const urlParams = new URLSearchParams(window.location.search);
const page = parseInt(urlParams.get(`${id}-page`) ?? '') || undefined;
@@ -108,9 +116,7 @@
});
async function onAllCheck(checked: boolean) {
const pageIds = items!.data
.filter((item) => !rowSelectionDisabled?.(item))
.map((item) => item.id);
const pageIds = items!.data.map((item) => item.id);
const current = selectedIds ?? [];
if (checked) {
@@ -163,7 +169,8 @@
async function changePageState(page: number) {
const url = new URL(window.location.href);
url.searchParams.set(`${id}-page`, page.toString());
if (page > 1) url.searchParams.set(`${id}-page`, page.toString());
else url.searchParams.delete(`${id}-page`);
await goto(url, { shallow: true, replace: true, state: currentPage.state });
requestOptions.pagination!.page = page;
}
@@ -236,7 +243,11 @@
{searchValue}
bind:searchInput
{withoutSearch}
{refresh}
onSearch={async (search) => {
searchValue = search;
requestOptions.search = search;
await refresh();
}}
{onFilterChange}
/>
@@ -251,28 +262,89 @@
{/each}
</div>
{:else}
{#if !items}
<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}
<!-- 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>
<!-- Refetches keep the old rows dimmed until the new page arrives, after a short delay so fast responses never flash -->
<div
in:fade={{ duration: cssMs('--dur-fast') }}
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-10">
<Checkbox
checked={allChecked}
disabled={items.data.length === 0}
indeterminate={!allChecked &&
items.data.some((item) => selectedIds!.includes(item.id))}
aria-label={m.select_all_rows_on_this_page()}
onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
/>
</Table.Head>
{/if}
{#each visibleColumns as column, i (column.key ?? column.column ?? column.label)}
{@const sortDirection =
requestOptions.sort?.column === column.column
? requestOptions.sort?.direction
: undefined}
<Table.Head
class={columnClass(column, i)}
aria-sort={column.sortable
? sortDirection === 'asc'
? 'ascending'
: sortDirection === 'desc'
? 'descending'
: 'none'
: undefined}
>
{#if column.sortable}
<button
type="button"
class={cn(
'hover:text-fg focus-visible:outline-ring inline-flex items-center gap-1 rounded-xs font-medium transition-colors duration-(--dur-fast) focus-visible:outline-2 focus-visible:outline-offset-2',
sortDirection && 'text-fg'
)}
onclick={() => onSort(column.column)}
>
{column.label}
{#if sortDirection === 'asc'}
<ArrowUpIcon class="size-3" />
{:else if sortDirection === 'desc'}
<ArrowDownIcon class="size-3" />
{/if}
</button>
{:else}
{column.label}
{/if}
</Table.Head>
{/each}
{#if hasStickyColumn}
<!-- 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">
{#if hasOptionalColumns}
<AdvancedTableColumnSelection
{columns}
bind:selectedColumns={tablePreferences.current.visibleColumns}
/>
{/if}
</Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item (item.id)}
{@const selected = selectedIds?.includes(item.id)}
{@const nameId = `${id}-${item.id}-name`}
<Table.Row
data-state={selected ? 'selected' : undefined}
class={cn('group/row', (getPrimaryAction(item) || onRowClick) && 'cursor-pointer')}
onclick={onRowClick ? () => onRowClick(item) : getPrimaryAction(item)}
>
{#if selectedIds}
<!-- Selecting a row must not also open it -->
<Table.Cell class="w-10" onclick={(e) => e.stopPropagation()}>
@@ -281,7 +353,7 @@
aria-labelledby={nameId}
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
/>
</Table.Head>
</Table.Cell>
{/if}
{#each visibleColumns as column, i (column.key ?? column.column ?? column.label)}
<Table.Cell class={columnClass(column, i)} id={i === 0 ? nameId : undefined}>
@@ -294,47 +366,9 @@
{:else if column.column}
{item[column.column]}
{/if}
</Table.Head>
</Table.Cell>
{/each}
<!-- 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
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-10">
<Checkbox
disabled={selectionDisabled || rowSelectionDisabled?.(item)}
checked={selected}
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
/>
</Table.Cell>
{/if}
{#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}
{@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.Cell>
{/each}
{#if hasStickyColumn}
<!-- 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)"
@@ -373,3 +407,10 @@
{/if}
</div>
{/if}
<ListPagination
pagination={items?.pagination.totalItems ? items.pagination : undefined}
pageSizes={availablePageSizes}
{onPageChange}
{onPageSizeChange}
/>
@@ -26,7 +26,6 @@
import { cn, type WithElementRef } from '#lib/utils/style.js';
import { m } from '#lib/paraglide/messages.js';
import { LucideX } from '@lucide/svelte';
import { onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let {
@@ -35,53 +34,28 @@
variant = 'info',
children,
onDismiss,
dismissibleId = undefined,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
onDismiss?: () => void;
dismissibleId?: string;
} = $props();
let isVisible = $state(!dismissibleId);
onMount(() => {
if (dismissibleId) {
const dismissedAlerts = JSON.parse(localStorage?.getItem('dismissed-alerts') || '[]');
isVisible = !dismissedAlerts.includes(dismissibleId);
}
});
function dismiss() {
onDismiss?.();
if (dismissibleId) {
const dismissedAlerts = JSON.parse(localStorage?.getItem('dismissed-alerts') || '[]');
localStorage?.setItem(
'dismissed-alerts',
JSON.stringify([...dismissedAlerts, dismissibleId])
);
isVisible = false;
}
}
</script>
{#if isVisible}
<div
bind:this={ref}
data-slot="alert"
role={variant === 'destructive' ? 'alert' : 'status'}
class={cn(alertVariants({ variant }), (dismissibleId || onDismiss) && 'pr-10', className)}
{...restProps}
>
{@render children?.()}
{#if dismissibleId || onDismiss}
<button
onclick={dismiss}
aria-label={m.close()}
class="text-fg-muted hover:bg-fill-hover hover:text-fg focus-visible:outline-ring absolute top-2 right-2 grid size-6 place-items-center rounded-xs transition-colors focus-visible:outline-2"
>
<LucideX class="size-3.5" />
</button>
{/if}
</div>
{/if}
<div
bind:this={ref}
data-slot="alert"
role={variant === 'destructive' ? 'alert' : 'status'}
class={cn(alertVariants({ variant }), onDismiss && 'pr-10', className)}
{...restProps}
>
{@render children?.()}
{#if onDismiss}
<button
onclick={onDismiss}
aria-label={m.close()}
class="text-fg-muted hover:bg-fill-hover hover:text-fg focus-visible:outline-ring absolute top-2 right-2 grid size-6 place-items-center rounded-xs transition-colors focus-visible:outline-2"
>
<LucideX class="size-3.5" />
</button>
{/if}
</div>
@@ -11,7 +11,6 @@
variants: {
variant: {
default: `${tag} bg-fill-muted text-fg`,
secondary: `${tag} border-transparent bg-fill-muted text-fg`,
outline: `${tag} bg-transparent text-fg`,
mono: `${tag} h-5 font-mono text-mono-s`,
success: `${status} before:bg-success-solid`,
@@ -1,34 +0,0 @@
import Root from './item.svelte';
import Group from './item-group.svelte';
import Separator from './item-separator.svelte';
import Header from './item-header.svelte';
import Footer from './item-footer.svelte';
import Content from './item-content.svelte';
import Title from './item-title.svelte';
import Description from './item-description.svelte';
import Actions from './item-actions.svelte';
import Media from './item-media.svelte';
export {
Root,
Group,
Separator,
Header,
Footer,
Content,
Title,
Description,
Actions,
Media,
//
Root as Item,
Group as ItemGroup,
Separator as ItemSeparator,
Header as ItemHeader,
Footer as ItemFooter,
Content as ItemContent,
Title as ItemTitle,
Description as ItemDescription,
Actions as ItemActions,
Media as ItemMedia
};
@@ -1,20 +0,0 @@
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-actions"
class={cn('flex items-center gap-2', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -1,23 +0,0 @@
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-content"
class={cn(
'flex flex-1 flex-col gap-0.5 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -1,23 +0,0 @@
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="item-description"
class={cn(
'text-caption text-fg-muted [&>a:hover]:text-fg line-clamp-2 text-left [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...restProps}
>
{@render children?.()}
</p>
@@ -1,20 +0,0 @@
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-footer"
class={cn('flex basis-full items-center justify-between gap-2', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -1,24 +0,0 @@
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
role="list"
data-slot="item-group"
class={cn(
'group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -1,20 +0,0 @@
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-header"
class={cn('flex basis-full items-center justify-between gap-2', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -1,43 +0,0 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const itemMediaVariants = tv({
base: 'gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start flex shrink-0 items-center justify-center [&_svg]:pointer-events-none',
variants: {
variant: {
default: 'bg-transparent',
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
'size-10 overflow-hidden rounded-md group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 group-data-[size=xs]/item:rounded-xs [&_img]:size-full [&_img]:object-cover'
}
},
defaultVariants: {
variant: 'default'
}
});
export type ItemMediaVariant = VariantProps<typeof itemMediaVariants>['variant'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
variant = 'default',
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: ItemMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="item-media"
data-variant={variant}
class={cn(itemMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -1,19 +0,0 @@
<script lang="ts">
import { Separator } from '#lib/components/ui/separator/index.js';
import { cn } from '#lib/utils/style.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="item-separator"
orientation="horizontal"
class={cn('my-2', className)}
{...restProps}
/>
@@ -1,23 +0,0 @@
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-title"
class={cn(
'text-heading line-clamp-1 flex w-fit items-center gap-2 underline-offset-4',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -1,62 +0,0 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const itemVariants = tv({
base: 'group/item flex w-full flex-wrap items-center rounded-md border text-control transition-colors duration-(--dur-fast) outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [a]:hover:bg-fill-row-hover',
variants: {
variant: {
default: 'border-transparent',
outline: 'border-border bg-surface',
card: 'border-border bg-surface',
transparent: 'border-transparent bg-transparent'
},
size: {
default: 'gap-4 px-4 py-3',
sm: 'gap-3 px-3 py-2.5',
xs: 'gap-2.5 px-3 py-2 in-data-[slot=dropdown-menu-content]:p-0'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ItemSize = VariantProps<typeof itemVariants>['size'];
export type ItemVariant = VariantProps<typeof itemVariants>['variant'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '#lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
class: className,
child,
variant,
size,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
variant?: ItemVariant;
size?: ItemSize;
} = $props();
const mergedProps = $derived({
class: cn(itemVariants({ variant, size }), className),
'data-slot': 'item',
'data-variant': variant,
'data-size': size,
...restProps
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
@@ -1,10 +0,0 @@
import Scrollbar from './scroll-area-scrollbar.svelte';
import Root from './scroll-area.svelte';
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar
};
@@ -1,30 +0,0 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '#lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
{orientation}
class={cn(
'flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent',
className
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
class="bg-border-strong/50 relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.Scrollbar>
@@ -1,43 +0,0 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '#lib/utils/style.js';
import { Scrollbar } from './index.js';
let {
ref = $bindable(null),
viewportRef = $bindable(null),
class: className,
orientation = 'vertical',
scrollbarXClasses = '',
scrollbarYClasses = '',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
viewportRef?: HTMLElement | null;
} = $props();
</script>
<ScrollAreaPrimitive.Root
bind:ref
data-slot="scroll-area"
class={cn('relative', className)}
{...restProps}
>
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
class="focus-visible:outline-ring size-full rounded-[inherit] outline-none focus-visible:outline-2 focus-visible:-outline-offset-2"
>
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === 'vertical' || orientation === 'both'}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === 'horizontal' || orientation === 'both'}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
@@ -0,0 +1,4 @@
import Root from '../dialog/dialog.svelte';
import Content from './sheet-content.svelte';
export { Root, Content };
@@ -0,0 +1,73 @@
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.js';
import DialogOverlay from '#lib/components/ui/dialog/dialog-overlay.svelte';
import { m } from '#lib/paraglide/messages.js';
import { cn, type WithoutChildrenOrChild } from '#lib/utils/style.js';
import XIcon from '@lucide/svelte/icons/x';
import { Dialog as DialogPrimitive } from 'bits-ui';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
title,
footer,
class: className,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
title: string;
// Sticky actions under the scrolling body, right-aligned: the ghost Cancel, then the primary
footer?: Snippet;
children: Snippet;
} = $props();
const FIELD =
'input:not([type="hidden"]):not(:disabled), textarea:not(:disabled), [role="combobox"], [role="radio"]';
// Desktop starts in the first field so people can type right away; phones focus the sheet itself so the keyboard doesn't cover it
function focusFirstField(event: Event) {
event.preventDefault();
const phone = window.matchMedia('(width < 40rem)').matches;
const field = phone ? null : ref?.querySelector<HTMLElement>(FIELD);
(field ?? ref)?.focus();
}
</script>
<DialogPrimitive.Portal>
<DialogOverlay />
<!-- Inset from the window like the panel, and a full-height bottom sheet on phones -->
<DialogPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(
'bg-surface-raised text-fg shadow-overlay transition-motion fixed z-(--z-overlay) flex flex-col overflow-hidden border outline-none',
'inset-x-0 top-2 bottom-0 rounded-t-lg duration-(--dur-slow) data-closed:translate-y-full data-closed:duration-(--dur-base) data-closed:ease-in starting:translate-y-full',
'sm:inset-y-2 sm:right-2 sm:left-auto sm:w-[min(var(--sheet-w),calc(100%-48px))] sm:rounded-lg sm:data-closed:translate-x-(--slide) sm:data-closed:translate-y-0 sm:data-closed:opacity-0 sm:starting:translate-x-(--slide) sm:starting:translate-y-0 sm:starting:opacity-0',
className
)}
onOpenAutoFocus={focusFirstField}
{...restProps}
>
<div class="flex h-14 shrink-0 items-center gap-3 ps-6 pe-4">
<DialogPrimitive.Title class="text-title text-fg min-w-0 flex-1 truncate">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Close>
{#snippet child({ props })}
<Button {...props} variant="outline" size="icon-xs">
<XIcon />
<span class="sr-only">{m.close()}</span>
</Button>
{/snippet}
</DialogPrimitive.Close>
</div>
<div class="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 pt-2 pb-6">
{@render children()}
</div>
{#if footer}
<div class="flex h-14 shrink-0 items-center justify-end gap-2 border-t px-6">
{@render footer()}
</div>
{/if}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
@@ -1,13 +0,0 @@
import Root from './toggle.svelte';
export {
toggleVariants,
type ToggleSize,
type ToggleVariant,
type ToggleVariants
} from './toggle.svelte';
export {
Root,
//
Root as Toggle
};
@@ -1,51 +0,0 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const toggleVariants = tv({
base: "inline-flex items-center justify-center gap-2 rounded-sm text-control whitespace-nowrap text-fg-muted hover:bg-fill-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-45 data-[state=on]:bg-fill-selected data-[state=on]:font-medium data-[state=on]:text-fg [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: '',
outline: 'border border-border'
},
size: {
default: 'h-(--h-md) min-w-(--h-md) px-3',
sm: 'h-(--h-sm) min-w-(--h-sm) px-2.5',
lg: 'h-(--h-lg) min-w-(--h-lg) px-4'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ToggleVariant = VariantProps<typeof toggleVariants>['variant'];
export type ToggleSize = VariantProps<typeof toggleVariants>['size'];
export type ToggleVariants = VariantProps<typeof toggleVariants>;
</script>
<script lang="ts">
import { Toggle as TogglePrimitive } from 'bits-ui';
import { cn } from '#lib/utils/style.js';
let {
ref = $bindable(null),
pressed = $bindable(false),
class: className,
size = 'default',
variant = 'default',
...restProps
}: TogglePrimitive.RootProps & {
variant?: ToggleVariant;
size?: ToggleSize;
} = $props();
</script>
<TogglePrimitive.Root
bind:ref
bind:pressed
data-slot="toggle"
class={cn(toggleVariants({ variant, size }), className)}
{...restProps}
/>
@@ -1,41 +1,33 @@
<script lang="ts">
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 UserGroupService from '#lib/services/user-group-service.ts';
import type { AdvancedTableColumn } from '#lib/types/advanced-table.type.ts';
import type { UserGroupMinimal } from '#lib/types/user-group.type.ts';
let {
selectionDisabled = false,
selectedGroupIds = $bindable()
}: {
selectionDisabled?: boolean;
selectedGroupIds: string[];
} = $props();
let { selectedGroupIds = $bindable() }: { selectedGroupIds: string[] } = $props();
const userGroupService = new UserGroupService();
const columns: AdvancedTableColumn<UserGroupMinimal>[] = [
{ label: 'ID', column: 'id', hidden: true },
{ label: m.friendly_name(), column: 'friendlyName', sortable: true },
{ label: m.name(), column: 'name', sortable: true },
{ label: m.user_count(), column: 'userCount', sortable: true },
{
label: m.created(),
column: 'createdAt',
sortable: true,
hidden: true,
value: (item) => new Date(item.createdAt).toLocaleString()
},
{ label: m.ldap_id(), column: 'ldapId', hidden: true }
{ label: m.group(), column: 'friendlyName', sortable: true, cell: GroupCell },
{ label: m.members(), column: 'userCount', sortable: true },
{ label: m.name(), column: 'name', sortable: true, hidden: true }
];
</script>
{#snippet GroupCell({ item }: { item: UserGroupMinimal })}
<div class="flex max-w-(--table-id-max) min-w-0 items-center gap-3">
<OidcClientAvatar id={item.id} name={item.friendlyName} hasLogo={false} size={24} />
<span class="text-control text-fg truncate font-medium">{item.friendlyName}</span>
</div>
{/snippet}
<AdvancedTable
id="user-group-selection"
fetchCallback={userGroupService.list}
defaultSort={{ column: 'friendlyName', direction: 'asc' }}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
{columns}
/>
+1 -3
View File
@@ -72,8 +72,6 @@
</script>
<script lang="ts">
import { mode } from 'mode-watcher';
let {
class: className,
taut = 0,
@@ -93,7 +91,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 181.05 72.62" class={className}>
<g
transform="translate(11.145 36.748)"
stroke={mode.current == 'dark' ? 'white' : 'black'}
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
fill="none"
-24
View File
@@ -1,24 +0,0 @@
<script lang="ts">
let {
class: className
}: {
class?: string;
} = $props();
</script>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 336.19673868301203 129.38671875"
class={className}
>
<g stroke-linecap="round" transform="translate(10 10) rotate(0 158.09836934150601 54.693359375)">
<path
d="M27.35 0 C121.36 -0.62, 208.79 0.52, 288.85 0 M288.85 0 C305.5 3.32, 316.8 6.14, 316.2 27.35 M316.2 27.35 C315.58 42.15, 314.92 54.54, 316.2 82.04 M316.2 82.04 C313.79 100.68, 304.9 110.1, 288.85 109.39 M288.85 109.39 C192.86 108.68, 93.17 110.07, 27.35 109.39 M27.35 109.39 C13.09 109.46, -1.61 102.22, 0 82.04 M0 82.04 C-0.35 60.8, -1.11 41.01, 0 27.35 M0 27.35 C1.94 9.62, 8.6 1.41, 27.35 0"
stroke="#A1A1AA"
stroke-width="4.5"
fill="none"
stroke-dasharray="8 12"
></path>
</g>
</svg>
@@ -0,0 +1,6 @@
import type { Features } from '#lib/types/feature.type.ts';
import APIService from './api-service';
export default class FeatureService extends APIService {
list = async () => (await this.api.get<Features>('/features')).data;
}
@@ -1,4 +1,5 @@
import type { CustomClaim } from './custom-claim.type';
import type { Feature } from './feature.type';
export type AppConfig = {
appName: string;
@@ -60,6 +61,8 @@ export type AllAppConfig = AppConfig & {
// OIDC
cimdUrlAllowlist: string[];
autoCreateOidcClientSecret: boolean;
// Features
enabledFeatures: Feature[];
};
export type AppConfigRawResponse = {
+11
View File
@@ -0,0 +1,11 @@
export type Feature =
| 'ldap'
| 'scim'
| 'customClaims'
| 'apis'
| 'apiKeys'
| 'cimd'
| 'federatedCredentials';
// A feature in use is always enabled, and only an unused one can be turned off
export type Features = Record<Feature, { enabled: boolean; inUse: boolean }>;
+106
View File
@@ -0,0 +1,106 @@
import { m } from '#lib/paraglide/messages.js';
import type { Feature } from '#lib/types/feature.type.ts';
import {
LucideBookUser,
LucideFileCode,
LucideKey,
LucideRefreshCw,
LucideServer,
LucideTags,
LucideWaypoints,
type LucideIcon
} from '@lucide/svelte';
export type FeatureRow = {
id: Feature;
title: string;
description: string;
// Where the feature shows up, which the Features tab adds to the description
adds: string;
inUse: string;
icon: LucideIcon;
};
// The Features tab, the welcome step and the command palette list the features in this order
export function featureSections(appName: string): {
title: string;
description: string;
rows: FeatureRow[];
}[] {
return [
{
title: m.features_directory(),
description: m.features_directory_description(),
rows: [
{
id: 'ldap',
title: m.ldap(),
description: m.feature_ldap_description(),
adds: m.feature_ldap_adds(),
inUse: m.feature_ldap_in_use(),
icon: LucideBookUser
},
{
id: 'scim',
title: m.scim_provisioning(),
description: m.feature_scim_description(),
adds: m.feature_scim_adds(),
inUse: m.feature_scim_in_use(),
icon: LucideRefreshCw
},
{
id: 'customClaims',
title: m.custom_claims(),
description: m.feature_custom_claims_description(),
adds: m.feature_custom_claims_adds(),
inUse: m.feature_custom_claims_in_use(),
icon: LucideTags
}
]
},
{
title: m.features_clients(),
description: m.features_clients_description({ appName }),
rows: [
{
id: 'cimd',
title: m.client_id_metadata_documents(),
description: m.feature_cimd_description(),
adds: m.feature_cimd_adds(),
inUse: m.feature_cimd_in_use(),
icon: LucideFileCode
},
{
id: 'federatedCredentials',
title: m.federated_client_credentials(),
description: m.feature_federated_credentials_description(),
adds: m.feature_federated_credentials_adds(),
inUse: m.feature_federated_credentials_in_use(),
icon: LucideWaypoints
}
]
},
{
title: m.features_developers(),
description: m.features_developers_description({ appName }),
rows: [
{
id: 'apis',
title: m.apis(),
description: m.feature_apis_description(),
adds: m.feature_apis_adds(),
inUse: m.feature_apis_in_use(),
icon: LucideServer
},
{
id: 'apiKeys',
title: m.api_keys(),
description: m.feature_api_keys_description({ appName }),
adds: m.feature_api_keys_adds(),
inUse: m.feature_api_keys_in_use(),
icon: LucideKey
}
]
}
];
}
+23 -3
View File
@@ -1,7 +1,27 @@
<script lang="ts">
import Error from '#lib/components/error.svelte';
import { page } from '$app/state';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import AuthTile from '#lib/components/auth/auth-tile.svelte';
import { authPrimaryClass } from '#lib/components/auth/auth-util.ts';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { ErrorProps } from './$types';
let { error } = $props();
let { error }: ErrorProps = $props();
const title = $derived(page.status === 404 ? m.page_not_found_title() : m.something_went_wrong());
</script>
<Error message={error.message} />
<svelte:head>
<title>{title}</title>
</svelte:head>
<SignInWrapper>
<AuthTile />
<!-- Without a matched route the message is SvelteKit's untranslated default -->
<AuthHeading {title}>
{page.route.id ? error.message : m.page_not_found()}
</AuthHeading>
<Button class={authPrimaryClass} href="/">{m.go_back_to_home()}</Button>
</SignInWrapper>
+220 -176
View File
@@ -1,10 +1,22 @@
<script lang="ts">
import FormattedMessage from '#lib/components/formatted-message.svelte';
import ActionPanel from '#lib/components/auth/action-panel.svelte';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import AuthTile from '#lib/components/auth/auth-tile.svelte';
import {
alternativeSignInHref,
authPrimaryClass,
authQuietClass,
isPasskeyPromptClosed
} from '#lib/components/auth/auth-util.ts';
import AuthorizeTiles from '#lib/components/auth/authorize-tiles.svelte';
import CodeGroup from '#lib/components/auth/code-group.svelte';
import CodeLine from '#lib/components/auth/code-line.svelte';
import TrustLine from '#lib/components/auth/trust-line.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 { Button } from '#lib/components/ui/button/index.ts';
import * as Card from '#lib/components/ui/card/index.ts';
import * as InputOTP from '#lib/components/ui/input-otp/index.ts';
import { Label } from '#lib/components/ui/label/index.ts';
import { m } from '#lib/paraglide/messages.js';
import DeviceLoginService from '#lib/services/device-login-service.ts';
import OIDCService from '#lib/services/oidc-service.ts';
@@ -17,10 +29,7 @@
import { getAxiosErrorCode, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { preventDefault } from '#lib/utils/event-util.ts';
import { startAuthentication } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
import LoginLogoErrorSuccessIndicator from '../login/components/login-logo-error-success-indicator.svelte';
import { onMount, untrack } from 'svelte';
let { data } = $props();
@@ -35,7 +44,7 @@
let success = $state(false);
let deviceLoginOutcome: 'approved' | 'denied' | undefined = $state();
let deviceLoginDecision: 'approve' | 'deny' | undefined = $state();
let errorMessage: string | null = $state(null);
let failure: { message: string; code?: string; closed: boolean } | undefined = $state();
let authorizationRequired = $state(false);
let selectedScopes: string[] = $state([]);
let reauthenticationRequired = $state(false);
@@ -44,15 +53,46 @@
userCode.trim().toUpperCase().replaceAll('I', '1').replaceAll('O', '0')
);
let codeComplete = $derived(normalizedUserCode.length === 8);
let completed = $derived(success || deviceLoginOutcome !== undefined);
let errorCode: string | undefined = $state();
let emailVerificationRequired = $derived(errorCode === 'email_verification_required');
let signedInName = $derived($userStore?.displayName || $userStore?.username || '');
let deviceLoginLocation = $derived(
deviceLoginInfo?.city && deviceLoginInfo.country
? `${deviceLoginInfo.city}, ${deviceLoginInfo.country}`
: deviceLoginInfo?.city || deviceLoginInfo?.country || m.unknown()
);
const client = $derived(deviceInfo?.client);
const clientName = $derived(client ? (getClientIDHost(client) ?? client.name) : '');
const view = $derived(
deviceLoginOutcome
? deviceLoginOutcome
: success
? 'authorized'
: deviceLoginInfo
? 'review'
: client && (authorizationRequired || reauthenticationRequired)
? 'consent'
: 'enter'
);
// Failures of the code itself show under the cells, everything else in the status line
const codeFailure = $derived(
view === 'enter' &&
(failure?.code === 'oidc_device_code_expired'
? 'expired'
: failure?.code === 'oidc_invalid_device_code' || failure?.code === 'device_login_expired'
? 'invalid'
: undefined)
);
const statusFailure = $derived(codeFailure ? undefined : failure);
const statusTone = $derived(
statusFailure ? (statusFailure.closed ? 'info' : 'danger') : undefined
);
// Typing again clears the last outcome
$effect(() => {
void userCode;
untrack(() => (failure = undefined));
});
onMount(() => {
// Device login codes always show a confirmation before approval
if (data.code && normalizedUserCode.startsWith('P') && $userStore) {
@@ -61,11 +101,14 @@
});
async function authorize() {
if (!data.code && !codeComplete) return;
if (!codeComplete) return;
if (codeFailure === 'expired') {
userCode = '';
return;
}
isLoading = true;
errorMessage = null;
errorCode = undefined;
failure = undefined;
try {
await authenticateUserIfNeeded();
@@ -101,8 +144,7 @@
);
success = true;
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
errorCode = getAxiosErrorCode(error);
setFailure(error);
} finally {
isLoading = false;
}
@@ -120,8 +162,7 @@
async function decideDeviceLogin(decision: 'approve' | 'deny') {
isLoading = true;
deviceLoginDecision = decision;
errorMessage = null;
errorCode = undefined;
failure = undefined;
try {
if (decision === 'approve') {
await reauthenticate();
@@ -129,8 +170,7 @@
await deviceLoginService.decideRequest(normalizedUserCode, decision);
deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied';
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
errorCode = getAxiosErrorCode(error);
setFailure(error);
} finally {
isLoading = false;
deviceLoginDecision = undefined;
@@ -147,179 +187,183 @@
}
}
function retry() {
errorMessage = null;
errorCode = undefined;
if (!deviceLoginInfo) {
userCode = '';
}
if (!deviceLoginInfo) {
deviceInfo = undefined;
authorizationRequired = false;
reauthenticationRequired = false;
}
function setFailure(error: unknown) {
failure = {
message: getWebauthnErrorMessage(error),
code: getAxiosErrorCode(error),
closed: isPasskeyPromptClosed(error)
};
}
</script>
<svelte:head>
<title>{m.authorize_device()}</title>
<title>{m.auth_connect_a_device()}</title>
</svelte:head>
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
<div class="flex justify-center">
{#if deviceInfo?.client}
<ClientProviderImages client={deviceInfo.client} {success} error={!!errorMessage} />
{:else}
<LoginLogoErrorSuccessIndicator
success={success || deviceLoginOutcome === 'approved'}
error={!!errorMessage}
/>
{/if}
</div>
<h1 class="font-display mt-5 text-4xl font-bold">
{m.authorize_device()}
</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2">
{errorMessage}. {#if !emailVerificationRequired}{m.please_try_again()}{/if}
</p>
{:else if deviceLoginOutcome === 'approved'}
<p class="text-muted-foreground mt-2">{m.the_requesting_device_has_been_signed_in()}</p>
{:else if deviceLoginOutcome === 'denied'}
<p class="text-muted-foreground mt-2">{m.the_sign_in_request_was_denied()}</p>
{:else if success}
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
{:else if deviceLoginInfo}
<p class="text-muted-foreground mt-2">{m.review_the_request_before_approving_it()}</p>
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 text-start">
<Card.Content>
<dl class="flex flex-col gap-4 text-sm">
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.code()}</dt>
<dd class="font-medium">
{deviceLoginInfo.userCode.substring(0, 4)} - {deviceLoginInfo.userCode.substring(
4,
8
)}
</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.device()}</dt>
<dd class="text-right font-medium">{deviceLoginInfo.device}</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.ip_address()}</dt>
<dd class="font-medium">{deviceLoginInfo.ipAddress || m.unknown()}</dd>
</div>
<div class="flex items-start justify-between gap-6">
<dt class="text-muted-foreground">{m.approximate_location()}</dt>
<dd class="text-right font-medium">{deviceLoginLocation}</dd>
</div>
</dl>
</Card.Content>
</Card.Root>
</div>
{:else if reauthenticationRequired && deviceInfo?.client}
<p class="text-muted-foreground mt-2">
<FormattedMessage
message={m.do_you_want_to_sign_in_to_client_with_your_app_name_account}
inputs={{
client: getClientIDHost(deviceInfo!.client) ?? deviceInfo!.client.name,
appName: $appConfigStore.appName
}}
/>
</p>
{:else if authorizationRequired}
<div class="w-full max-w-112.5" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 gap-2">
<Card.Header>
<Card.Description class="text-start">
<FormattedMessage
message={m.client_wants_to_access_the_following_information}
inputs={{ client: getClientIDHost(deviceInfo!.client) ?? deviceInfo!.client.name }}
/>
</Card.Description>
</Card.Header>
<Card.Content data-testid="scopes">
<ScopeList
scopes={deviceInfo!.scope || []}
scopeInfo={deviceInfo!.scopeInfo || []}
bind:selectedScopes
/>
</Card.Content>
</Card.Root>
</div>
{#snippet status(fallback: string)}
{#if statusFailure}
{statusFailure.closed ? m.auth_passkey_prompt_closed() : statusFailure.message}
{:else}
<p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p>
<form
id="device-code-form"
onsubmit={preventDefault(authorize)}
class="mt-7 flex w-full max-w-112.5 justify-center"
>
<InputOTP.Root
class="gap-1 sm:gap-2"
maxlength={8}
aria-label={m.code()}
bind:value={userCode}
onValueChange={(value) => (userCode = value.toUpperCase())}
pasteTransformer={(value) => value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()}
>
{#snippet children({ cells })}
<InputOTP.Group class="gap-0.5 sm:gap-1.5">
{#each cells.slice(0, 4) as cell (cell)}
<InputOTP.Slot class="h-12 w-8 sm:h-13 sm:w-10" {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group class="gap-0.5 sm:gap-1.5">
{#each cells.slice(4) as cell (cell)}
<InputOTP.Slot class="h-12 w-8 sm:h-13 sm:w-10" {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
</form>
{fallback}
{/if}
{#if !completed}
<div class="mt-10 flex w-full max-w-112.5 gap-2">
{#if errorMessage}
<Button class="flex-1" variant="secondary" href="/">{m.cancel()}</Button>
{#if emailVerificationRequired}
<Button class="flex-1" href="/settings/account">{m.verify_email_address()}</Button>
{:else}
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
{/snippet}
<SignInWrapper wide={view === 'consent' && authorizationRequired}>
{#if client && (view === 'consent' || view === 'authorized')}
<AuthorizeTiles
{client}
success={view === 'authorized'}
error={!!statusFailure && !statusFailure.closed}
/>
{:else}
<AuthTile success={view === 'approved'} />
{/if}
{#if view === 'authorized'}
<AuthHeading title={m.auth_authorized()}>
{m.auth_device_authorized_description({ name: clientName })}
</AuthHeading>
{:else if view === 'approved'}
<AuthHeading title={m.auth_signed_in()}>
{m.auth_other_device_signed_in({ device: deviceLoginInfo!.device, name: signedInName })}
</AuthHeading>
{:else if view === 'denied'}
<AuthHeading title={m.auth_request_denied()}>
{m.auth_other_device_denied({ device: deviceLoginInfo!.device })}
</AuthHeading>
{:else if view === 'review'}
<AuthHeading
title={m.auth_device_wants_to_sign_in({ device: deviceLoginInfo!.device })}
tone={statusTone}
>
{@render status(m.auth_does_this_code_match())}
</AuthHeading>
<div
class="mb-4 flex items-center font-mono text-xl font-medium"
data-testid="device-login-code"
>
{#each [deviceLoginInfo!.userCode.slice(0, 4), deviceLoginInfo!.userCode.slice(4)] as group, i (i)}
{#if i > 0}
<span class="text-fg-muted w-7 shrink-0 text-center text-xs">–</span>
{/if}
{:else if deviceLoginInfo}
<span class="bg-surface-sunken flex min-w-0 flex-1 rounded-sm">
{#each group as char, j (j)}
<span class="grid h-13 min-w-0 flex-1 place-items-center max-lg:h-11">{char}</span>
{/each}
</span>
{/each}
</div>
<dl class="mb-6 flex flex-col gap-1.5">
<div class="grid grid-cols-[100px_minmax(0,1fr)] items-baseline gap-2">
<dt class="text-caption text-fg-muted">{m.auth_location()}</dt>
<dd class="text-control">{deviceLoginLocation}</dd>
</div>
<div class="grid grid-cols-[100px_minmax(0,1fr)] items-baseline gap-2">
<dt class="text-caption text-fg-muted">{m.auth_ip_address()}</dt>
<dd class="text-mono-s font-mono">{deviceLoginInfo!.ipAddress || m.unknown()}</dd>
</div>
</dl>
<div class="mb-6">
<IdentityRow user={$userStore!} />
</div>
<!-- Refusing a security request is an answer, so Deny is a full-width button rather than a text action -->
<ActionPanel>
<div class="flex flex-col gap-3">
<Button
class={authPrimaryClass}
disabled={isLoading}
isLoading={deviceLoginDecision === 'approve'}
onclick={() => decideDeviceLogin('approve')}
>
{statusFailure ? m.try_again() : m.approve()}
</Button>
<Button
class="flex-1"
variant="secondary"
class={authPrimaryClass}
disabled={isLoading}
isLoading={deviceLoginDecision === 'deny'}
onclick={() => decideDeviceLogin('deny')}
>
{m.deny()}
</Button>
<Button
class="flex-1"
disabled={isLoading}
isLoading={deviceLoginDecision === 'approve'}
onclick={() => decideDeviceLogin('approve')}
>
{m.approve()}
</Button>
{:else}
<Button href="/" class="flex-1" variant="secondary">{m.cancel()}</Button>
<Button
form="device-code-form"
class="flex-1"
disabled={!codeComplete}
{isLoading}
onclick={authorize}
>
{m.authorize()}
</Button>
{/if}
</div>
<p class="text-control text-fg-muted mt-2 text-center">
{m.auth_approving_asks_for_passkey()}
</p>
</ActionPanel>
{:else if view === 'consent' && client}
<AuthHeading title={client.name} tone={statusTone}>
{@render status(m.auth_wants_to_use_account_on_device({ appName: $appConfigStore.appName }))}
</AuthHeading>
<TrustLine {client} />
<div class="mb-6">
<IdentityRow user={$userStore!} />
</div>
{#if authorizationRequired}
<div class="mb-6 lg:break-before-column">
<ScopeList
scopes={deviceInfo!.scope || []}
scopeInfo={deviceInfo!.scopeInfo || []}
bind:selectedScopes
/>
</div>
{/if}
<ActionPanel>
<div class="flex flex-col items-center gap-3">
{#if failure?.code === 'email_verification_required'}
<Button class={authPrimaryClass} href="/settings/account">
{m.verify_email_address()}
</Button>
{:else}
<Button class={authPrimaryClass} {isLoading} onclick={authorize}>
{statusFailure ? m.try_again() : m.approve()}
</Button>
{/if}
<Button variant="link" class={authQuietClass} href="/">{m.cancel()}</Button>
</div>
</ActionPanel>
{:else}
<AuthHeading title={m.auth_connect_a_device()} tone={statusTone}>
{@render status(
codeFailure === 'expired'
? m.auth_device_code_expired_description()
: m.auth_enter_device_code()
)}
</AuthHeading>
<form onsubmit={preventDefault(authorize)} class="flex flex-col">
<Label for="device-code" class="mb-2">{m.auth_device_code()}</Label>
<CodeGroup
id="device-code"
groups={[4, 4]}
uppercase
bind:value={userCode}
invalid={codeFailure === 'invalid'}
expired={codeFailure === 'expired'}
readonly={isLoading}
aria-describedby="device-code-line"
autofocus={!userCode}
/>
<CodeLine
id="device-code-line"
tone={codeFailure === 'invalid' ? 'error' : codeFailure === 'expired' ? 'info' : undefined}
>
{#if codeFailure === 'invalid'}
{m.auth_code_did_not_work()}
{:else if codeFailure === 'expired'}
{m.auth_code_has_expired()}
{/if}
</CodeLine>
<div class="mt-6 flex flex-col items-center gap-3">
<Button type="submit" class={authPrimaryClass} disabled={!codeComplete} {isLoading}>
{codeFailure === 'expired' ? m.auth_enter_a_new_code() : m.auth_continue()}
</Button>
{#if !$userStore}
<Button variant="link" class={authQuietClass} href={alternativeSignInHref()}>
{m.auth_other_ways_to_sign_in()}
</Button>
{/if}
<Button variant="link" class={authQuietClass} href="/">{m.cancel()}</Button>
</div>
</form>
{/if}
</SignInWrapper>
+167 -90
View File
@@ -1,11 +1,21 @@
<script lang="ts">
import { refreshAll } from '$app/navigation';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import ActionPanel from '#lib/components/auth/action-panel.svelte';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import {
alternativeSignInHref,
authPrimaryClass,
authQuietClass,
isPasskeyPromptClosed,
successHold
} from '#lib/components/auth/auth-util.ts';
import AuthorizeTiles from '#lib/components/auth/authorize-tiles.svelte';
import TrustLine from '#lib/components/auth/trust-line.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 { Button } from '#lib/components/ui/button/index.ts';
import * as Card from '#lib/components/ui/card/index.ts';
import { Spinner } from '#lib/components/ui/spinner/index.ts';
import { m } from '#lib/paraglide/messages.js';
import OidcService from '#lib/services/oidc-service.ts';
import WebAuthnService from '#lib/services/webauthn-service.ts';
@@ -14,9 +24,9 @@
import type { InteractionStep } from '#lib/types/oidc.type.ts';
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
import { getAxiosErrorCode, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { cssMs } from '#lib/utils/style.ts';
import { startAuthentication } from '@simplewebauthn/browser';
import { slide } from 'svelte/transition';
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
import { LucideChevronRight } from '@lucide/svelte';
import type { PageProps } from './$types';
const webauthnService = new WebAuthnService();
@@ -25,24 +35,44 @@
let { data }: PageProps = $props();
let interactionSession = $state(data.interactionSession);
let isLoading = $state(false);
let success = $state(false);
let errorMessage: string | null = $state(null);
let errorCode: string | undefined = $state();
// Signing in keeps the sign-in step on screen until the next step is known, so the card changes size once
let signingIn = $state(false);
let redirectUrl: string | undefined = $state();
let showFallback = $state(false);
let failure: { message: string; code?: string; closed: boolean } | undefined = $state();
let currentStep = $derived(interactionSession.currentStep);
// 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 client = $derived(interactionSession.client);
// Metadata-document clients go by their host in body copy, because their display name is self-declared
const clientName = $derived(getClientIDHost(client) ?? client.name);
// openid and offline_access show nothing, so a request for only those keeps the compact card
const hasScopes = $derived(
(interactionSession.scopes ?? []).some((s) => s !== 'openid' && s !== 'offline_access')
);
const emailVerificationRequired = $derived(failure?.code === 'email_verification_required');
const view = $derived(
redirectUrl
? 'success'
: !$userStore || signingIn
? 'sign-in'
: emailVerificationRequired
? 'verify'
: (currentStep ?? 'authenticate')
);
const cancelHref = document.referrer || '/';
async function useDifferentAccount() {
await webauthnService.logout();
await completeInteraction('select_account', true);
userStore.clearUser();
await refreshAll();
}
async function handlePipeline() {
isLoading = true;
errorMessage = null;
errorCode = undefined;
signingIn = !$userStore;
failure = undefined;
try {
if (!$userStore) {
await authenticate();
@@ -54,11 +84,14 @@
await completeInteraction(currentStep!);
} catch (e) {
success = false;
errorMessage = getWebauthnErrorMessage(e);
errorCode = getAxiosErrorCode(e);
failure = {
message: getWebauthnErrorMessage(e),
code: getAxiosErrorCode(e),
closed: isPasskeyPromptClosed(e)
};
} finally {
isLoading = false;
signingIn = false;
}
}
@@ -95,98 +128,142 @@
}
if (result.redirectUrl && !skipRedirect) {
success = true;
await new Promise((r) => setTimeout(r, 800));
redirectUrl = result.redirectUrl;
// The way forward becomes a link when the browser has not left after a few seconds
setTimeout(() => (showFallback = true), cssMs('--t-redirect-fallback'));
await successHold();
window.location.href = result.redirectUrl;
}
}
</script>
<svelte:head>
<title>{m.sign_in_to({ name: interactionSession.client.name })}</title>
<title>{m.sign_in_to({ name: client.name })}</title>
</svelte:head>
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
<ClientProviderImages client={interactionSession.client} {success} error={!!errorMessage} />
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to({ name: interactionSession.client.name })}
</h1>
<p class="text-muted-foreground mt-2 mb-10">
{#if errorMessage}
{errorMessage}.
{:else if currentStep == 'select_account' && $userStore}
<FormattedMessage
message={m.account_selection_signin_confirmation}
inputs={{
name: getClientIDHost(interactionSession.client) ?? interactionSession.client.name
}}
/>
{:else}
<FormattedMessage
message={m.do_you_want_to_sign_in_to_client_with_your_app_name_account}
inputs={{
client: getClientIDHost(interactionSession.client) ?? interactionSession.client.name,
appName: $appConfigStore.appName
}}
/>
{/if}
</p>
{#if !$userStore || errorMessage}
<!-- Return nothing -->
{:else if currentStep === 'select_account'}
<SignInWrapper wide={view === 'consent' && hasScopes}>
<AuthorizeTiles {client} success={!!redirectUrl} error={!!failure && !failure.closed} />
{#if view === 'success'}
<AuthHeading title={m.auth_signed_in()}>
{m.auth_taking_you_to({ name: clientName })}
</AuthHeading>
<div
transition:slide={{ duration: 300 }}
class="flex flex-col items-center"
data-testid="account-selection"
class="transition-opacity duration-(--dur-base)"
class:invisible={!showFallback}
class:opacity-0={!showFallback}
>
{#if $userStore}
<div class="mx-5 mb-2 w-full sm:min-w-sm">
<IdentityRow user={$userStore} />
</div>
<Button variant="link" class="text-body h-(--h-xl) max-w-full gap-1" href={redirectUrl}>
<span class="truncate">{m.auth_continue_to({ name: clientName })}</span>
<LucideChevronRight />
</Button>
</div>
{:else}
<AuthHeading
title={view === 'select_account'
? m.auth_choose_an_account()
: view === 'reauthenticate'
? m.auth_confirm_its_you()
: client.name}
tone={failure ? (failure.closed ? 'info' : 'danger') : undefined}
>
{#if failure}
{failure.closed ? m.auth_passkey_prompt_closed() : failure.message}
{:else if view === 'select_account'}
{m.auth_to_continue_to({ name: clientName })}
{:else if view === 'reauthenticate'}
{m.auth_confirm_with_passkey({ name: clientName })}
{:else}
{m.auth_wants_to_sign_you_in({ appName: $appConfigStore.appName })}
{/if}
<div class="mb-10 flex justify-center">
</AuthHeading>
{#if view === 'consent' || view === 'authenticate'}
<TrustLine {client} />
{/if}
{#if view === 'select_account'}
<div class="mb-6" data-testid="account-selection">
<button
type="button"
class="text-muted-foreground text-xs transition-colors hover:underline"
onclick={useDifferentAccount}
class="group/account focus-visible:outline-ring after:bg-fg/4 transition-motion relative block w-full rounded-md text-start after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:opacity-0 after:transition-opacity after:duration-(--dur-fast) hover:after:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-(--press-scale)"
aria-busy={isLoading || undefined}
disabled={isLoading}
onclick={handlePipeline}
>
{m.use_a_different_account()}
<IdentityRow user={$userStore!}>
<span class="text-fg-muted shrink-0">
{#if isLoading}
<Spinner class="size-4" />
{:else}
<LucideChevronRight
class="size-4 transition-transform duration-(--dur-fast) group-hover/account:translate-x-(--nudge)"
/>
{/if}
</span>
</IdentityRow>
</button>
</div>
</div>
{:else if currentStep === 'consent'}
<div class="w-full max-w-md" transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 gap-3">
<Card.Header>
<p class="text-muted-foreground text-start">
<FormattedMessage
message={m.client_wants_to_access_the_following_information}
inputs={{
client: getClientIDHost(interactionSession.client) ?? interactionSession.client.name
}}
/>
</p>
</Card.Header>
<Card.Content>
<ScopeList
scopes={interactionSession.scopes || []}
scopeInfo={interactionSession.scopeInfo ?? []}
bind:selectedScopes
/>
</Card.Content>
</Card.Root>
</div>
{/if}
<div class="flex w-full max-w-md flex-row-reverse gap-2">
{#if errorCode === 'email_verification_required'}
<Button class="flex-1" href="/settings/account">{m.verify_email_address()}</Button>
{:else}
<Button class="flex-1" {isLoading} onclick={handlePipeline} autofocus={true}>
{errorMessage ? m.try_again() : m.sign_in()}
</Button>
{:else if $userStore && view !== 'verify' && view !== 'sign-in'}
<div class="mb-6">
<IdentityRow user={$userStore} />
</div>
{/if}
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
{m.cancel()}
</Button>
</div>
{#if view === 'consent' && hasScopes}
<div class="mb-6 lg:break-before-column">
<ScopeList
scopes={interactionSession.scopes || []}
scopeInfo={interactionSession.scopeInfo ?? []}
bind:selectedScopes
/>
</div>
{/if}
<ActionPanel>
<div class="flex flex-col items-center gap-3">
{#if view === 'verify'}
<Button class={authPrimaryClass} href="/settings/account">
{m.verify_email_address()}
</Button>
{:else if view === 'sign-in'}
<Button class={authPrimaryClass} {isLoading} onclick={handlePipeline}>
{failure ? m.try_again() : m.auth_sign_in_with_passkey()}
</Button>
<Button variant="link" class={authQuietClass} href={alternativeSignInHref()}>
{m.auth_other_ways_to_sign_in()}
</Button>
{:else if view === 'select_account'}
<Button variant="link" class={authQuietClass} onclick={useDifferentAccount}>
{m.use_a_different_account()}
</Button>
{:else}
<!-- A metadata-document client goes by its host, which can be longer than the button -->
<Button class={authPrimaryClass} {isLoading} onclick={handlePipeline}>
<span class="truncate">
{failure
? m.try_again()
: view === 'reauthenticate'
? m.auth_continue_with_passkey()
: m.auth_continue_to({ name: clientName })}
</span>
</Button>
{/if}
<!-- Cancel goes back through authorize, so the app hears access_denied instead of the person being left on Pocket ID -->
<Button
variant="link"
class={authQuietClass}
href={`/authorize?interaction=${encodeURIComponent(interactionSession.id)}&cancel=true`}
>
{m.cancel()}
</Button>
{/if}
<Button variant="link" class={authQuietClass} href={cancelHref}>{m.cancel()}</Button>
</div>
{#if view === 'consent'}
<p class="text-control text-fg-muted mt-2 text-center">{m.auth_remove_access_anytime()}</p>
{/if}
</ActionPanel>
{/if}
</SignInWrapper>
+2 -1
View File
@@ -1,3 +1,4 @@
import { m } from '#lib/paraglide/messages.js';
import OidcService from '#lib/services/oidc-service.ts';
import { getAxiosErrorMessage } from '#lib/utils/error-util.ts';
import { redirect } from '@sveltejs/kit';
@@ -6,7 +7,7 @@ import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const interactionSessionId = url.searchParams.get('interaction');
if (!interactionSessionId) {
return redirectToError('Missing authorize interaction');
return redirectToError(m.auth_missing_interaction());
}
const oidcService = new OidcService();
@@ -1,44 +1,41 @@
<script lang="ts">
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import { authPrimaryClass, authQuietClass } from '#lib/components/auth/auth-util.ts';
import AuthorizeTiles from '#lib/components/auth/authorize-tiles.svelte';
import IdentityRow from '#lib/components/identity-row.svelte';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import userStore from '#lib/stores/user-store.ts';
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
import ClientProviderImages from '../../authorize/components/client-provider-images.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let { error } = data;
const backHref = document.referrer || '/';
</script>
<svelte:head>
<title>{m.error()}</title>
<title>{m.auth_could_not_sign_you_in()}</title>
</svelte:head>
<SignInWrapper>
<ClientProviderImages error success={false} />
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
{m.error()}
</h1>
<p class="text-muted-foreground mt-2 mb-10">
{error}
</p>
<div class="flex w-full flex-col items-center gap-3">
<AuthorizeTiles error />
<AuthHeading title={m.auth_could_not_sign_you_in()}>{data.error}</AuthHeading>
{#if $userStore}
<div class="mb-6">
<IdentityRow user={$userStore} />
</div>
{/if}
<div class="flex flex-col items-center gap-3">
{#if isEmailVerificationPending($userStore, $appConfigStore)}
<div class="flex w-full max-w-md flex-row-reverse gap-2">
<Button class="flex-1" href="/settings/account">
{m.verify_email_address()}
</Button>
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
{m.go_back()}
</Button>
</div>
{:else}
<Button class="w-full sm:w-[50%]" variant="secondary" href={document.referrer || '/'}>
{m.go_back()}
<Button class={authPrimaryClass} href="/settings/account">
{m.verify_email_address()}
</Button>
<Button variant="link" class={authQuietClass} href={backHref}>{m.go_back()}</Button>
{:else}
<Button class={authPrimaryClass} href={backHref}>{m.go_back()}</Button>
{/if}
</div>
</SignInWrapper>
@@ -1,7 +1,8 @@
import { m } from '#lib/paraglide/messages.js';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const error = url.searchParams.get('error') ?? 'An unknown error occurred.';
const error = url.searchParams.get('error') ?? m.an_unknown_error_occurred();
return {
error
+59 -33
View File
@@ -1,5 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import AuthTile from '#lib/components/auth/auth-tile.svelte';
import {
alternativeSignInHref,
authPrimaryClass,
authQuietClass,
isPasskeyPromptClosed,
successHold,
takingYouMessage
} from '#lib/components/auth/auth-util.ts';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
@@ -8,18 +18,29 @@
import userStore from '#lib/stores/user-store.ts';
import { getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { startAuthentication } from '@simplewebauthn/browser';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
import { onMount } from 'svelte';
let { data } = $props();
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
let error: string | undefined = $state(undefined);
let success = $state(false);
let failure: { message: string; closed: boolean } | undefined = $state();
let primary = $state<HTMLElement | null>(null);
// Enter works right away, but the ring only shows once a key is pressed
onMount(() => primary?.focus({ focusVisible: false }));
// Chrome keeps a focus taken without a ring ringless through later key presses, so the first one focuses it again
function showFocusRing() {
if (!primary || document.activeElement !== primary || primary.matches(':focus-visible')) return;
primary.blur();
primary.focus();
}
async function authenticate() {
error = undefined;
failure = undefined;
isLoading = true;
try {
const loginOptions = await webauthnService.getLoginOptions();
@@ -27,9 +48,11 @@
const user = await webauthnService.finishLogin(authResponse);
await userStore.setUser(user);
success = true;
await successHold();
await goto(data.redirect || '/settings');
} catch (e) {
error = getWebauthnErrorMessage(e);
failure = { message: getWebauthnErrorMessage(e), closed: isPasskeyPromptClosed(e) };
}
isLoading = false;
}
@@ -39,35 +62,38 @@
<title>{m.sign_in()}</title>
</svelte:head>
<SignInWrapper showAlternativeSignInMethodButton>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to_appname({ appName: $appConfigStore.appName })}
</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. {m.please_try_to_sign_in_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2" in:fade>
{m.authenticate_with_passkey_to_access_account()}
</p>
{/if}
<div class="mt-10 flex w-full max-w-[450px] justify-center gap-3">
{#if $appConfigStore.allowUserSignups === 'open'}
<Button class="w-[50%]" variant="secondary" href="/signup">
{m.signup()}
</Button>
<svelte:window onkeydown={showFocusRing} />
<SignInWrapper>
<AuthTile {success} />
<AuthHeading
title={success
? m.auth_signed_in()
: m.sign_in_to_appname({ appName: $appConfigStore.appName })}
tone={failure ? (failure.closed ? 'info' : 'danger') : undefined}
>
{#if success}
{takingYouMessage(data.redirect)}
{:else if failure}
{failure.closed ? m.auth_passkey_prompt_closed() : failure.message}
{:else}
{m.auth_sign_in_subtitle()}
{#if $appConfigStore.allowUserSignups === 'open'}
{m.auth_new_here()}
<a href="/signup" class="inline-link">{m.auth_create_an_account()}</a>
{/if}
{/if}
<Button
class={$appConfigStore.allowUserSignups === 'open' ? 'w-[50%]' : 'w-[80%] sm:w-[40%]'}
{isLoading}
onclick={authenticate}
autofocus={true}
>
{error ? m.try_again() : m.authenticate()}
</AuthHeading>
<div
class="flex flex-col items-center gap-3 transition-opacity duration-(--dur-fast)"
class:opacity-0={success}
inert={success}
>
<Button bind:ref={primary} class={authPrimaryClass} {isLoading} onclick={authenticate}>
{failure ? m.try_again() : m.auth_sign_in_with_passkey()}
</Button>
<Button variant="link" class={authQuietClass} href={alternativeSignInHref()}>
{m.auth_other_ways_to_sign_in()}
</Button>
</div>
</SignInWrapper>
@@ -1,78 +1,78 @@
<script lang="ts">
import { page } from '$app/state';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import AuthTile from '#lib/components/auth/auth-tile.svelte';
import { authQuietClass, emailedLoginCode } from '#lib/components/auth/auth-util.ts';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import Logo from '#lib/components/logo.svelte';
import * as Item from '#lib/components/ui/item/index.js';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import {
LucideChevronRight,
LucideMail,
LucideQrCode,
LucideRectangleEllipsis
} from '@lucide/svelte';
import { LucideChevronRight, LucideKeyboard, LucideMail, LucideSmartphone } from '@lucide/svelte';
const emailCodes = $appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled;
// Choosing a method again starts over, so Enter a login code no longer refers to an earlier email
emailedLoginCode.sentTo = '';
const methods = [
{
icon: LucideQrCode,
title: m.sign_in_with_another_device(),
description: m.sign_in_with_another_device_description(),
icon: LucideSmartphone,
title: m.auth_use_another_device(),
description: m.auth_use_another_device_description(),
href: '/login/alternative/device'
},
{
icon: LucideRectangleEllipsis,
title: m.login_code(),
description: m.enter_a_login_code_to_sign_in(),
icon: LucideKeyboard,
title: m.auth_enter_a_login_code(),
description: emailCodes
? m.auth_enter_a_login_code_description()
: m.auth_enter_an_admin_login_code_description(),
href: '/login/alternative/code'
}
},
...(emailCodes
? [
{
icon: LucideMail,
title: m.auth_email_me_a_login_code(),
description: m.auth_email_me_a_login_code_description(),
href: '/login/alternative/email'
}
]
: [])
];
if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) {
methods.push({
icon: LucideMail,
title: m.email_login(),
description: m.request_a_login_code_via_email(),
href: '/login/alternative/email'
});
}
</script>
<svelte:head>
<title>{m.sign_in()}</title>
<title>{m.auth_other_ways_to_sign_in()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex h-full flex-col justify-center">
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="size-10" />
</div>
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
<p class="text-muted-foreground mt-3">
{m.if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods()}
</p>
<Item.Group class="mt-5 gap-3">
{#each methods as method (method.href)}
<Item.Root variant="outline" class="gap-5">
{#snippet child({ props })}
<a href={method.href + page.url.search} {...props}>
<Item.Media class="text-primary !translate-y-0 !self-center">
<method.icon class="size-7" />
</Item.Media>
<Item.Content class="text-start">
<Item.Title class="text-lg font-semibold">{method.title}</Item.Title>
<Item.Description>{method.description}</Item.Description>
</Item.Content>
<Item.Actions>
<LucideChevronRight class="size-5" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
{/each}
</Item.Group>
<a class="text-muted-foreground mt-5 text-xs" href={'/login' + page.url.search}
>{m.use_your_passkey_instead()}</a
>
</div>
<SignInWrapper back={'/login' + page.url.search}>
<AuthTile />
<AuthHeading title={m.auth_other_ways_to_sign_in()}>
{m.auth_other_ways_to_sign_in_description()}
</AuthHeading>
<ul class="-mx-3 mb-6 flex flex-col gap-0.5">
{#each methods as method (method.href)}
<li>
<a
href={method.href + page.url.search}
class="group/method before:bg-fill-row-hover transition-motion focus-visible:outline-ring relative isolate flex min-h-16 items-center gap-3 rounded-md p-3 before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:opacity-0 before:transition-opacity before:duration-(--dur-fast) hover:before:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-(--press-scale)"
>
<span class="bg-fill-muted grid size-8 shrink-0 place-items-center rounded-[8px]">
<method.icon class="size-4" />
</span>
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-heading font-medium">{method.title}</span>
<span class="text-control text-fg-muted">{method.description}</span>
</span>
<LucideChevronRight
class="text-fg-muted size-4 shrink-0 transition-transform duration-(--dur-fast) group-hover/method:translate-x-(--nudge)"
/>
</a>
</li>
{/each}
</ul>
<Button variant="link" class={authQuietClass} href={'/login' + page.url.search}>
{m.auth_use_your_passkey_instead()}
</Button>
</SignInWrapper>
@@ -1,129 +1,202 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { goto } from '$app/navigation';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import AuthTile from '#lib/components/auth/auth-tile.svelte';
import {
authPrimaryClass,
authQuietClass,
emailedLoginCode,
successHold,
takingYouMessage
} from '#lib/components/auth/auth-util.ts';
import CodeGroup from '#lib/components/auth/code-group.svelte';
import CodeLine from '#lib/components/auth/code-line.svelte';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as InputOTP from '#lib/components/ui/input-otp/index.js';
import Input from '#lib/components/ui/input/input.svelte';
import { Input } from '#lib/components/ui/input/index.ts';
import { Label } from '#lib/components/ui/label/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 userStore from '#lib/stores/user-store.js';
import { getAxiosErrorMessage } from '#lib/utils/error-util.ts';
import { getAxiosErrorCode, getAxiosErrorMessage } from '#lib/utils/error-util.ts';
import { preventDefault } from '#lib/utils/event-util.ts';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { onMount, tick, untrack } from 'svelte';
let { data } = $props();
let code = $state(data.code ?? '');
let isLoading = $state(false);
let error: string | undefined = $state();
let backHref = $state('/login/alternative');
const shortCodeLength = 6;
const longCodeLength = 12;
let longCodeRequested = $state(
code.length > shortCodeLength || !$appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled
);
let codeComplete = $derived(
longCodeRequested ? code.length === longCodeLength : code.length === shortCodeLength
);
const SHORT = 6;
const LONG = 12;
const emailCodes = $appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled;
const userService = new UserService();
// If the previous page is a Pocket ID page, go back there instead of the generic alternative login page
afterNavigate((e) => {
if (e.from?.url?.pathname && !e.shallow) {
backHref = e.from.url.pathname + e.from.url.search;
}
const sentTo = data.code ? '' : emailedLoginCode.sentTo;
const redirectQuery =
data.redirect === '/settings' ? '' : `?redirect=${encodeURIComponent(data.redirect)}`;
let code = $state(data.code ?? '');
let long = $state(!sentTo && (code ? code.length > SHORT : !emailCodes));
let resendIn = $state(30);
let resending = $state(false);
let checking = $state(false);
let success = $state(false);
let error: { message: string; invalid: boolean } | undefined = $state();
const token = $derived(code.replace(/[\s-]/g, ''));
const complete = $derived(token.length >= (long ? LONG : SHORT));
// Typing again clears the last outcome
$effect(() => {
void code;
untrack(() => (error = undefined));
});
async function authenticate() {
if (!code?.trim()) return;
if (!codeComplete) return;
isLoading = true;
async function signIn() {
if (checking) return;
checking = true;
error = undefined;
try {
const user = await userService.exchangeOneTimeAccessToken(code);
const user = await userService.exchangeOneTimeAccessToken(token);
await userStore.setUser(user);
try {
await goto(data.redirect);
} catch {
error = m.invalid_redirect_url();
}
success = true;
await successHold();
await goto(data.redirect).catch(() => {
success = false;
error = { message: m.invalid_redirect_url(), invalid: false };
});
} catch (e) {
error = getAxiosErrorMessage(e);
const invalid = getAxiosErrorCode(e) === 'token_invalid_or_expired';
error = { message: invalid ? m.auth_code_did_not_work() : getAxiosErrorMessage(e), invalid };
}
checking = false;
}
isLoading = false;
// The last outcome belonged to the other format, and the new field takes over the typing
async function switchFormat() {
long = !long;
error = undefined;
if (token.length > (long ? LONG : SHORT)) code = '';
await tick();
document.getElementById('login-code')?.focus();
}
// Resend code becomes available once the countdown under the primary runs out
$effect(() => {
if (!sentTo) return;
const timer = setInterval(() => resendIn > 0 && resendIn--, 1000);
return () => clearInterval(timer);
});
async function resend() {
resending = true;
try {
await userService.requestOneTimeAccessEmailAsUnauthenticatedUser(sentTo, data.redirect);
resendIn = 30;
} catch (e) {
error = { message: getAxiosErrorMessage(e), invalid: false };
}
resending = false;
await tick();
document.getElementById('login-code')?.focus();
}
onMount(() => {
if (code) {
authenticate();
}
if (code) signIn();
});
</script>
<svelte:head>
<title>{m.login_code()}</title>
<title>{m.auth_enter_your_login_code()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-display mt-5 text-4xl font-bold">{m.login_code()}</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. {m.please_try_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p>
{/if}
<form onsubmit={preventDefault(authenticate)} class="mt-8 flex w-full flex-col items-center">
<div class="flex w-full flex-col items-center justify-center">
{#if longCodeRequested}
<Input
id="Code"
class="w-[80%]"
placeholder={m.code()}
aria-label={m.code()}
bind:value={code}
autofocus
type="text"
<SignInWrapper back={(sentTo ? '/login/alternative/email' : '/login/alternative') + redirectQuery}>
<AuthTile {success} />
<AuthHeading
title={success
? m.auth_signed_in()
: sentTo
? m.auth_check_your_email()
: m.auth_enter_your_login_code()}
>
{#if success}
{takingYouMessage(data.redirect)}
{:else if sentTo}
<span class="[&_b]:text-fg [&_b]:font-medium">
<FormattedMessage
message={m.auth_email_code_sent}
inputs={{ email: sentTo, appName: $appConfigStore.appName }}
/>
{:else}
<InputOTP.Root maxlength={shortCodeLength} bind:value={code} autofocus>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
</span>
{:else if long}
{m.auth_login_code_long_description()}
{:else}
{m.auth_login_code_short_description()}
{/if}
</AuthHeading>
<form
onsubmit={preventDefault(signIn)}
class="flex flex-col transition-opacity duration-(--dur-fast)"
class:opacity-0={success}
inert={success}
>
<Label for="login-code" class="mb-2">{m.auth_login_code()}</Label>
{#if long}
<Input
id="login-code"
bind:value={code}
class="h-(--h-xl) font-mono tracking-[0.12em]"
autocomplete="one-time-code"
autocapitalize="off"
spellcheck={false}
readonly={checking}
aria-invalid={error?.invalid || undefined}
aria-describedby="login-code-line"
autofocus
/>
{:else}
<CodeGroup
id="login-code"
groups={[3, 3]}
bind:value={code}
invalid={error?.invalid}
readonly={checking}
onComplete={signIn}
autocomplete="one-time-code"
autofocus
aria-describedby="login-code-line"
/>
{/if}
<CodeLine id="login-code-line" tone={checking ? 'checking' : error ? 'error' : undefined}>
{#if checking}
{m.auth_checking_code()}
{:else if error}
{error.message}
{:else if long}
{m.auth_spaces_and_dashes_are_ignored()}
{/if}
{#if !longCodeRequested}
<div class="flex justify-center">
<Button
class="text-muted-foreground mt-2 text-xs"
size="sm"
variant="ghost"
type="button"
onclick={() => (longCodeRequested = true)}
>
{m.i_have_a_longer_code()}
</CodeLine>
<div class="mt-6 flex flex-col items-center gap-3">
<Button type="submit" class={authPrimaryClass} disabled={!complete}>
{m.sign_in()}
</Button>
{#if sentTo}
{#if resendIn > 0}
<p class="text-control text-fg-muted flex h-(--h-xl) items-center tabular-nums">
{m.auth_resend_code_in({
time: `${Math.floor(resendIn / 60)}:${String(resendIn % 60).padStart(2, '0')}`
})}
</p>
{:else}
<Button variant="link" class={authQuietClass} isLoading={resending} onclick={resend}>
{m.auth_resend_code()}
</Button>
</div>
{/if}
{:else if emailCodes}
<Button variant="link" class={authQuietClass} disabled={checking} onclick={switchFormat}>
{long ? m.auth_i_have_a_6_character_code() : m.auth_i_have_a_12_character_code()}
</Button>
{/if}
</div>
<div class="flex w-full max-w-[450px] gap-4 pt-7">
<Button class="flex-1" variant="secondary" href={backHref}>
{m.go_back()}
</Button>
<Button class="flex-1" type="submit" disabled={!codeComplete} {isLoading}>
{m.submit()}
</Button>
</div>
</form>
</SignInWrapper>
@@ -1,6 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import AuthTile from '#lib/components/auth/auth-tile.svelte';
import {
authPrimaryClass,
authQuietClass,
successHold,
takingYouMessage
} from '#lib/components/auth/auth-util.ts';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import QrPanel from '#lib/components/qrcode/qr-panel.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
@@ -9,18 +17,17 @@
import DeviceLoginService from '#lib/services/device-login-service.ts';
import userStore from '#lib/stores/user-store.ts';
import type { DeviceLoginRequest } from '#lib/types/device-login.type.ts';
import { getAxiosErrorMessage } from '#lib/utils/error-util.ts';
import { getAxiosErrorCode, getAxiosErrorMessage } from '#lib/utils/error-util.ts';
import { isAxiosError } from 'axios';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
let { data } = $props();
const deviceLoginService = new DeviceLoginService();
let request: DeviceLoginRequest | undefined = $state();
let errorMessage: string | null = $state(null);
let isStarting = $state(true);
let ended: { kind: 'expired' | 'denied' | 'failed'; message?: string } | undefined = $state();
let success = $state(false);
let pollTimer: ReturnType<typeof setTimeout> | undefined;
let requestController: AbortController | undefined;
@@ -37,9 +44,6 @@
// Track this attempt so stale responses cannot update the current page
const controller = new AbortController();
requestController = controller;
request = undefined;
errorMessage = null;
isStarting = true;
try {
const next = await deviceLoginService.createRequest(controller.signal);
@@ -51,12 +55,7 @@
schedulePoll(controller);
} catch (error) {
if (controller.signal.aborted) return;
errorMessage = getAxiosErrorMessage(error);
} finally {
// Stop the initial loading state only if this attempt still owns the page
if (requestController === controller) {
isStarting = false;
}
ended = { kind: 'failed', message: getAxiosErrorMessage(error) };
}
}
@@ -84,6 +83,8 @@
await userStore.setUser(user);
// Navigate only after confirming that this attempt is still active
if (requestController !== controller) return;
success = true;
await successHold();
await goto(data.redirect);
} catch (error) {
if (controller.signal.aborted) return;
@@ -95,11 +96,10 @@
// Stop retrying when the server reports a non-transient failure
clearTimers();
if (isAxiosError(error) && error.response?.status === 401) {
errorMessage = m.device_login_request_expired();
return;
}
errorMessage = getAxiosErrorMessage(error);
const code = getAxiosErrorCode(error);
if (code === 'device_login_denied') ended = { kind: 'denied' };
else if (code === 'device_login_expired') ended = { kind: 'expired' };
else ended = { kind: 'failed', message: getAxiosErrorMessage(error) };
}
}
@@ -132,33 +132,55 @@
<title>{m.sign_in_with_another_device()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!errorMessage} />
</div>
<h1 class="font-display mt-5 text-2xl font-bold sm:text-4xl">
{m.sign_in_with_another_device()}
</h1>
<p class="text-muted-foreground mt-2">
{errorMessage ? errorMessage : m.sign_in_with_another_device_description()}
</p>
{#if isStarting}
<div class="mt-10 flex items-center gap-2 text-sm">
<Spinner />
{m.creating_device_login_request()}
</div>
{:else if request && !errorMessage}
<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"
>{m.go_back()}</Button
>
<Button class="flex-1" isLoading={!errorMessage} onclick={startRequest}>
{errorMessage ? m.try_again() : m.waiting_for_approval()}
</Button>
<SignInWrapper back={'/login/alternative' + page.url.search}>
<AuthTile {success} />
<AuthHeading
title={success ? m.auth_signed_in() : m.sign_in_with_another_device()}
tone={ended ? (ended.kind === 'failed' ? 'danger' : 'info') : undefined}
>
{#if success}
{takingYouMessage(data.redirect)}
{:else if ended?.kind === 'expired'}
{m.auth_another_device_expired()}
{:else if ended?.kind === 'denied'}
{m.auth_another_device_denied()}
{:else if ended}
{ended.message}
{:else}
{m.auth_use_another_device_description()}
{/if}
</AuthHeading>
<div
class="flex flex-col items-center transition-opacity duration-(--dur-fast)"
class:opacity-0={success}
inert={success}
>
{#if request}
<div class="mb-6 w-full">
<QrPanel {request} ended={!!ended} />
</div>
{/if}
{#if ended}
<Button class={authPrimaryClass} usePromiseLoading onclick={startRequest}>
{ended.kind === 'denied' ? m.try_again() : m.auth_get_a_new_code()}
</Button>
{:else}
<!-- While the request is alive there is nothing to press, so the primary's place holds the wait as a status line -->
<p
class="text-body flex min-h-(--h-xl) items-center justify-center gap-2 text-center lg:min-h-13"
aria-live="polite"
>
<Spinner class="size-3 shrink-0" />
{request ? m.auth_waiting_for_approval() : m.auth_creating_sign_in_request()}
</p>
<Button
variant="link"
class={authQuietClass + ' mt-3'}
disabled={!request}
onclick={startRequest}
>
{m.auth_get_a_new_code()}
</Button>
{/if}
</div>
</SignInWrapper>
@@ -1,83 +1,76 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import AuthTile from '#lib/components/auth/auth-tile.svelte';
import { authPrimaryClass, emailedLoginCode } from '#lib/components/auth/auth-util.ts';
import CodeLine from '#lib/components/auth/code-line.svelte';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import Input from '#lib/components/ui/input/input.svelte';
import { Input } from '#lib/components/ui/input/index.ts';
import { Label } from '#lib/components/ui/label/index.ts';
import { m } from '#lib/paraglide/messages.js';
import UserService from '#lib/services/user-service.ts';
import { preventDefault } from '#lib/utils/event-util.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { getAxiosErrorMessage } from '#lib/utils/error-util.ts';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { preventDefault } from '#lib/utils/event-util.ts';
import { createForm } from '#lib/utils/form-util.ts';
import { z } from 'zod/v4';
const { data } = $props();
const userService = new UserService();
const { inputs, ...form } = createForm(
z.object({ email: z.email(m.auth_enter_a_full_email_address()) }),
{ email: emailedLoginCode.sentTo }
);
let email = $state('');
let isLoading = $state(false);
let error: string | undefined = $state(undefined);
let success = $state(false);
let error: string | undefined = $state();
async function requestEmail() {
const values = form.validate();
if (!values) return;
isLoading = true;
await userService
.requestOneTimeAccessEmailAsUnauthenticatedUser(email, data.redirect)
.then(() => (success = true))
.catch((e) => (error = getAxiosErrorMessage(e)));
error = undefined;
try {
await userService.requestOneTimeAccessEmailAsUnauthenticatedUser(values.email, data.redirect);
emailedLoginCode.sentTo = values.email;
await goto('/login/alternative/code' + page.url.search);
} catch (e) {
error = getAxiosErrorMessage(e);
}
isLoading = false;
}
</script>
<svelte:head>
<title>{m.email_login()}</title>
<title>{m.auth_get_a_login_code_by_email()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div>
<h1 class="font-display mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. {m.please_try_again()}
</p>
<div class="mt-10 flex justify-between gap-2">
<Button variant="secondary" class="flex-1" href="/">{m.go_back()}</Button>
<Button class="flex-1" onclick={() => (error = undefined)}>{m.try_again()}</Button>
</div>
{:else if success}
<p class="text-muted-foreground mt-2" in:fade>
{m.an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system()}
</p>
<div class="mt-8 flex justify-between gap-2">
<Button variant="secondary" class="flex-1" href={'/login/alternative' + page.url.search}
>{m.go_back()}</Button
>
<Button class="flex-1" href={'/login/alternative/code' + page.url.search}
>{m.enter_code()}</Button
>
</div>
{:else}
<form onsubmit={preventDefault(requestEmail)} class="w-full max-w-[450px]">
<p class="text-muted-foreground mt-2" in:fade>
{m.enter_your_email_address_to_receive_an_email_with_a_login_code()}
</p>
<Input
id="Email"
class="mt-7"
placeholder={m.your_email()}
aria-label={m.email()}
bind:value={email}
type="email"
/>
<div class="mt-8 flex justify-between gap-2">
<Button variant="secondary" class="flex-1" href={'/login/alternative' + page.url.search}
>{m.go_back()}</Button
>
<Button class="flex-1" type="submit" {isLoading}>{m.submit()}</Button>
</div>
</form>
{/if}
<SignInWrapper back={'/login/alternative' + page.url.search}>
<AuthTile />
<AuthHeading title={m.auth_get_a_login_code_by_email()} tone={error ? 'danger' : undefined}>
{error ?? m.auth_email_login_description({ appName: $appConfigStore.appName })}
</AuthHeading>
<form onsubmit={preventDefault(requestEmail)} class="flex flex-col" novalidate>
<Label for="email" class="mb-2">{m.email_address()}</Label>
<Input
id="email"
type="email"
autocomplete="email"
class="h-(--h-xl)"
bind:value={$inputs.email.value}
oninput={() => ($inputs.email.error = null)}
aria-invalid={!!$inputs.email.error}
aria-describedby="email-line"
autofocus
/>
<CodeLine id="email-line" tone={$inputs.email.error ? 'error' : undefined}>
{$inputs.email.error ?? m.auth_email_login_helper()}
</CodeLine>
<Button type="submit" class={authPrimaryClass + ' mt-6'} {isLoading}>
{error ? m.try_again() : m.auth_email_me_a_code()}
</Button>
</form>
</SignInWrapper>
@@ -1,33 +0,0 @@
<script lang="ts">
import Logo from '#lib/components/logo.svelte';
import CheckmarkAnimated from '#lib/icons/checkmark-animated.svelte';
import CrossAnimated from '#lib/icons/cross-animated.svelte';
import { fade } from 'svelte/transition';
const {
error,
success
}: {
error?: boolean;
success?: boolean;
} = $props();
</script>
<div
class="rounded-2xl p-3 transition-[background-color] duration-300
{error ? 'bg-red-200' : success ? 'bg-green-200' : 'bg-muted'}"
>
{#if error || success}
<div class="flex size-10 items-center justify-center">
{#if error}
<CrossAnimated class="size-5" />
{:else}
<CheckmarkAnimated class="size-5" />
{/if}
</div>
{:else}
<div in:fade={{ duration: 300 }}>
<Logo class="size-10" />
</div>
{/if}
</div>
+44 -30
View File
@@ -1,52 +1,66 @@
<script lang="ts">
import { goto } from '$app/navigation';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import AuthHeading from '#lib/components/auth/auth-heading.svelte';
import AuthTile from '#lib/components/auth/auth-tile.svelte';
import { authPrimaryClass, authQuietClass } from '#lib/components/auth/auth-util.ts';
import IdentityRow from '#lib/components/identity-row.svelte';
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import Logo from '#lib/components/logo.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import WebAuthnService from '#lib/services/webauthn-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import userStore from '#lib/stores/user-store.js';
import { axiosErrorToast } from '#lib/utils/error-util.js';
let isLoading = $state(false);
const webauthnService = new WebAuthnService();
const user = $userStore!;
const name = user.displayName || user.username;
let isLoading = $state(false);
let failed = $state(false);
let signedOut = $state(false);
async function signOut() {
isLoading = true;
await webauthnService
.logout()
.then(() => goto('/'))
.catch(axiosErrorToast);
failed = false;
try {
await webauthnService.logout();
signedOut = true;
} catch {
failed = true;
}
isLoading = false;
}
</script>
<svelte:head>
<title>{m.logout()}</title>
<title>{m.sign_out()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="size-10" />
<AuthTile />
{#if signedOut}
<AuthHeading title={m.auth_you_are_signed_out()}>
{m.auth_signed_out_description()}
</AuthHeading>
<Button class={authPrimaryClass} href="/login">{m.auth_sign_in_again()}</Button>
{:else}
<AuthHeading
title={m.auth_sign_out_question({ appName: $appConfigStore.appName, name })}
tone={failed ? 'danger' : undefined}
>
{failed
? m.auth_sign_out_failed()
: m.auth_sign_out_requested({ appName: $appConfigStore.appName })}
</AuthHeading>
<div class="mb-6">
<IdentityRow {user} />
</div>
</div>
<h1 class="font-display mt-5 text-4xl">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2">
<FormattedMessage
message={m.do_you_want_to_sign_out_of_pocketid_with_the_account}
inputs={{
username: $userStore?.username ?? '',
appName: $appConfigStore.appName
}}
/>
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button class="flex-1" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>
<Button class="flex-1" {isLoading} onclick={signOut}>{m.sign_out()}</Button>
</div>
<div class="flex flex-col items-center gap-3">
<Button class={authPrimaryClass} {isLoading} onclick={signOut}>
{failed ? m.try_again() : m.sign_out()}
</Button>
<Button variant="link" class={authQuietClass} onclick={() => history.back()}>
{m.auth_stay_signed_in()}
</Button>
</div>
{/if}
</SignInWrapper>
@@ -0,0 +1,12 @@
<script lang="ts">
import { page } from '$app/state';
import Error from '#lib/components/error.svelte';
import { m } from '#lib/paraglide/messages.js';
import type { ErrorProps } from './$types';
let { error }: ErrorProps = $props();
</script>
<!-- Errors under /settings render inside the shell, so the navigation stays -->
<!-- Without a matched route the message is SvelteKit's untranslated default -->
<Error message={page.route.id ? error.message : m.page_not_found()} />
+4 -3
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { onNavigate } from '$app/navigation';
import EmailVerificationStateBox from '#lib/components/email-verification-state-box.svelte';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import AppSidebar from '#lib/components/shell/app-sidebar.svelte';
import { navGroups } from '#lib/components/shell/nav-items.ts';
@@ -102,8 +101,10 @@
</Alert.Description>
</Alert.Root>
{/if}
<EmailVerificationStateBox />
{@render children()}
<!-- SvelteKit reuses the page when only the id changes, so a remount makes its state start from the new entity -->
{#key page.params.id}
{@render children()}
{/key}
</main>
</div>
</div>
@@ -0,0 +1,5 @@
import { m } from '#lib/paraglide/messages.js';
import { error } from '@sveltejs/kit';
// Unknown paths under /settings would otherwise fall through to the root error page and lose the shell
export const load = () => error(404, m.page_not_found());
+33 -175
View File
@@ -1,194 +1,52 @@
<script lang="ts">
import EmailVerificationStateBox from '#lib/components/email-verification-state-box.svelte';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import PageHeader from '#lib/components/page-header.svelte';
import SettingRow from '#lib/components/setting-row.svelte';
import SettingsSection from '#lib/components/settings-section.svelte';
import * as Alert from '#lib/components/ui/alert/index.ts';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Card from '#lib/components/ui/card/index.ts';
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 WebAuthnService from '#lib/services/webauthn-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import userStore from '#lib/stores/user-store.ts';
import type { Passkey } from '#lib/types/passkey.type.ts';
import type { AccountUpdate } from '#lib/types/user.type.ts';
import { axiosErrorToast, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import {
KeyRound,
Languages,
LucideAlertTriangle,
LucideMailCheck,
UserCog
} from '@lucide/svelte';
import { startRegistration } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner';
import { LucideLanguages, LucideTriangleAlert } from '@lucide/svelte';
import AccountForm from './account-form.svelte';
import LocalePicker from './locale-picker.svelte';
import PasskeyList from './passkey-list.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { data } = $props();
let account = $state(data.account);
let passkeys = $state(data.passkeys);
let pendingEmailChange = $state(data.pendingEmailChange);
let passkeyToRename: Passkey | null = $state(null);
const userService = new UserService();
const webauthnService = new WebAuthnService();
const userInfoInputDisabled = $derived(
!$appConfigStore.allowOwnAccountEdit || (!!account.ldapId && $appConfigStore.ldapEnabled)
);
async function updateAccount(user: AccountUpdate) {
const updated = await userService.updateCurrent(user);
userStore.setUser(updated);
// A verified email is only replaced once the new address is confirmed, so the change shows up as pending instead
if (user.email && user.email !== updated.email) {
pendingEmailChange = await userService.getPendingEmailChange().catch(() => null);
}
return updated;
}
async function cancelEmailChange() {
await userService
.cancelEmailChange()
.then(() => {
pendingEmailChange = null;
toast.success(m.email_change_canceled());
})
.catch(axiosErrorToast);
}
async function createPasskey() {
try {
const opts = await webauthnService.getRegistrationOptions();
const attResp = await startRegistration({ optionsJSON: opts });
const passkey = await webauthnService.finishRegistration(attResp);
passkeys = await webauthnService.listCredentials();
passkeyToRename = passkey;
} catch (e) {
toast.error(getWebauthnErrorMessage(e));
}
}
</script>
<svelte:head>
<title>{m.account_settings()}</title>
<title>{m.my_account()}</title>
</svelte:head>
{#if passkeys.length == 0}
<Alert.Root variant="warning" class="flex gap-3">
<LucideAlertTriangle class="size-4 shrink-0" />
<div class="md:flex md:w-full md:place-content-between">
<div>
<Alert.Title class="font-semibold">{m.passkey_missing()}</Alert.Title>
<Alert.Description class="text-sm">
{m.please_provide_a_passkey_to_prevent_losing_access_to_your_account()}
</Alert.Description>
</div>
<div>
<Button class="mt-2 md:mt-0" onclick={createPasskey}>
{m.add_passkey()}
</Button>
</div>
</div>
</Alert.Root>
{:else if passkeys.length == 1}
<Alert.Root variant="warning" dismissibleId="single-passkey" class="flex gap-3">
<LucideAlertTriangle class="size-4 shrink-0" />
<div>
<Alert.Title class="font-semibold">{m.single_passkey_configured()}</Alert.Title>
<Alert.Description class="text-sm">
{m.it_is_recommended_to_add_more_than_one_passkey()}
<div class="flex flex-col gap-6">
<PageHeader title={m.my_account()} description={m.account_page_description()} />
<EmailVerificationStateBox />
{#if passkeys.length === 1}
<Alert.Root variant="warning">
<LucideTriangleAlert />
<Alert.Title>{m.add_backup_passkey()}</Alert.Title>
<Alert.Description class="wrap-anywhere">
{m.add_backup_passkey_description({ name: passkeys[0].name })}
</Alert.Description>
</div>
</Alert.Root>
{/if}
{#if pendingEmailChange}
<Alert.Root variant="info" class="flex gap-3">
<LucideMailCheck class="size-4 shrink-0" />
<div class="md:flex md:w-full md:place-content-between">
<div>
<Alert.Title class="font-semibold">{m.email_change_pending_title()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_change_pending_description({ email: pendingEmailChange.email })}
</Alert.Description>
</div>
<div>
<Button
class="mt-2 md:mt-0"
variant="outline"
usePromiseLoading
onclick={cancelEmailChange}
>
{m.cancel_email_change()}
</Button>
</div>
</div>
</Alert.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>
<UserCog class="text-primary/80 size-5" />
{m.account_details()}
</Card.Title>
</Card.Header>
<Card.Content>
<AccountForm
{account}
userId={account.id}
callback={updateAccount}
isLdapUser={!!account.ldapId}
{userInfoInputDisabled}
/>
</Card.Content>
</Card.Root>
<Item.Group class="bg-card rounded-4xl border p-5 shadow-sm">
<Item.Root class="border-none bg-transparent p-0">
<Item.Media class="text-primary/80">
<KeyRound class="size-5" />
</Item.Media>
<Item.Content class="min-w-52">
<Item.Title class="text-xl font-semibold">{m.passkeys()}</Item.Title>
<Item.Description>
{m.manage_your_passkeys_that_you_can_use_to_authenticate_yourself()}
</Item.Description>
</Item.Content>
<Item.Actions>
<Button variant="outline" onclick={createPasskey}>
{m.add_passkey()}
</Button>
</Item.Actions>
</Item.Root>
{#if passkeys.length != 0}
<PasskeyList bind:passkeys />
</Alert.Root>
{/if}
</Item.Group>
</div>
<Item.Root variant="card" class="border-border mb-2">
<Item.Media class="text-primary/80">
<Languages class="size-5" />
</Item.Media>
<Item.Content class="min-w-52">
<Item.Title>{m.language()}</Item.Title>
<Item.Description>
{m.select_the_language_you_want_to_use()}
<br />
<FormattedMessage message={m.contribute_to_translation} />
</Item.Description>
</Item.Content>
<Item.Actions>
<LocalePicker />
</Item.Actions>
</Item.Root>
<div class="mt-6 flex flex-col gap-10">
<AccountForm account={data.account} pendingEmailChange={data.pendingEmailChange} />
<RenamePasskeyModal
bind:passkey={passkeyToRename}
callback={async () => (passkeys = await webauthnService.listCredentials())}
/>
<PasskeyList bind:passkeys />
<SettingsSection title={m.preferences()}>
<SettingRow title={m.language()} icon={LucideLanguages}>
{#snippet description()}
<FormattedMessage message={m.language_crowdin_description} />
{/snippet}
{#snippet control(props)}
<LocalePicker {...props} />
{/snippet}
</SettingRow>
</SettingsSection>
</div>
@@ -1,36 +1,60 @@
<script lang="ts">
import FormInput from '#lib/components/form/form-input.svelte';
import ProfilePictureSettings from '#lib/components/form/profile-picture-settings.svelte';
import * as Field from '#lib/components/ui/field/index.js';
import SettingsSection from '#lib/components/settings-section.svelte';
import { Badge } from '#lib/components/ui/badge/index.ts';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Field from '#lib/components/ui/field/index.ts';
import { Input } from '#lib/components/ui/input/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 type { AccountUpdate, User } from '#lib/types/user.type.ts';
import userStore from '#lib/stores/user-store.ts';
import type { AccountUpdate, EmailChange, User } from '#lib/types/user.type.ts';
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { createForm } from '#lib/utils/form-util.ts';
import { trackFormChanges } from '#lib/utils/unsaved-changes-util.svelte.ts';
import { emptyToUndefined, usernameSchema } from '#lib/utils/zod-util.ts';
import { LucideInfo, LucideSend, LucideUndo2 } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { get } from 'svelte/store';
import { z } from 'zod/v4';
let {
callback,
account,
userId,
isLdapUser = false,
userInfoInputDisabled = false
pendingEmailChange: initialPendingEmailChange
}: {
account: AccountUpdate;
userId: string;
callback: (user: AccountUpdate) => Promise<User>;
isLdapUser?: boolean;
userInfoInputDisabled?: boolean;
account: User;
pendingEmailChange: EmailChange | null;
} = $props();
let hasManualDisplayNameEdit = $state(!!account.displayName);
const userService = new UserService();
const emailId = $props.id();
let pendingEmailChange = $state(initialPendingEmailChange);
let hasManualDisplayNameEdit = $state(
!!account.displayName &&
account.displayName !== [account.firstName, account.lastName].filter(Boolean).join(' ')
);
const readOnlyReason = $derived(
account.ldapId && $appConfigStore.ldapEnabled
? m.profile_managed_by_ldap()
: !$appConfigStore.allowOwnAccountEdit
? m.profile_editing_disabled()
: undefined
);
const emailStatus = $derived.by(() => {
if ($appConfigStore.emailVerification === 'disabled' || !$userStore?.email) return undefined;
if ($userStore.emailVerified)
return { variant: 'success' as const, label: m.email_status_verified() };
if (isEmailVerificationPending($userStore, $appConfigStore)) {
return { variant: 'destructive' as const, label: m.email_status_verification_required() };
}
return { variant: 'warning' as const, label: m.email_status_unverified() };
});
const formSchema = z.object({
firstName: z.string().max(50),
@@ -57,18 +81,37 @@
trackFormChanges(
() => formStore,
async (data) => {
const saved = await callback(data);
async (data: AccountUpdate) => {
const saved = await userService.updateCurrent(data);
userStore.setUser(saved);
// A verified email is only replaced once the new address is confirmed, so keep showing the email that is still in effect
// The saved data becomes the new baseline, so it has to carry that email as well
if (saved.email && data.email && saved.email !== data.email) {
pendingEmailChange = await userService.getPendingEmailChange().catch(() => null);
data.email = saved.email;
formStore.setValue('email', saved.email);
}
}
);
async function cancelEmailChange() {
await userService
.cancelEmailChange()
.then(() => {
pendingEmailChange = null;
toast.success(m.email_change_canceled());
})
.catch(axiosErrorToast);
}
async function sendEmailVerification() {
await userService
.sendEmailVerification()
.then(() => toast.success(m.email_verification_sent()))
.catch(axiosErrorToast);
}
async function updateProfilePicture(image: File) {
await userService
.updateCurrentUsersProfilePicture(image)
@@ -84,27 +127,118 @@
}
</script>
<form class="space-y-6">
<ProfilePictureSettings
{userId}
{isLdapUser}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
<SettingsSection
title={m.profile()}
description={m.profile_section_description({ appName: $appConfigStore.appName })}
>
<div class="flex flex-col gap-6 pt-4">
<ProfilePictureSettings
userId={account.id}
isLdapUser={!!account.ldapId}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
<Field.Separator class="m-2" />
<fieldset disabled={userInfoInputDisabled}>
<Field.Group class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label={m.email()} type="email" bind:input={$inputs.email} />
<FormInput label={m.first_name()} bind:input={$inputs.firstName} onInput={onNameInput} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} onInput={onNameInput} />
<FormInput
label={m.display_name()}
bind:input={$inputs.displayName}
onInput={() => (hasManualDisplayNameEdit = true)}
/>
</Field.Group>
</fieldset>
</form>
<div class="flex flex-col gap-3">
{#if readOnlyReason}
<p class="text-caption text-fg-muted flex items-start gap-1.5">
<LucideInfo class="mt-1 size-3 shrink-0" />
{readOnlyReason}
</p>
{/if}
<form class="grid gap-4 sm:grid-cols-2">
<FormInput
label={m.first_name()}
optional
bind:input={$inputs.firstName}
onInput={onNameInput}
readonly={!!readOnlyReason}
/>
<FormInput
label={m.last_name()}
optional
bind:input={$inputs.lastName}
onInput={onNameInput}
readonly={!!readOnlyReason}
/>
<FormInput
label={m.display_name()}
optional
description={readOnlyReason ? undefined : m.display_name_helper()}
bind:input={$inputs.displayName}
onInput={() => (hasManualDisplayNameEdit = true)}
readonly={!!readOnlyReason}
/>
<FormInput label={m.username()} bind:input={$inputs.username} readonly={!!readOnlyReason} />
<FormInput
label={m.email()}
labelFor={emailId}
optional={!$appConfigStore.requireUserEmail}
readonly={!!readOnlyReason}
class="sm:col-span-2"
>
<!-- The status is the field's helper line, and on phones it sits between the field and its action -->
<div class="grid gap-x-4 gap-y-1.5 sm:grid-cols-2 sm:items-center">
<Input
id={emailId}
type="email"
autocomplete="email"
aria-invalid={!!$inputs.email.error}
aria-describedby="{emailId}-helper"
readonly={!!readOnlyReason}
bind:value={
() => $inputs.email.value,
(value) => {
$inputs.email.value = value;
$inputs.email.error = null;
}
}
/>
{#if $inputs.email.error}
<Field.Error id="{emailId}-helper" class="sm:row-start-2">
{$inputs.email.error}
</Field.Error>
{:else}
<p
id="{emailId}-helper"
class="text-caption text-fg-muted wrap-anywhere sm:row-start-2"
>
{#if pendingEmailChange}
{m.email_change_waiting({ email: pendingEmailChange.email })}
{:else if emailStatus}
<Badge variant={emailStatus.variant} class="text-caption">
{emailStatus.label}
</Badge>
{:else}
{m.email_helper_purpose()}
{/if}
</p>
{/if}
{#if pendingEmailChange}
<div class="sm:col-start-2 sm:row-start-1">
<Button variant="secondary" size="sm" usePromiseLoading onclick={cancelEmailChange}>
<LucideUndo2 />
{m.cancel_email_change()}
</Button>
</div>
{:else if emailStatus && !$userStore?.emailVerified}
<!-- An edited address is not saved yet, so the button waits instead of leaving and shifting the form on phones -->
<div class="sm:col-start-2 sm:row-start-1">
<Button
variant="secondary"
size="sm"
usePromiseLoading
disabled={$inputs.email.value !== $userStore?.email}
onclick={sendEmailVerification}
>
<LucideSend />
{m.send_email()}
</Button>
</div>
{/if}
</div>
</FormInput>
</form>
</div>
</div>
</SettingsSection>
@@ -5,6 +5,8 @@
import userStore from '#lib/stores/user-store.ts';
import { setLocale } from '#lib/utils/locale.util.ts';
let controlProps: { id: string; 'aria-describedby': string | undefined } = $props();
const userService = new UserService();
const currentLocale = getLocale();
@@ -47,10 +49,10 @@
</script>
<Select.Root type="single" value={currentLocale} onValueChange={(v) => updateLocale(v as Locale)}>
<Select.Trigger class="max-w-[200px]" aria-label="Select locale">
<Select.Trigger {...controlProps}>
{locales[currentLocale]}
</Select.Trigger>
<Select.Content class="max-h-100">
<Select.Content align="end" collisionPadding={16} class="max-h-100">
{#each Object.entries(locales) as [value, label] (value)}
<Select.Item {value}>{label}</Select.Item>
{/each}
@@ -1,30 +1,54 @@
<script lang="ts">
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
import PasskeyRow from '#lib/components/passkey-row.svelte';
import SettingsSection from '#lib/components/settings-section.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import WebauthnService from '#lib/services/webauthn-service.ts';
import WebAuthnService from '#lib/services/webauthn-service.ts';
import type { Passkey } from '#lib/types/passkey.type.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { axiosErrorToast, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { LucidePlus } from '@lucide/svelte';
import { startRegistration } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
const webauthnService = new WebauthnService();
const webauthnService = new WebAuthnService();
let passkeyToRename: Passkey | null = $state(null);
async function deletePasskey(passkey: Passkey) {
async function refresh() {
passkeys = await webauthnService.listCredentials();
}
async function createPasskey() {
try {
const opts = await webauthnService.getRegistrationOptions();
const attResp = await startRegistration({ optionsJSON: opts });
const passkey = await webauthnService.finishRegistration(attResp);
await refresh();
passkeyToRename = passkey;
} catch (e) {
toast.error(getWebauthnErrorMessage(e));
}
}
function deletePasskey(passkey: Passkey) {
openConfirmDialog({
title: m.delete_passkey_name({ passkeyName: passkey.name }),
message: m.are_you_sure_you_want_to_delete_this_passkey(),
title: m.delete_name_question({ name: passkey.name }),
message:
passkeys.length === 1
? m.delete_last_own_passkey_consequence()
: m.delete_own_passkey_consequence(),
confirm: {
label: m.delete_name_action({ name: passkey.name }),
destructive: true,
action: async () => {
try {
await webauthnService.removeCredential(passkey.id);
passkeys = await webauthnService.listCredentials();
await refresh();
toast.success(m.passkey_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
@@ -35,17 +59,44 @@
}
</script>
<div class="mt-3 divide-y overflow-hidden rounded-md border">
{#each passkeys as passkey (passkey.id)}
<PasskeyRow
{passkey}
onRename={() => (passkeyToRename = passkey)}
onDelete={() => deletePasskey(passkey)}
/>
{/each}
</div>
<SettingsSection title={m.passkeys()} description={m.passkeys_section_description()}>
{#snippet action()}
{#if passkeys.length > 0}
<Button variant="secondary" size="sm" usePromiseLoading onclick={createPasskey}>
<LucidePlus />
{m.add_passkey()}
</Button>
{/if}
{/snippet}
<RenamePasskeyModal
bind:passkey={passkeyToRename}
callback={async () => (passkeys = await webauthnService.listCredentials())}
/>
<div class="flex flex-col gap-3 pt-2">
{#if passkeys.length > 0}
<!-- Collections inside a section are open rows with hairlines, so the shared row loses its own inset -->
<div
role="list"
aria-label={m.passkeys()}
class="*:*:min-h-[68px] *:*:px-0 *:shadow-[inset_0_-1px_0_var(--border)] *:last:shadow-none"
>
{#each passkeys as passkey (passkey.id)}
<div role="listitem">
<PasskeyRow
{passkey}
onRename={() => (passkeyToRename = passkey)}
onDelete={() => deletePasskey(passkey)}
/>
</div>
{/each}
</div>
{:else}
<div class="flex min-h-11 flex-wrap items-center justify-between gap-2">
<p class="text-control text-fg-muted">{m.no_passkeys_yet()}</p>
<Button variant="ghost" size="sm" usePromiseLoading onclick={createPasskey}>
<LucidePlus />
{m.add_passkey()}
</Button>
</div>
{/if}
</div>
</SettingsSection>
<RenamePasskeyModal bind:passkey={passkeyToRename} callback={refresh} />
@@ -19,8 +19,9 @@
} = $props();
let name = $state('');
let input = $state<HTMLInputElement | null>(null);
$effect(() => {
$effect.pre(() => {
if (passkey) name = passkey.name;
});
@@ -34,7 +35,7 @@
async function onSubmit() {
await webauthnService
.updateCredentialName(passkey!.id, name)
.updateCredentialName(passkey!.id, name.trim())
.then(() => {
passkey = null;
toast.success(m.passkey_name_updated_successfully());
@@ -45,18 +46,25 @@
</script>
<Dialog.Root open={!!passkey} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Content
class="max-w-(--dialog-sm)"
onOpenAutoFocus={(e) => {
e.preventDefault();
input?.focus();
input?.select();
}}
>
<Dialog.Header>
<Dialog.Title>{m.name_passkey()}</Dialog.Title>
<Dialog.Description>{m.name_your_passkey_to_easily_identify_it_later()}</Dialog.Description>
</Dialog.Header>
<form onsubmit={preventDefault(onSubmit)}>
<div class="grid items-center gap-4 sm:grid-cols-4">
<Field.Label for="name" class="sm:text-right">{m.name()}</Field.Label>
<Input id="name" bind:value={name} class="col-span-3" />
</div>
<Field.Field>
<Field.Label for="name">{m.name()}</Field.Label>
<Input id="name" bind:ref={input} bind:value={name} maxlength={50} />
</Field.Field>
<Dialog.Footer class="mt-4">
<Button type="submit">{m.save()}</Button>
<Button type="submit" disabled={!name.trim()}>{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
@@ -1,32 +1,123 @@
<script lang="ts">
import FormattedMessage from '#lib/components/formatted-message.svelte';
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
import DocsLink from '#lib/components/docs-link.svelte';
import PageHeader from '#lib/components/page-header.svelte';
import EmptyList from '#lib/components/empty-list.svelte';
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
import { Badge } from '#lib/components/ui/badge/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';
import { getLocale } from '#lib/paraglide/runtime.js';
import ApiKeyService from '#lib/services/api-key-service.ts';
import type { ApiKeyCreate, ApiKeyResponse } from '#lib/types/api-key.type.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} from '#lib/types/advanced-table.type.ts';
import type { ApiKey } from '#lib/types/api-key.type.ts';
import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { LucideMinus, ShieldEllipsis, ShieldPlus } from '@lucide/svelte';
import { slide } from 'svelte/transition';
import ApiKeyDialog from './api-key-dialog.svelte';
import ApiKeyForm from './api-key-form.svelte';
import ApiKeyList from './api-key-list.svelte';
import { LucideArrowUpRight, LucideBan, LucidePlus, LucideRefreshCcw } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import ApiKeyCreateSheet from './api-key-create-sheet.svelte';
import RenewApiKeyDialog from './renew-api-key-dialog.svelte';
let { data } = $props();
const apiKeyService = new ApiKeyService();
let expandAddApiKey = $state(false);
let apiKeyResponse = $state<ApiKeyResponse | null>(null);
let listRef: ApiKeyList;
const docsUrl = 'https://pocket-id.org/docs/api';
const DAY = 24 * 60 * 60 * 1000;
async function createApiKey(apiKeyData: ApiKeyCreate) {
try {
const response = await apiKeyService.create(apiKeyData);
apiKeyResponse = response;
listRef.refresh();
return true;
} catch (e) {
axiosErrorToast(e);
return false;
let tableRef = $state<AdvancedTable<ApiKey>>();
let createOpen = $state(false);
let renewing = $state<ApiKey | null>(null);
let empty = $state(false);
const formatDate = (date: string) =>
new Date(date).toLocaleDateString(getLocale(), { dateStyle: 'medium' });
// Expiry reads as a status only when it needs attention; later dates are plain
function expiryStatus(key: ApiKey) {
const expiresAt = new Date(key.expiresAt);
if (expiresAt.getTime() <= Date.now())
return { variant: 'warning' as const, label: m.expired() };
// Count calendar days, since a day across a DST change is not 24 hours long
const days = Math.round(
(expiresAt.setHours(0, 0, 0, 0) - new Date().setHours(0, 0, 0, 0)) / DAY
);
if (days === 0) return { variant: 'warning' as const, label: m.expires_today() };
if (days === 1) return { variant: 'warning' as const, label: m.expires_tomorrow() };
if (days <= 7)
return { variant: 'warning' as const, label: m.expires_in_days({ count: days }) };
if (days <= 30) return { variant: 'muted' as const, label: m.expires_in_days({ count: days }) };
return null;
}
// Phones only show the key column, so its expiry and last use move into a line under the name
function phoneMeta(key: ApiKey) {
const expiry = expiryStatus(key)?.label ?? m.expires_date({ date: formatDate(key.expiresAt) });
const lastUsed = key.lastUsedAt ? `${m.last_used()} ${formatDate(key.lastUsedAt)}` : undefined;
return [expiry, lastUsed].filter(Boolean).join(' · ');
}
async function fetchKeys(options: ListRequestOptions) {
const res = await apiKeyService.list(options);
empty = res.pagination.totalItems === 0;
return res;
}
const refresh = () => tableRef?.refresh();
const columns: AdvancedTableColumn<ApiKey>[] = [
{ label: m.key(), column: 'name', sortable: true, cell: KeyCell },
{ label: m.expires(), column: 'expiresAt', sortable: true, cell: ExpiresCell },
{
label: m.last_used(),
column: 'lastUsedAt',
sortable: true,
hideBelow: 760,
cell: LastUsedCell
},
{
label: m.created(),
column: 'createdAt',
sortable: true,
hidden: true,
value: (key) => formatDate(key.createdAt)
}
];
// The backend renews only expired keys
const actions: CreateAdvancedTableActions<ApiKey> = (key) => [
{
label: m.renew_ellipsis(),
icon: LucideRefreshCcw,
hidden: new Date(key.expiresAt) > new Date(),
onClick: () => (renewing = key)
},
{ label: m.revoke_ellipsis(), icon: LucideBan, variant: 'danger', onClick: revoke }
];
function revoke(key: ApiKey) {
openConfirmDialog({
title: m.revoke_name_question({ name: key.name }),
message: m.revoke_api_key_consequence(),
confirm: {
label: m.revoke_name_action({ name: key.name }),
destructive: true,
icon: LucideBan,
action: async () => {
try {
await apiKeyService.revoke(key.id);
toast.success(m.name_revoked({ name: key.name }));
await refresh();
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
@@ -34,48 +125,90 @@
<title>{m.api_keys()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-4 md:flex-nowrap">
<div>
<Card.Title>
<ShieldPlus class="text-primary/80 size-5" />
{#snippet KeyCell({ item }: { item: ApiKey })}
<div class="flex max-w-(--table-id-max) min-w-0 flex-col">
<span class="text-control text-fg truncate font-medium">{item.name}</span>
{#if item.description}
<span class="text-caption text-fg-muted truncate">{item.description}</span>
{/if}
<span class="text-caption text-fg-muted truncate sm:hidden">{phoneMeta(item)}</span>
</div>
{/snippet}
{#snippet ExpiresCell({ item }: { item: ApiKey })}
{@const status = expiryStatus(item)}
{#if status}
<Badge variant={status.variant} title={formatDate(item.expiresAt)}>{status.label}</Badge>
{:else}
<span class="tabular-nums">{formatDate(item.expiresAt)}</span>
{/if}
{/snippet}
{#snippet LastUsedCell({ item }: { item: ApiKey })}
{#if item.lastUsedAt}
<span class="tabular-nums">{formatDate(item.lastUsedAt)}</span>
{:else}
<span class="text-fg-muted">{m.never()}</span>
{/if}
{/snippet}
{#snippet headerDescription()}
{m.api_keys_page_description()}
<DocsLink href={docsUrl} />
{/snippet}
<div class="flex flex-col">
<PageHeader title={m.api_keys()} description={empty ? undefined : headerDescription}>
{#snippet actions()}
{#if !empty}
<Button onclick={() => (createOpen = true)}>
<LucidePlus />
{m.create_api_key()}
</Card.Title>
<Card.Description>
<FormattedMessage message={m.add_a_new_api_key_for_programmatic_access} />
</Card.Description>
</div>
{#if !expandAddApiKey}
<Button class="w-full md:w-auto" onclick={() => (expandAddApiKey = true)}
>{m.add_api_key()}</Button
>
{:else}
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddApiKey = false)}>
<LucideMinus class="size-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddApiKey}
<div transition:slide>
<Card.Content>
<ApiKeyForm callback={createApiKey} />
</Card.Content>
{/snippet}
</PageHeader>
{#if empty}
<EmptyList
columns={[m.key(), m.expires()]}
title={m.create_your_first_api_key()}
avatar={false}
>
{#snippet description()}
{m.no_api_keys_yet_description({ appName: $appConfigStore.appName })}
{/snippet}
{#snippet action()}
<Button onclick={() => (createOpen = true)}>
<LucidePlus />
{m.create_api_key()}
</Button>
<Button variant="link" class="gap-1" href={docsUrl} target="_blank" rel="noreferrer">
{m.docs()}
<LucideArrowUpRight />
</Button>
{/snippet}
</EmptyList>
{:else}
<div class="mt-10">
<AdvancedTable
id="api-key-list"
bind:this={tableRef}
fetchCallback={fetchKeys}
defaultSort={{ column: 'lastUsedAt', direction: 'desc' }}
withoutSearch
{columns}
{actions}
/>
</div>
{/if}
</Card.Root>
</div>
<Card.Root class="gap-0">
<Card.Header>
<Card.Title>
<ShieldEllipsis class="text-primary/80 size-5" />
{m.manage_api_keys()}
</Card.Title>
</Card.Header>
<Card.Content>
<ApiKeyList bind:this={listRef} />
</Card.Content>
</Card.Root>
<ApiKeyDialog title={m.api_key_created()} bind:apiKeyResponse />
<ApiKeyCreateSheet
bind:open={createOpen}
onCreated={async () => {
empty = false;
await refresh();
}}
/>
<RenewApiKeyDialog bind:apiKey={renewing} onRenewed={refresh} />
@@ -0,0 +1,7 @@
import ApiKeyService from '#lib/services/api-key-service.ts';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const apiKeys = await new ApiKeyService().list({ pagination: { page: 1, limit: 1 } });
return { empty: apiKeys.pagination.totalItems === 0 };
};
@@ -0,0 +1,98 @@
<script lang="ts">
import FormInput from '#lib/components/form/form-input.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Sheet from '#lib/components/ui/sheet/index.ts';
import { m } from '#lib/paraglide/messages.js';
import ApiKeyService from '#lib/services/api-key-service.ts';
import type { ApiKeyResponse } from '#lib/types/api-key.type.ts';
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 { emptyToUndefined } from '#lib/utils/zod-util.ts';
import { z } from 'zod/v4';
import ApiKeyReveal from './api-key-reveal.svelte';
let { open = $bindable(), onCreated }: { open: boolean; onCreated: () => unknown } = $props();
const apiKeyService = new ApiKeyService();
const formId = $props.id();
let isLoading = $state(false);
let created = $state<ApiKeyResponse | null>(null);
function inThirtyDays() {
const date = new Date();
date.setDate(date.getDate() + 30);
date.setHours(0, 0, 0, 0);
return date;
}
const formSchema = z.object({
name: z.string().min(3).max(50),
description: emptyToUndefined(z.string()),
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
});
const formStore = createForm(formSchema, {
name: '',
description: '',
expiresAt: inThirtyDays()
});
const { inputs } = formStore;
function reset() {
formStore.reset();
formStore.setValue('expiresAt', inThirtyDays());
created = null;
}
async function onSubmit() {
const data = formStore.validate();
if (!data) return;
isLoading = true;
try {
created = await apiKeyService.create(data);
await onCreated();
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
</script>
<Sheet.Root bind:open onOpenChangeComplete={(o) => !o && reset()}>
<Sheet.Content title={created ? m.api_key_created() : m.create_api_key()}>
{#if created}
<ApiKeyReveal response={created} />
{:else}
<form id={formId} onsubmit={preventDefault(onSubmit)} class="flex flex-col gap-6">
<FormInput
label={m.name()}
placeholder="Terraform"
description={m.api_key_name_helper()}
bind:input={$inputs.name}
/>
<FormInput
label={m.description()}
optional
placeholder={m.api_key_description_placeholder()}
bind:input={$inputs.description}
/>
<FormInput
label={m.expires_on()}
description={m.api_key_expires_on_helper()}
type="date"
bind:input={$inputs.expiresAt}
/>
</form>
{/if}
{#snippet footer()}
{#if created}
<Button onclick={() => (open = false)}>{m.done()}</Button>
{:else}
<Button variant="ghost" onclick={() => (open = false)}>{m.cancel()}</Button>
<Button type="submit" form={formId} {isLoading}>{m.create_api_key()}</Button>
{/if}
{/snippet}
</Sheet.Content>
</Sheet.Root>
@@ -1,52 +0,0 @@
<script lang="ts">
import CopyToClipboard from '#lib/components/copy-to-clipboard.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as Dialog from '#lib/components/ui/dialog/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { ApiKeyResponse } from '#lib/types/api-key.type.ts';
let {
title,
apiKeyResponse = $bindable()
}: {
title: string;
apiKeyResponse: ApiKeyResponse | null;
} = $props();
function onOpenChange(open: boolean) {
if (!open) {
apiKeyResponse = null;
}
}
</script>
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
<Dialog.Content class="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{m.for_security_reasons_this_key_will_only_be_shown_once()}
</Dialog.Description>
</Dialog.Header>
{#if apiKeyResponse}
<div>
<div class="mb-2 font-medium">{m.name()}</div>
<p class="text-muted-foreground">{apiKeyResponse.apiKey.name}</p>
{#if apiKeyResponse.apiKey.description}
<div class="mt-4 mb-2 font-medium">{m.description()}</div>
<p class="text-muted-foreground">{apiKeyResponse.apiKey.description}</p>
{/if}
<div class="mt-4 mb-2 font-medium">{m.api_key()}</div>
<div class="bg-muted rounded-md p-2">
<CopyToClipboard value={apiKeyResponse.token}>
<span class="font-mono text-sm break-all">{apiKeyResponse.token}</span>
</CopyToClipboard>
</div>
</div>
{/if}
<Dialog.Footer class="mt-3">
<Button variant="default" onclick={() => onOpenChange(false)}>{m.close()}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,80 +0,0 @@
<script lang="ts">
import FormInput from '#lib/components/form/form-input.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { ApiKeyCreate } from '#lib/types/api-key.type.ts';
import { preventDefault } from '#lib/utils/event-util.ts';
import { createForm } from '#lib/utils/form-util.ts';
import { emptyToUndefined } from '#lib/utils/zod-util.ts';
import { SvelteDate } from 'svelte/reactivity';
import { z } from 'zod/v4';
let {
callback
}: {
callback: (apiKey: ApiKeyCreate) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
// Set default expiration to 30 days from now
const defaultExpiry = new SvelteDate();
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
defaultExpiry.setHours(0, 0, 0, 0);
const apiKey = {
name: '',
description: '',
expiresAt: defaultExpiry
};
const formSchema = z.object({
name: z.string().min(3).max(50),
description: emptyToUndefined(z.string().optional()),
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, apiKey);
async function onSubmit() {
const data = form.validate();
if (!data) return;
const apiKeyData: ApiKeyCreate = {
name: data.name,
description: data.description,
expiresAt: data.expiresAt
};
isLoading = true;
const success = await callback(apiKeyData);
if (success) form.reset();
isLoading = false;
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput
label={m.name()}
bind:input={$inputs.name}
description={m.name_to_identify_this_api_key()}
/>
<FormInput
label={m.expires_at()}
type="date"
description={m.when_this_api_key_will_expire()}
bind:input={$inputs.expiresAt}
/>
<div class="col-span-1 md:col-span-2">
<FormInput
label={m.description()}
description={m.optional_description_to_help_identify_this_keys_purpose()}
bind:input={$inputs.description}
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>
@@ -1,138 +0,0 @@
<script lang="ts">
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
import { m } from '#lib/paraglide/messages.js';
import ApiKeyService from '#lib/services/api-key-service.ts';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} from '#lib/types/advanced-table.type.ts';
import type { ApiKey, ApiKeyResponse } from '#lib/types/api-key.type.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { LucideBan, LucideRefreshCcw, LucideTriangleAlert } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import ApiKeyDialog from './api-key-dialog.svelte';
import RenewApiKeyModal from './renew-api-key-modal.svelte';
const apiKeyService = new ApiKeyService();
let apiKeyToRenew: ApiKey | null = $state(null);
let renewedApiKey: ApiKeyResponse | null = $state(null);
let tableRef: AdvancedTable<ApiKey>;
export function refresh() {
return tableRef?.refresh();
}
function formatDate(dateStr: string | undefined) {
if (!dateStr) return m.never();
return new Date(dateStr).toLocaleString();
}
const columns: AdvancedTableColumn<ApiKey>[] = [
{ label: m.name(), column: 'name', sortable: true },
{
label: m.description(),
column: 'description'
},
{
label: m.expires_at(),
column: 'expiresAt',
sortable: true,
cell: ExpirationCell
},
{
label: m.last_used(),
column: 'lastUsedAt',
sortable: true,
value: (item) => formatDate(item.lastUsedAt)
},
{
label: m.created(),
column: 'createdAt',
sortable: true,
hidden: true,
value: (item) => formatDate(item.createdAt)
}
];
const actions: CreateAdvancedTableActions<ApiKey> = (apiKey) => [
{
label: m.renew(),
icon: LucideRefreshCcw,
variant: 'primary',
hidden: new Date(apiKey.expiresAt) > new Date(),
onClick: (apiKey) => (apiKeyToRenew = apiKey)
},
{
label: m.revoke(),
icon: LucideBan,
variant: 'danger',
onClick: (apiKey) => revokeApiKey(apiKey)
}
];
async function renewApiKey(expirationDate: Date) {
if (!apiKeyToRenew) return;
await apiKeyService
.renew(apiKeyToRenew.id, expirationDate)
.then(async (response) => {
renewedApiKey = response;
await refresh();
apiKeyToRenew = null;
})
.catch((e) => {
axiosErrorToast(e);
});
}
function revokeApiKey(apiKey: ApiKey) {
openConfirmDialog({
title: m.revoke_api_key(),
message: m.are_you_sure_you_want_to_revoke_the_api_key_apikeyname({
apiKeyName: apiKey.name
}),
confirm: {
label: m.revoke(),
destructive: true,
action: async () => {
try {
await apiKeyService.revoke(apiKey.id);
await refresh();
toast.success(m.api_key_revoked_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
{#snippet ExpirationCell({ item }: { item: ApiKey })}
{@const expired = new Date(item.expiresAt) <= new Date()}
<span
class={{
'flex items-center gap-2': true,
'text-orange-300': expired
}}
>{formatDate(item.expiresAt)}
{#if expired}
<LucideTriangleAlert class="size-4" />
{/if}
</span>
{/snippet}
<AdvancedTable
id="api-key-list"
bind:this={tableRef}
fetchCallback={apiKeyService.list}
defaultSort={{ column: 'lastUsedAt', direction: 'desc' }}
withoutSearch
{columns}
{actions}
/>
<ApiKeyDialog title={m.api_key_renewed()} bind:apiKeyResponse={renewedApiKey} />
<RenewApiKeyModal bind:apiKey={apiKeyToRenew} onRenew={renewApiKey} />
@@ -0,0 +1,27 @@
<script lang="ts">
import CopyField from '#lib/components/copy-field.svelte';
import { m } from '#lib/paraglide/messages.js';
import { getLocale } from '#lib/paraglide/runtime.js';
import type { ApiKeyResponse } from '#lib/types/api-key.type.ts';
import { LucideInfo } from '@lucide/svelte';
let { response }: { response: ApiKeyResponse } = $props();
</script>
<dl class="text-control grid grid-cols-[auto_1fr] gap-x-6 gap-y-2">
<dt class="text-fg-muted">{m.name()}</dt>
<dd class="wrap-anywhere">{response.apiKey.name}</dd>
{#if response.apiKey.description}
<dt class="text-fg-muted">{m.description()}</dt>
<dd class="wrap-anywhere">{response.apiKey.description}</dd>
{/if}
<dt class="text-fg-muted">{m.expires()}</dt>
<dd>
{new Date(response.apiKey.expiresAt).toLocaleDateString(getLocale(), { dateStyle: 'medium' })}
</dd>
</dl>
<p class="text-caption text-fg-muted flex items-start gap-1.5">
<LucideInfo class="mt-[3px] size-3 shrink-0" />
{m.copy_key_now_before_closing()}
</p>
<CopyField label={m.api_key()} value={response.token} copyLabel={m.copy_api_key()} />
@@ -0,0 +1,74 @@
<script lang="ts">
import DatePicker from '#lib/components/form/date-picker.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 { m } from '#lib/paraglide/messages.js';
import ApiKeyService from '#lib/services/api-key-service.ts';
import type { ApiKey, ApiKeyResponse } from '#lib/types/api-key.type.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import ApiKeyReveal from './api-key-reveal.svelte';
let { apiKey = $bindable(), onRenewed }: { apiKey: ApiKey | null; onRenewed: () => unknown } =
$props();
const apiKeyService = new ApiKeyService();
let open = $state(false);
let date = $state(new Date());
let renewed = $state<ApiKeyResponse | null>(null);
// The new key lasts as long as the old one did
// It is rounded up to a midnight, because a picked day means the key stops working at the start of that day
$effect(() => {
if (!apiKey) return;
const lifetime = new Date(apiKey.expiresAt).getTime() - new Date(apiKey.createdAt).getTime();
const next = new Date(Date.now() + lifetime);
next.setHours(24, 0, 0, 0);
date = next;
open = true;
});
async function renew() {
try {
renewed = await apiKeyService.renew(apiKey!.id, date);
await onRenewed();
} catch (e) {
axiosErrorToast(e);
}
}
function reset() {
apiKey = null;
renewed = null;
}
</script>
<Dialog.Root bind:open onOpenChangeComplete={(o) => !o && reset()}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{renewed ? m.api_key_renewed() : m.renew_name_question({ name: apiKey?.name ?? '' })}
</Dialog.Title>
{#if !renewed}
<Dialog.Description>{m.renew_api_key_consequence()}</Dialog.Description>
{/if}
</Dialog.Header>
{#if renewed}
<ApiKeyReveal response={renewed} />
<Dialog.Footer>
<Button onclick={() => (open = false)}>{m.done()}</Button>
</Dialog.Footer>
{:else}
<Field.Field>
<Field.Label for="renew-expires-on">{m.expires_on()}</Field.Label>
<div class="max-w-60"><DatePicker id="renew-expires-on" bind:value={date} /></div>
<Field.Description>{m.api_key_renew_expires_on_helper()}</Field.Description>
</Field.Field>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)}>{m.cancel()}</Button>
<Button usePromiseLoading onclick={renew}>{m.renew_key()}</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -1,52 +0,0 @@
<script lang="ts">
import DatePicker from '#lib/components/form/date-picker.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.js';
import { m } from '#lib/paraglide/messages.js';
import type { ApiKey } from '#lib/types/api-key.type.ts';
let {
apiKey = $bindable(null),
onRenew
}: {
apiKey: ApiKey | null;
onRenew: (date: Date) => Promise<void>;
} = $props();
let date = $state(new Date());
$effect(() => {
if (apiKey) {
const lastExpirationDuration =
new Date(apiKey.expiresAt).getTime() - new Date(apiKey.createdAt).getTime();
date = new Date(Date.now() + lastExpirationDuration);
}
});
function onOpenChange(open: boolean) {
if (!open) {
apiKey = null;
}
}
</script>
<Dialog.Root open={!!apiKey} {onOpenChange}>
<Dialog.Content class="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{m.renew_api_key()}</Dialog.Title>
<Dialog.Description>
{m.renew_api_key_description()}
</Dialog.Description>
</Dialog.Header>
<Field.Field>
<Field.Label>{m.expiration()}</Field.Label>
<DatePicker bind:value={date} />
</Field.Field>
<Dialog.Footer class="mt-3">
<Button variant="outline" onclick={() => onOpenChange(false)}>{m.cancel()}</Button>
<Button variant="default" usePromiseLoading onclick={() => onRenew(date)}>{m.renew()}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,76 +1,157 @@
<script lang="ts">
import { goto } from '$app/navigation';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import DocsLink from '#lib/components/docs-link.svelte';
import OidcClientAvatar from '#lib/components/oidc-client-avatar.svelte';
import PageHeader from '#lib/components/page-header.svelte';
import EmptyList from '#lib/components/empty-list.svelte';
import AdvancedTable from '#lib/components/table/advanced-table.svelte';
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';
import ApisService from '#lib/services/apis-service.ts';
import type { ApiCreate } from '#lib/types/api.type.ts';
import { LucideMinus, LucidePlus, LucideServer } from '@lucide/svelte';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} from '#lib/types/advanced-table.type.ts';
import type { Api } from '#lib/types/api.type.ts';
import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import ApiForm from './api-form.svelte';
import ApiList from './api-list.svelte';
import {
LucideArrowRight,
LucideArrowUpRight,
LucideCopy,
LucidePlus,
LucideTrash2
} from '@lucide/svelte';
import { confirmDeleteApi } from './api-actions';
import ApiCreateSheet from './api-create-sheet.svelte';
let expandAddApi = $state(false);
let { data } = $props();
const apisService = new ApisService();
const docsUrl = 'https://pocket-id.org/docs/guides/apis';
async function createApi(api: ApiCreate) {
const createdApi = await apisService.create(api);
toast.success(m.api_created_successfully());
goto(`/settings/admin/apis/${createdApi.id}`);
let tableRef = $state<AdvancedTable<Api>>();
let createOpen = $state(false);
let empty = $state(false);
const href = (api: Api) => `/settings/admin/apis/${api.id}`;
async function fetchApis(options: ListRequestOptions) {
const res = await apisService.list(options);
empty = res.pagination.totalItems === 0 && !options.search;
return res;
}
const columns: AdvancedTableColumn<Api>[] = $derived([
{ label: m.api(), column: 'name', sortable: true, cell: ApiCell },
{ label: m.permissions(), key: 'permissions', value: (api) => api.permissions.length },
...(data.features?.cimd.enabled
? [
{
label: m.metadata_document_clients(),
key: 'allowCimdClients',
hidden: true,
cell: CimdCell
}
]
: []),
{ label: m.api_id(), key: 'id', hidden: true, cell: IdCell }
]);
const actions: CreateAdvancedTableActions<Api> = () => [
{ label: m.open(), primary: true, icon: LucideArrowRight, onClick: (api) => goto(href(api)) },
{
label: m.copy_resource_uri(),
icon: LucideCopy,
onClick: async (api) => {
await navigator.clipboard.writeText(api.resource);
toast.success(m.resource_uri_copied());
}
},
{
label: m.delete_ellipsis(),
icon: LucideTrash2,
variant: 'danger',
onClick: (api) => confirmDeleteApi(api, () => tableRef?.refresh())
}
];
</script>
<svelte:head>
<title>{m.apis()}</title>
</svelte:head>
<div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-4 md:flex-nowrap">
<div>
<Card.Title>
<LucidePlus class="text-primary/80 size-5" />
{m.create_api()}
</Card.Title>
<Card.Description>
<FormattedMessage message={m.create_a_new_api_description} />
</Card.Description>
</div>
{#if !expandAddApi}
<Button class="w-full md:w-auto" onclick={() => (expandAddApi = true)}
>{m.add_api()}</Button
>
{:else}
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddApi = false)}>
<LucideMinus class="size-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddApi}
<div transition:slide>
<Card.Content>
<ApiForm callback={createApi} />
</Card.Content>
</div>
{/if}
</Card.Root>
{#snippet ApiCell({ item }: { item: Api })}
<div class="flex max-w-(--table-id-max) min-w-0 items-center gap-3">
<OidcClientAvatar id={item.id} name={item.name} hasLogo={false} size={24} />
<div class="flex min-w-0 flex-col">
<a
href={href(item)}
class="text-control text-fg truncate font-medium"
onclick={(e) => e.stopPropagation()}>{item.name}</a
>
<span class="text-mono-s text-fg-muted truncate font-mono">{item.resource}</span>
</div>
</div>
{/snippet}
{#snippet CimdCell({ item }: { item: Api })}
{#if item.allowCimdClients}
{m.allowed()}
{:else}
<span class="text-fg-muted">{m.not_allowed()}</span>
{/if}
{/snippet}
{#snippet IdCell({ item }: { item: Api })}
<span class="text-caption font-mono">{item.id}</span>
{/snippet}
{#snippet headerDescription()}
{m.apis_page_description()}
<DocsLink href={docsUrl} />
{/snippet}
<div class="flex flex-col">
<!-- The first-run block states the purpose itself, so the header drops its description while the list is empty -->
<PageHeader title={m.apis()} description={empty ? undefined : headerDescription}>
{#snippet actions()}
{#if !empty}
<Button onclick={() => (createOpen = true)}>
<LucidePlus />
{m.add_api()}
</Button>
{/if}
{/snippet}
</PageHeader>
{#if empty}
<EmptyList columns={[m.api(), m.permissions()]} title={m.register_your_first_api()}>
{#snippet description()}{m.no_apis_yet_description()}{/snippet}
{#snippet action()}
<Button onclick={() => (createOpen = true)}>
<LucidePlus />
{m.add_api()}
</Button>
<Button variant="link" class="gap-1" href={docsUrl} target="_blank" rel="noreferrer">
{m.docs()}
<LucideArrowUpRight />
</Button>
{/snippet}
</EmptyList>
{:else}
<div class="mt-10">
<AdvancedTable
id="api-list"
bind:this={tableRef}
fetchCallback={fetchApis}
defaultSort={{ column: 'name', direction: 'asc' }}
onRowClick={(api) => goto(href(api))}
{columns}
{actions}
/>
</div>
{/if}
</div>
<div>
<Card.Root>
<Card.Header>
<Card.Title>
<LucideServer class="text-primary/80 size-5" />
{m.manage_apis()}
</Card.Title>
</Card.Header>
<Card.Content>
<ApiList />
</Card.Content>
</Card.Root>
</div>
<ApiCreateSheet bind:open={createOpen} />
@@ -0,0 +1,7 @@
import ApisService from '#lib/services/apis-service.ts';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const apis = await new ApisService().list({ pagination: { page: 1, limit: 1 } });
return { empty: apis.pagination.totalItems === 0 };
};
@@ -1,102 +1,200 @@
<script lang="ts">
import ActionsMenu from '#lib/components/actions-menu.svelte';
import CollapsibleCard from '#lib/components/collapsible-card.svelte';
import * as Card from '#lib/components/ui/card/index.ts';
import CopyButton from '#lib/components/copy-button.svelte';
import FormInput from '#lib/components/form/form-input.svelte';
import OidcClientAvatar from '#lib/components/oidc-client-avatar.svelte';
import PageHeader from '#lib/components/page-header.svelte';
import SettingsSection from '#lib/components/settings-section.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.ts';
import * as Tabs from '#lib/components/ui/tabs/index.ts';
import { m } from '#lib/paraglide/messages.js';
import ApisService from '#lib/services/apis-service.ts';
import type { ApiCimdAccessUpdate, ApiCreate, ApiPermissionInput } from '#lib/types/api.type.ts';
import { trackUnsavedValue } from '#lib/utils/unsaved-changes-util.svelte.ts';
import { LucideChevronLeft } from '@lucide/svelte';
import type { ApiCimdAccessUpdate, ApiPermissionInput } from '#lib/types/api.type.ts';
import { createForm } from '#lib/utils/form-util.ts';
import { trackFormChanges, trackUnsavedValue } from '#lib/utils/unsaved-changes-util.svelte.ts';
import { LucideCopy, LucideEllipsis, LucideInfo, LucideTrash2 } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
import { backNavigate } from '../../users/navigate-back-util';
import { deleteApiAction } from '../api-actions';
import ApiForm from '../api-form.svelte';
import ApiAccessCard from './api-access-card.svelte';
import { confirmDeleteApi } from '../api-actions';
import ApiCimdAccessTab from './api-cimd-access-tab.svelte';
import ApiClientsTab from './api-clients-tab.svelte';
import ApiPermissionsInput from './api-permissions-input.svelte';
let { data } = $props();
let api = $state(data.api);
let clients = $state(data.clients);
let permissions = $state<ApiPermissionInput[]>(toPermissionInputs(data.api.permissions));
let clientsTab = $state<ApiClientsTab>();
let tab = $state('general');
const apisService = new ApisService();
const backNavigation = backNavigate('/settings/admin/apis');
function toPermissionInputs(
apiPermissions: { key: string; name: string; description?: string }[]
): ApiPermissionInput[] {
const inputs = apiPermissions.map((p) => ({
return apiPermissions.map((p) => ({
key: p.key,
name: p.name,
description: p.description ?? ''
}));
// Show an empty row so the user doesn't have to add one first
return inputs.length > 0 ? inputs : [{ key: '', name: '', description: '' }];
}
function isEmptyPermission(p: ApiPermissionInput) {
return !p.key.trim() && !p.name.trim() && !p.description.trim();
}
const isEmptyPermission = (p: ApiPermissionInput) =>
!p.key.trim() && !p.name.trim() && !p.description.trim();
const apisService = new ApisService();
const backNavigation = backNavigate('/settings/admin/apis');
const actions = [deleteApiAction(backNavigation.leave)];
let accessCard = $state<ApiAccessCard>();
async function updateApi(updated: ApiCreate) {
api = { ...api, ...(await apisService.update(api.id, { name: updated.name })) };
}
async function updatePermissions(updatedPermissions: ApiPermissionInput[]) {
try {
const res = await apisService.updatePermissions(api.id, updatedPermissions);
api = res;
permissions = toPermissionInputs(res.permissions);
} finally {
// A removed permission takes the client grants that referenced it with it
await accessCard?.refresh();
const formStore = createForm(z.object({ name: z.string().min(1).max(50) }), { name: api.name });
const { inputs } = formStore;
trackFormChanges(
() => formStore,
async ({ name }) => {
api = { ...api, ...(await apisService.update(api.id, { name })) };
}
}
);
trackUnsavedValue(
() => permissions.filter((p) => !isEmptyPermission(p)),
(savedPermissions) => (permissions = toPermissionInputs(savedPermissions)),
updatePermissions
(saved) => (permissions = toPermissionInputs(saved)),
async (updated) => {
try {
api = await apisService.updatePermissions(api.id, updated);
permissions = updated;
} finally {
// A removed permission takes the client grants that referenced it with it
await clientsTab?.refresh();
}
}
);
async function updateCimdAccess(update: ApiCimdAccessUpdate) {
try {
api = await apisService.updateCimdAccess(api.id, update);
} finally {
await accessCard?.refresh();
await clientsTab?.refresh();
}
}
async function copy(value: string, message: string) {
await navigator.clipboard.writeText(value);
toast.success(message);
}
</script>
<svelte:head>
<title>{api.name}</title>
</svelte:head>
<div class="flex items-center justify-between">
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}>
<LucideChevronLeft class="size-5" />
{m.back()}
</button>
<ActionsMenu item={api} {actions} label={m.actions()} variant="outline" size="icon-sm" />
{#snippet count(n: number)}
<span class="text-fg-muted tabular-nums">{n}</span>
{/snippet}
<div class="flex flex-col gap-8">
<PageHeader
title={api.name}
breadcrumbs={[{ label: m.administration() }, { label: m.apis(), href: '/settings/admin/apis' }]}
>
{#snippet identity()}
<OidcClientAvatar id={api.id} name={api.name} hasLogo={false} size={40} />
{/snippet}
{#snippet tags()}
<span class="text-mono-s font-mono break-all">{api.resource}</span>
{/snippet}
{#snippet actions()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="icon" aria-label={m.more_actions()}>
<LucideEllipsis />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => copy(api.resource, m.resource_uri_copied())}>
<LucideCopy />
{m.copy_resource_uri()}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => copy(api.id, m.api_id_copied())}>
<LucideCopy />
{m.copy_api_id()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={() => confirmDeleteApi(api, backNavigation.leave)}
>
<LucideTrash2 />
{m.delete_api_ellipsis()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</PageHeader>
<Tabs.Root bind:value={tab} useHash class="gap-10">
<div class="shadow-[inset_0_-1px_0_var(--border)]">
<div class="scroll-fade-x [scrollbar-width:none] overflow-x-auto">
<Tabs.List variant="line" class="shadow-none max-sm:gap-4" aria-label={m.api_sections()}>
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
<Tabs.Trigger value="permissions">
{m.permissions()}
{@render count(api.permissions.length)}
</Tabs.Trigger>
<Tabs.Trigger value="clients">{m.clients()} {@render count(clients.length)}</Tabs.Trigger>
{#if data.features?.cimd.enabled}
<Tabs.Trigger value="metadata-documents">{m.metadata_documents()}</Tabs.Trigger>
{/if}
</Tabs.List>
</div>
</div>
<Tabs.Content value="general">
<SettingsSection title={m.name_and_identifiers()}>
<div class="flex flex-col gap-6 pt-4">
<FormInput class="max-w-100" label={m.name()} bind:input={$inputs.name} />
<div class="flex flex-col gap-1">
<p class="text-caption text-fg-muted mb-1 font-medium">{m.identifiers()}</p>
<dl class="text-control flex flex-col">
{#each [{ label: m.api_resource_uri(), value: api.resource }, { label: m.api_id(), value: api.id }] as identifier (identifier.label)}
<div
class="flex min-h-9 items-center gap-4 max-sm:flex-col max-sm:items-stretch max-sm:gap-0"
>
<dt class="text-fg-muted w-35 shrink-0">{identifier.label}</dt>
<dd class="flex min-w-0 flex-1 items-center gap-2">
<span class="text-mono-s min-w-0 truncate font-mono" title={identifier.value}
>{identifier.value}</span
>
<CopyButton
value={identifier.value}
label={m.copy_name({ name: identifier.label })}
/>
</dd>
</div>
{/each}
</dl>
<p class="text-caption text-fg-muted flex items-start gap-1.5">
<LucideInfo class="mt-[3px] size-3 shrink-0" />
{m.resource_uris_cant_be_changed()}
</p>
</div>
</div>
</SettingsSection>
</Tabs.Content>
<Tabs.Content value="permissions">
<SettingsSection title={m.permissions()} description={m.api_permissions_description()}>
<div class="pt-2">
<ApiPermissionsInput bind:permissions />
</div>
</SettingsSection>
</Tabs.Content>
<Tabs.Content value="clients">
<ApiClientsTab bind:this={clientsTab} {api} bind:clients />
</Tabs.Content>
<Tabs.Content value="metadata-documents">
<ApiCimdAccessTab {api} onSave={updateCimdAccess} />
</Tabs.Content>
</Tabs.Root>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<ApiForm existingApi={api} callback={updateApi} />
</Card.Content>
</Card.Root>
<CollapsibleCard
id="api-permissions"
title={m.api_permissions()}
description={m.api_permissions_description()}
defaultExpanded={true}
>
<ApiPermissionsInput bind:permissions />
</CollapsibleCard>
<ApiAccessCard bind:this={accessCard} {api} onCimdAccessSave={updateCimdAccess} />
@@ -2,6 +2,10 @@ import ApisService from '#lib/services/apis-service.ts';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
const api = await new ApisService().get(params.id);
return { api };
const apisService = new ApisService();
const [api, clients] = await Promise.all([
apisService.get(params.id),
apisService.listClients(params.id, { pagination: { page: 1, limit: 100 } })
]);
return { api, clients: clients.data };
};

Some files were not shown because too many files have changed in this diff Show More