initial commit

This commit is contained in:
Elias Schneider
2024-08-12 11:00:25 +02:00
commit eaff977b22
241 changed files with 14378 additions and 0 deletions

View File

@@ -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;
};

View 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>

View File

@@ -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);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />