1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-10 11:59:16 +00:00

feat: display all accessible oidc clients in the dashboard (#832)

Co-authored-by: Kyle Mendell <ksm@ofkm.us>
This commit is contained in:
Elias Schneider
2025-08-17 22:47:34 +02:00
committed by GitHub
parent 3fa2f9a162
commit 3188e92257
28 changed files with 306 additions and 110 deletions

View File

@@ -1,5 +1,5 @@
import type {
AuthorizedOidcClient,
AccessibleOidcClient,
AuthorizeResponse,
OidcClient,
OidcClientCreate,
@@ -115,18 +115,12 @@ class OidcService extends APIService {
return response.data;
}
async listAuthorizedClients(options?: SearchPaginationSortRequest) {
async listOwnAccessibleClients(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>;
return res.data as Paginated<AccessibleOidcClient>;
}
async revokeOwnAuthorizedClient(clientId: string) {

View File

@@ -4,9 +4,9 @@ import { writable } from 'svelte/store';
const userStore = writable<User | null>(null);
const setUser = (user: User) => {
const setUser = async (user: User) => {
if (user.locale) {
setLocale(user.locale, false);
await setLocale(user.locale, false);
}
userStore.set(user);
};

View File

@@ -53,7 +53,6 @@ export type AuthorizeResponse = {
issuer: string;
};
export type AuthorizedOidcClient = {
scope: string;
client: OidcClientMetaData;
export type AccessibleOidcClient = OidcClientMetaData & {
lastUsedAt: Date | null;
};

View File

@@ -1,10 +1,26 @@
import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime';
import { setDefaultOptions } from 'date-fns';
import { z } from 'zod/v4';
export function setLocale(locale: Locale, reload = true) {
import(`../../../node_modules/zod/v4/locales/${locale}.js`)
.then((zodLocale) => z.config(zodLocale.default()))
.finally(() => {
setParaglideLocale(locale, { reload });
export async function setLocale(locale: Locale, reload = true) {
const [zodResult, dateFnsResult] = await Promise.allSettled([
import(`../../../node_modules/zod/v4/locales/${locale}.js`),
import(`../../../node_modules/date-fns/locale/${locale}.js`)
]);
if (zodResult.status === 'fulfilled') {
z.config(zodResult.value.default());
} else {
console.warn(`Failed to load zod locale for ${locale}:`, zodResult.reason);
}
setParaglideLocale(locale, { reload });
if (dateFnsResult.status === 'fulfilled') {
setDefaultOptions({
locale: dateFnsResult.value.default
});
} else {
console.warn(`Failed to load date-fns locale for ${locale}:`, dateFnsResult.reason);
}
}

View File

@@ -6,8 +6,6 @@
import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getAuthRedirectPath } from '$lib/utils/redirection-util';
import { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte';
@@ -28,14 +26,6 @@
if (redirectPath) {
goto(redirectPath);
}
if (user) {
userStore.setUser(user);
}
if (appConfig) {
appConfigStore.set(appConfig);
}
</script>
{#if !appConfig}

View File

@@ -1,5 +1,7 @@
import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { LayoutLoad } from './$types';
export const ssr = false;
@@ -19,6 +21,14 @@ export const load: LayoutLoad = async () => {
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
if (user) {
await userStore.setUser(user);
}
if (appConfig) {
appConfigStore.set(appConfig);
}
return {
user,
appConfig

View File

@@ -44,7 +44,7 @@
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
await userStore.setUser(user);
}
if (!authorizationConfirmed) {

View File

@@ -45,7 +45,7 @@
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
await userStore.setUser(user);
}
const info = await oidcService.getDeviceCodeInfo(userCode);

View File

@@ -23,7 +23,7 @@
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
await userStore.setUser(user);
goto('/settings');
} catch (e) {
error = getWebauthnErrorMessage(e);

View File

@@ -23,7 +23,7 @@
isLoading = true;
try {
const user = await userService.exchangeOneTimeAccessToken(code);
userStore.setUser(user);
await userStore.setUser(user);
try {
goto(data.redirect);

View File

@@ -31,7 +31,7 @@
...$userStore!,
locale
});
setLocale(locale);
await setLocale(locale);
}
</script>

View File

@@ -3,25 +3,25 @@
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 { AccessibleOidcClient, 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';
import AuthorizedOidcClientCard from './authorized-oidc-client-card.svelte';
let { data } = $props();
let authorizedClients: Paginated<AuthorizedOidcClient> = $state(data.authorizedClients);
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
const oidcService = new OIDCService();
async function onRefresh(options: SearchPaginationSortRequest) {
authorizedClients = await oidcService.listAuthorizedClients(options);
clients = await oidcService.listOwnAccessibleClients(options);
}
async function onPageChange(page: number) {
requestOptions.pagination = { limit: authorizedClients.pagination.itemsPerPage, page };
requestOptions.pagination = { limit: clients.pagination.itemsPerPage, page };
onRefresh(requestOptions);
}
@@ -64,7 +64,7 @@
</h1>
</div>
{#if authorizedClients.data.length === 0}
{#if clients.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">
@@ -76,20 +76,23 @@
</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} />
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));"
>
{#each clients.data as client}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
</div>
{#if authorizedClients.pagination.totalPages > 1}
{#if clients.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}
count={clients.pagination.totalItems}
perPage={clients.pagination.itemsPerPage}
{onPageChange}
page={authorizedClients.pagination.currentPage}
page={clients.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-center">
@@ -101,7 +104,7 @@
<Pagination.Item>
<Pagination.Link
{page}
isActive={authorizedClients.pagination.currentPage === page.value}
isActive={clients.pagination.currentPage === page.value}
>
{page.value}
</Pagination.Link>

View File

@@ -16,7 +16,7 @@ export const load: PageLoad = async () => {
}
};
const authorizedClients = await oidcService.listAuthorizedClients(appRequestOptions);
const clients = await oidcService.listOwnAccessibleClients(appRequestOptions);
return { authorizedClients, appRequestOptions };
return { clients, appRequestOptions };
};

View File

@@ -4,23 +4,26 @@
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 * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import {
LucideBan,
LucideEllipsisVertical,
LucideExternalLink,
LucideLogIn,
LucidePencil
} from '@lucide/svelte';
import { formatDistanceToNow } from 'date-fns';
import { mode } from 'mode-watcher';
let {
authorizedClient,
client,
onRevoke
}: {
authorizedClient: AuthorizedOidcClient;
client: AccessibleOidcClient;
onRevoke: (client: OidcClientMetaData) => Promise<void>;
} = $props();
@@ -28,7 +31,7 @@
</script>
<Card.Root
class="border-muted group h-[140px] p-5 transition-all duration-200 hover:shadow-md"
class="border-muted group relative h-[140px] p-5 transition-all duration-200 hover:shadow-md"
data-testid="authorized-oidc-client-card"
>
<Card.Content class=" p-0">
@@ -36,60 +39,84 @@
<div class="aspect-square h-[56px]">
<ImageBox
class="grow rounded-lg object-contain"
src={authorizedClient.client.hasLogo
? cachedOidcClientLogo.getUrl(authorizedClient.client.id)
src={client.hasLogo
? cachedOidcClientLogo.getUrl(client.id)
: cachedApplicationLogo.getUrl(isLightMode)}
alt={m.name_logo({ name: authorizedClient.client.name })}
alt={m.name_logo({ name: 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"
class="text-foreground line-clamp-2 text-ellipsis break-words break-all font-semibold leading-tight"
>
{authorizedClient.client.name}
{client.name}
</h3>
</div>
{#if authorizedClient.client.launchURL}
{#if client.launchURL}
<p
class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis"
class="text-muted-foreground line-clamp-1 text-ellipsis break-words break-all text-xs"
>
{new URL(authorizedClient.client.launchURL).hostname}
{new URL(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">
{#if $userStore?.isAdmin}
<DropdownMenu.Item
onclick={() => goto(`/settings/admin/oidc-clients/${authorizedClient.client.id}`)}
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
>
{/if}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
onclick={() => onRevoke(authorizedClient.client)}
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{#if $userStore?.isAdmin || client.lastUsedAt}
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<LucideEllipsisVertical class="size-4" />
<span class="sr-only">{m.toggle_menu()}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if $userStore?.isAdmin}
<DropdownMenu.Item
onclick={() => goto(`/settings/admin/oidc-clients/${client.id}`)}
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
>
{/if}
{#if client.lastUsedAt}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
onclick={() => onRevoke(client)}
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
</div>
<div class="mt-2 flex justify-end">
<div class="mt-2 flex items-end justify-between">
{#if client.lastUsedAt}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<p class="text-muted-foreground flex items-center text-xs">
<LucideLogIn class="mr-1 size-3" />
{formatDistanceToNow(client.lastUsedAt, { addSuffix: true })}
</p>
</Tooltip.Trigger>
<Tooltip.Content
>{m.last_signed_in_ago({
time: formatDistanceToNow(client.lastUsedAt)
})}</Tooltip.Content
>
</Tooltip.Root></Tooltip.Provider
>
{:else}
<div></div>
{/if}
<Button
href={authorizedClient.client.launchURL}
href={client.launchURL}
target="_blank"
size="sm"
class="h-8 text-xs"
disabled={!authorizedClient.client.launchURL}
rel="noopener noreferrer"
disabled={!client.launchURL}
>
{m.launch()}
<LucideExternalLink class="ml-1 size-3" />

View File

@@ -32,7 +32,7 @@
return false;
}
userStore.setUser(result.data);
await userStore.setUser(result.data);
isLoading = false;
goto('/signup/add-passkey');

View File

@@ -30,7 +30,7 @@
return false;
}
userStore.setUser(result.data);
await userStore.setUser(result.data);
isLoading = false;
goto('/signup/add-passkey');