mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-31 03:36:36 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import OIDCService from '$lib/services/oidc-service';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const oidcService = new OIDCService(cookies.get('access_token'));
|
||||
const clients = await oidcService.listClients();
|
||||
return clients;
|
||||
};
|
||||
76
frontend/src/routes/settings/admin/oidc-clients/+page.svelte
Normal file
76
frontend/src/routes/settings/admin/oidc-clients/+page.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import OIDCService from '$lib/services/oidc-service';
|
||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
import { LucideMinus } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import OIDCClientForm from './oidc-client-form.svelte';
|
||||
import OIDCClientList from './oidc-client-list.svelte';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||
import { goto } from '$app/navigation';
|
||||
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
|
||||
|
||||
let { data } = $props();
|
||||
let clients = $state(data);
|
||||
let expandAddClient = $state(false);
|
||||
|
||||
const oidcService = new OIDCService();
|
||||
|
||||
async function createOIDCClient(client: OidcClientCreateWithLogo) {
|
||||
try {
|
||||
const createdClient = await oidcService.createClient(client);
|
||||
if(client.logo){
|
||||
await oidcService.updateClientLogo(createdClient, client.logo);
|
||||
}
|
||||
const clientSecret = await oidcService.createClientSecret(createdClient.id);
|
||||
clientSecretStore.set(clientSecret);
|
||||
goto(`/settings/admin/oidc-clients/${createdClient.id}`);
|
||||
toast.success('OIDC client created successfully');
|
||||
return true;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>OIDC Clients</title>
|
||||
</svelte:head>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>Create OIDC Client</Card.Title>
|
||||
<Card.Description>Add a new OIDC client to {$applicationConfigurationStore.appName}.</Card.Description>
|
||||
</div>
|
||||
{#if !expandAddClient}
|
||||
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button>
|
||||
{:else}
|
||||
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddClient = false)}>
|
||||
<LucideMinus class="h-5 w-5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if expandAddClient}
|
||||
<div transition:slide>
|
||||
<Card.Content>
|
||||
<OIDCClientForm callback={createOIDCClient} />
|
||||
</Card.Content>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Manage OIDC Clients</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<OIDCClientList {clients} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,7 @@
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const oidcService = new OidcService(cookies.get('access_token'));
|
||||
return await oidcService.getClient(params.id);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideChevronLeft, LucideRefreshCcw } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import OidcForm from '../oidc-client-form.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let client = $state(data);
|
||||
|
||||
const oidcService = new OidcService();
|
||||
|
||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||
let success = true;
|
||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
||||
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
||||
|
||||
await Promise.all([dataPromise, imagePromise])
|
||||
.then(() => {
|
||||
toast.success('OIDC client updated successfully');
|
||||
})
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
success = false;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
async function createClientSecret() {
|
||||
openConfirmDialog({
|
||||
title: 'Create new client secret',
|
||||
message:
|
||||
'Are you sure you want to create a new client secret? The old one will be invalidated.',
|
||||
confirm: {
|
||||
label: 'Generate',
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
const clientSecret = await oidcService.createClientSecret(client.id);
|
||||
clientSecretStore.set(clientSecret);
|
||||
toast.success('New client secret created successfully');
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
clientSecretStore.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>OIDC Client {client.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
|
||||
><LucideChevronLeft class="h-5 w-5" /> Back</a
|
||||
>
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{client.name}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex">
|
||||
<Label class="mb-0 w-44">Client ID</Label>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center">
|
||||
<Label class="mb-0 w-44">Client secret</Label>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
|
||||
>
|
||||
{#if !$clientSecretStore}
|
||||
<Button
|
||||
class="ml-2"
|
||||
onclick={createClientSecret}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Content class="p-5">
|
||||
<OidcForm existingClient={client} callback={updateClient} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
|
||||
let {
|
||||
oneTimeLink = $bindable()
|
||||
}: {
|
||||
oneTimeLink: string | null;
|
||||
} = $props();
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
oneTimeLink = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>One Time Link</Dialog.Title>
|
||||
<Dialog.Description
|
||||
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
|
||||
have lost it.</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
<Label for="one-time-link">One Time Link</Label>
|
||||
<Input id="one-time-link" value={oneTimeLink} readonly />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import FileInput from '$lib/components/file-input.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import type {
|
||||
OidcClient,
|
||||
OidcClientCreate,
|
||||
OidcClientCreateWithLogo
|
||||
} from '$lib/types/oidc.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
|
||||
let {
|
||||
callback,
|
||||
existingClient
|
||||
}: {
|
||||
existingClient?: OidcClient;
|
||||
callback: (user: OidcClientCreateWithLogo) => Promise<boolean>;
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let logo = $state<File | null>(null);
|
||||
let logoDataURL: string | null = $state(
|
||||
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
|
||||
);
|
||||
|
||||
const client: OidcClientCreate = {
|
||||
name: existingClient?.name || '',
|
||||
callbackURL: existingClient?.callbackURL || ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2).max(50),
|
||||
callbackURL: z.string().url()
|
||||
});
|
||||
|
||||
type FormSchema = typeof formSchema;
|
||||
const { inputs, ...form } = createForm<FormSchema>(formSchema, client);
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
isLoading = true;
|
||||
const success = await callback({
|
||||
...data,
|
||||
logo
|
||||
});
|
||||
// Reset form if client was successfully created
|
||||
if (success && !existingClient) form.reset();
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function onLogoChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0] || null;
|
||||
if (file) {
|
||||
logo = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
logoDataURL = event.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function resetLogo() {
|
||||
logo = null;
|
||||
logoDataURL = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<FormInput label="Name" bind:input={$inputs.name} />
|
||||
<FormInput label="Callback URL" bind:input={$inputs.callbackURL} />
|
||||
<div class="mt-3">
|
||||
<Label for="logo">Logo</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#if logoDataURL}
|
||||
<div class="h-32 w-32 rounded-2xl bg-muted p-3">
|
||||
<img class="m-auto max-h-full max-w-full object-contain" src={logoDataURL} alt={`${$inputs.name.value} logo`} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileInput
|
||||
id="logo"
|
||||
variant="secondary"
|
||||
accept="image/png, image/jpeg, image/svg+xml"
|
||||
onchange={onLogoChange}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
|
||||
</Button>
|
||||
</FileInput>
|
||||
{#if logoDataURL}
|
||||
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full"></div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button {isLoading} type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import OIDCService from '$lib/services/oidc-service';
|
||||
import type { OidcClient } from '$lib/types/oidc.type';
|
||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucidePencil, LucideTrash } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import OneTimeLinkModal from './client-secret.svelte';
|
||||
|
||||
let { clients: initialClients }: { clients: Paginated<OidcClient> } = $props();
|
||||
let clients = $state<Paginated<OidcClient>>(initialClients);
|
||||
let oneTimeLink = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
clients = initialClients;
|
||||
});
|
||||
|
||||
const oidcService = new OIDCService();
|
||||
|
||||
let pagination = $state<PaginationRequest>({
|
||||
page: 1,
|
||||
limit: 10
|
||||
});
|
||||
let search = $state('');
|
||||
|
||||
async function deleteClient(client: OidcClient) {
|
||||
openConfirmDialog({
|
||||
title: `Delete ${client.name}`,
|
||||
message: 'Are you sure you want to delete this OIDC client?',
|
||||
confirm: {
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await oidcService.removeClient(client.id);
|
||||
clients = await oidcService.listClients(search, pagination);
|
||||
toast.success('OIDC client deleted successfully');
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search clients"
|
||||
bind:value={search}
|
||||
on:input={async (e) =>
|
||||
(clients = await oidcService.listClients((e.target as HTMLInputElement).value, pagination))}
|
||||
/>
|
||||
<Table.Root>
|
||||
<Table.Header class="sr-only">
|
||||
<Table.Row>
|
||||
<Table.Head>Logo</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if clients.data.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-center">No OIDC clients found</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each clients.data as client}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-8 font-medium">
|
||||
{#if client.hasLogo}
|
||||
<div class="h-8 w-8">
|
||||
<img
|
||||
class="m-auto max-h-full max-w-full object-contain"
|
||||
src="/api/oidc/clients/{client.id}/logo"
|
||||
alt="{client.name} logo"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{client.name}</Table.Cell>
|
||||
<Table.Cell class="flex justify-end gap-1">
|
||||
<Button
|
||||
href="/settings/admin/oidc-clients/{client.id}"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button
|
||||
>
|
||||
<Button
|
||||
on:click={() => deleteClient(client)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Delete"><LucideTrash class="h-3 w-3 text-red-500" /></Button
|
||||
>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
{#if clients?.data?.length ?? 0 > 0}
|
||||
<Pagination.Root
|
||||
class="mt-5"
|
||||
count={clients.pagination.totalItems}
|
||||
perPage={pagination.limit}
|
||||
onPageChange={async (p) =>
|
||||
(clients = await oidcService.listClients(search, {
|
||||
page: p,
|
||||
limit: pagination.limit
|
||||
}))}
|
||||
bind:page={clients.pagination.currentPage}
|
||||
let:pages
|
||||
let:currentPage
|
||||
>
|
||||
<Pagination.Content class="flex justify-end">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={clients.pagination.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
</Pagination.Root>
|
||||
{/if}
|
||||
|
||||
<OneTimeLinkModal {oneTimeLink} />
|
||||
Reference in New Issue
Block a user