mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-10 21:54:19 +00:00
feat: user application dashboard (#727)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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())
|
||||
});
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
121
frontend/src/routes/settings/apps/+page.svelte
Normal file
121
frontend/src/routes/settings/apps/+page.svelte
Normal 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>
|
||||
22
frontend/src/routes/settings/apps/+page.ts
Normal file
22
frontend/src/routes/settings/apps/+page.ts
Normal 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 };
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user