feat: user application dashboard (#727)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-08-10 10:56:03 -05:00
committed by GitHub
parent 87956ea725
commit 484c2f6ef2
31 changed files with 640 additions and 92 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { confirmDialogStore } from '.';
import FormattedMessage from '../formatted-message.svelte';
import Button from '../ui/button/button.svelte';
</script>
@@ -9,7 +10,7 @@
<AlertDialog.Header>
<AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title>
<AlertDialog.Description>
{$confirmDialogStore.message}
<FormattedMessage m={$confirmDialogStore.message} />
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>

View File

@@ -8,53 +8,66 @@
} = $props();
interface MessagePart {
type: 'text' | 'link';
type: 'text' | 'link' | 'bold';
content: string;
href?: string;
}
// Extracts attribute value from a tag's attribute string
function getAttr(attrs: string, name: string): string | undefined {
const re = new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i');
const m = re.exec(attrs ?? '');
return m?.[2];
}
const handlers: Record<string, (attrs: string, inner: string) => MessagePart | null> = {
link: (attrs, inner) => {
const href = getAttr(attrs, 'href');
if (!href) return { type: 'text', content: inner };
return { type: 'link', content: inner, href };
},
b: (_attrs, inner) => ({ type: 'bold', content: inner })
};
function buildTokenRegex(): RegExp {
const keys = Object.keys(handlers).join('|');
// Matches: <tag attrs>inner</tag> for allowed tags only
return new RegExp(`<(${keys})\\b([^>]*)>(.*?)<\\/\\1>`, 'g');
}
function parseMessage(content: string): MessagePart[] | string {
// Regex to match only <link href="url">text</link> format
const linkRegex = /<link\s+href=(['"])(.*?)\1>(.*?)<\/link>/g;
if (!linkRegex.test(content)) {
return content;
}
// Reset regex lastIndex for reuse
linkRegex.lastIndex = 0;
const tokenRegex = buildTokenRegex();
if (!tokenRegex.test(content)) return content;
// Reset lastIndex for reuse
tokenRegex.lastIndex = 0;
const parts: MessagePart[] = [];
let lastIndex = 0;
let match;
let match: RegExpExecArray | null;
while ((match = linkRegex.exec(content)) !== null) {
// Add text before the link
while ((match = tokenRegex.exec(content)) !== null) {
// Add text before the matched token
if (match.index > lastIndex) {
const textContent = content.slice(lastIndex, match.index);
if (textContent) {
parts.push({ type: 'text', content: textContent });
}
if (textContent) parts.push({ type: 'text', content: textContent });
}
const href = match[2];
const linkText = match[3];
parts.push({
type: 'link',
content: linkText,
href: href
});
const tag = match[1];
const attrs = match[2] ?? '';
const inner = match[3] ?? '';
const handler = handlers[tag];
const part: MessagePart | null = handler
? handler(attrs, inner)
: { type: 'text', content: inner };
if (part) parts.push(part);
lastIndex = match.index + match[0].length;
}
// Add remaining text after the last link
// Add remaining text after the last token
if (lastIndex < content.length) {
const remainingText = content.slice(lastIndex);
if (remainingText) {
parts.push({ type: 'text', content: remainingText });
}
if (remainingText) parts.push({ type: 'text', content: remainingText });
}
return parts;
@@ -69,6 +82,10 @@
{#each parsedContent as part}
{#if part.type === 'text'}
{part.content}
{:else if part.type === 'bold'}
<b>
{part.content}
</b>
{:else if part.type === 'link'}
<a
class="text-black underline dark:text-white"

View File

@@ -6,7 +6,7 @@
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { LucideLogOut, LucideUser } from '@lucide/svelte';
import { LayoutDashboard, LucideLogOut, LucideUser } from '@lucide/svelte';
const webauthnService = new WebAuthnService();
@@ -34,6 +34,9 @@
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onclick={() => goto('/settings/apps')}
><LayoutDashboard class="mr-2 size-4" /> {m.my_apps()}</DropdownMenu.Item
>
<DropdownMenu.Item onclick={() => goto('/settings/account')}
><LucideUser class="mr-2 size-4" /> {m.my_account()}</DropdownMenu.Item
>

View File

@@ -1,4 +1,5 @@
import type {
AuthorizedOidcClient,
AuthorizeResponse,
OidcClient,
OidcClientCreate,
@@ -113,6 +114,24 @@ class OidcService extends APIService {
});
return response.data;
}
async listAuthorizedClients(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/oidc/users/me/clients', {
params: options
});
return res.data as Paginated<AuthorizedOidcClient>;
}
async listAuthorizedClientsForUser(userId: string, options?: SearchPaginationSortRequest) {
const res = await this.api.get(`/oidc/users/${userId}/clients`, {
params: options
});
return res.data as Paginated<AuthorizedOidcClient>;
}
async revokeOwnAuthorizedClient(clientId: string) {
await this.api.delete(`/oidc/users/me/clients/${clientId}`);
}
}
export default OidcService;

View File

@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
id: string;
name: string;
hasLogo: boolean;
launchURL?: string;
};
export type OidcClientFederatedIdentity = {
@@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & {
isPublic: boolean;
pkceEnabled: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
};
export type OidcClientWithAllowedUserGroups = OidcClient & {
@@ -50,3 +52,8 @@ export type AuthorizeResponse = {
callbackURL: string;
issuer: string;
};
export type AuthorizedOidcClient = {
scope: string;
client: OidcClientMetaData;
};

View File

@@ -54,6 +54,13 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
inputs[input as keyof z.infer<T>].error = null;
}
}
// Update the input values with the parsed data
for (const key in result.data) {
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
inputs[key as keyof z.infer<T>].value = result.data[key];
}
}
return inputs;
});
return success ? data() : null;

View File

@@ -0,0 +1,11 @@
import z from 'zod/v4';
export const optionalString = z
.string()
.transform((v) => (v === '' ? undefined : v))
.optional();
export const optionalUrl = z
.url()
.optional()
.or(z.literal('').transform(() => undefined));

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import ScopeItem from '$lib/components/scope-item.svelte';
import { Button } from '$lib/components/ui/button';
@@ -98,17 +99,21 @@
{/if}
{#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mt-2 mb-10">
{@html m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: client.name,
appName: $appConfigStore.appName
})}
<FormattedMessage
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: client.name,
appName: $appConfigStore.appName
})}
/>
</p>
{:else if authorizationRequired}
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 mb-10">
<Card.Header>
<p class="text-muted-foreground text-start">
{@html m.client_wants_to_access_the_following_information({ client: client.name })}
<FormattedMessage
m={m.client_wants_to_access_the_following_information({ client: client.name })}
/>
</p>
</Card.Header>
<Card.Content data-testid="scopes">

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import ScopeList from '$lib/components/scope-list.svelte';
import { Button } from '$lib/components/ui/button';
@@ -94,9 +95,11 @@
<Card.Root class="mt-6">
<Card.Header class="pb-5">
<p class="text-muted-foreground text-start">
{@html m.client_wants_to_access_the_following_information({
client: deviceInfo!.client.name
})}
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
client: deviceInfo!.client.name
})}
/>
</p>
</Card.Header>
<Card.Content data-testid="scopes">

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
@@ -36,10 +37,12 @@
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2">
{@html m.do_you_want_to_sign_out_of_pocketid_with_the_account({
username: $userStore?.username ?? '',
appName: $appConfigStore.appName
})}
<FormattedMessage
m={m.do_you_want_to_sign_out_of_pocketid_with_the_account({
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>

View File

@@ -25,6 +25,8 @@
{ href: '/settings/audit-log', label: m.audit_log() }
];
const nonAdminLinks = [{ href: '/settings/apps', label: m.my_apps() }];
const adminLinks = [
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
@@ -35,6 +37,8 @@
if (user?.isAdmin || $userStore?.isAdmin) {
links.push(...adminLinks);
} else {
links.push(...nonAdminLinks);
}
</script>

View File

@@ -5,6 +5,7 @@
import type { ApiKeyCreate } from '$lib/types/api-key.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { optionalString } from '$lib/utils/zod-util';
import { z } from 'zod/v4';
let {
@@ -27,7 +28,7 @@
const formSchema = z.object({
name: z.string().min(3).max(50),
description: z.string().default(''),
description: optionalString,
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
});

View File

@@ -16,6 +16,7 @@
import { z } from 'zod/v4';
import FederatedIdentitiesInput from './federated-identities-input.svelte';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import { optionalUrl } from '$lib/utils/zod-util';
let {
callback,
@@ -38,6 +39,7 @@
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
}
@@ -49,6 +51,7 @@
logoutCallbackURLs: z.array(z.string().nonempty()),
isPublic: z.boolean(),
pkceEnabled: z.boolean(),
launchURL: optionalUrl,
credentials: z.object({
federatedIdentities: z.array(
z.object({
@@ -106,8 +109,18 @@
<form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2">
<FormInput label={m.name()} class="w-full" bind:input={$inputs.name} />
<div></div>
<FormInput
label={m.name()}
class="w-full"
description={m.client_name_description()}
bind:input={$inputs.name}
/>
<FormInput
label={m.client_launch_url()}
description={m.client_launch_url_description()}
class="w-full"
bind:input={$inputs.launchURL}
/>
<OidcCallbackUrlInput
label={m.callback_urls()}
description={m.callback_url_description()}

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import * as Pagination from '$lib/components/ui/pagination';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LayoutDashboard } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { default as AuthorizedOidcClientCard } from './authorized-oidc-client-card.svelte';
let { data } = $props();
let authorizedClients: Paginated<AuthorizedOidcClient> = $state(data.authorizedClients);
let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
const oidcService = new OIDCService();
async function onRefresh(options: SearchPaginationSortRequest) {
authorizedClients = await oidcService.listAuthorizedClients(options);
}
async function onPageChange(page: number) {
requestOptions.pagination = { limit: authorizedClients.pagination.itemsPerPage, page };
onRefresh(requestOptions);
}
async function revokeAuthorizedClient(client: OidcClientMetaData) {
openConfirmDialog({
title: m.revoke_access(),
message: m.revoke_access_description({
clientName: client.name
}),
confirm: {
label: m.revoke(),
destructive: true,
action: async () => {
try {
await oidcService.revokeOwnAuthorizedClient(client.id);
onRefresh(requestOptions);
toast.success(
m.revoke_access_successful({
clientName: client.name
})
);
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
<svelte:head>
<title>{m.my_apps()}</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold">
<LayoutDashboard class="text-primary/80 size-6" />
{m.my_apps()}
</h1>
</div>
{#if authorizedClients.data.length === 0}
<div class="py-16 text-center">
<LayoutDashboard class="text-muted-foreground mx-auto mb-4 size-16" />
<h3 class="text-muted-foreground mb-2 text-lg font-medium">
{m.no_apps_available()}
</h3>
<p class="text-muted-foreground mx-auto max-w-md text-sm">
{m.contact_your_administrator_for_app_access()}
</p>
</div>
{:else}
<div class="space-y-8">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{#each authorizedClients.data as authorizedClient}
<AuthorizedOidcClientCard {authorizedClient} onRevoke={revokeAuthorizedClient} />
{/each}
</div>
{#if authorizedClients.pagination.totalPages > 1}
<div class="border-border flex items-center justify-center border-t pt-3">
<Pagination.Root
class="mx-0 w-auto"
count={authorizedClients.pagination.totalItems}
perPage={authorizedClients.pagination.itemsPerPage}
{onPageChange}
page={authorizedClients.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-center">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link
{page}
isActive={authorizedClients.pagination.currentPage === page.value}
>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,22 @@
import OIDCService from '$lib/services/oidc-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const oidcService = new OIDCService();
const appRequestOptions: SearchPaginationSortRequest = {
pagination: {
page: 1,
limit: 20
},
sort: {
column: 'lastUsedAt',
direction: 'desc'
}
};
const authorizedClients = await oidcService.listAuthorizedClients(appRequestOptions);
return { authorizedClients, appRequestOptions };
};

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ImageBox from '$lib/components/image-box.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import {
LucideBan,
LucideEllipsisVertical,
LucideExternalLink,
LucidePencil
} from '@lucide/svelte';
import { mode } from 'mode-watcher';
let {
authorizedClient,
onRevoke
}: {
authorizedClient: AuthorizedOidcClient;
onRevoke: (client: OidcClientMetaData) => Promise<void>;
} = $props();
const isLightMode = $derived(mode.current === 'light');
</script>
<Card.Root
class="border-muted group h-[140px] p-5 transition-all duration-200 hover:shadow-md"
data-testid="authorized-oidc-client-card"
>
<Card.Content class=" p-0">
<div class="flex gap-3">
<div class="aspect-square h-[56px]">
<ImageBox
class="grow rounded-lg object-contain"
src={authorizedClient.client.hasLogo
? cachedOidcClientLogo.getUrl(authorizedClient.client.id)
: cachedApplicationLogo.getUrl(isLightMode)}
alt={m.name_logo({ name: authorizedClient.client.name })}
/>
</div>
<div class="flex w-full justify-between gap-3">
<div>
<div class="mb-1 flex items-start gap-2">
<h3
class="text-foreground line-clamp-2 leading-tight font-semibold break-words break-all text-ellipsis"
>
{authorizedClient.client.name}
</h3>
</div>
{#if authorizedClient.client.launchURL}
<p
class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis"
>
{new URL(authorizedClient.client.launchURL).hostname}
</p>
{/if}
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<LucideEllipsisVertical class="size-4" />
<span class="sr-only">{m.toggle_menu()}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => goto(`/settings/admin/oidc-clients/${authorizedClient.client.id}`)}
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
>
{#if $userStore?.isAdmin}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
onclick={() => onRevoke(authorizedClient.client)}
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</div>
<div class="mt-2 flex justify-end">
<Button
href={authorizedClient.client.launchURL}
target="_blank"
size="sm"
class="h-8 text-xs"
disabled={!authorizedClient.client.launchURL}
>
{m.launch()}
<LucideExternalLink class="ml-1 size-3" />
</Button>
</div>
</Card.Content>
</Card.Root>
<style>
</style>