mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 20:19:04 +01:00
feat(frontend): rebuild screens with the v3 design
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+59
-43
@@ -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: '' };
|
||||
+45
-38
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }>;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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()} />
|
||||
@@ -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());
|
||||
@@ -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
Reference in New Issue
Block a user