1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 13:21:45 +00:00
Files
pocket-id/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte
Robert Mang 0cb039d35d feat: add option to OIDC client to require re-authentication (#747)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 08:56:40 +02:00

214 lines
6.4 KiB
Svelte

<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import ImageBox from '$lib/components/image-box.svelte';
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from '@lucide/svelte';
import { slide } from 'svelte/transition';
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,
existingClient
}: {
existingClient?: OidcClient;
callback: (user: OidcClientCreateWithLogo) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
let showAdvancedOptions = $state(false);
let logo = $state<File | null | undefined>();
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? cachedOidcClientLogo.getUrl(existingClient!.id) : null
);
const client = {
name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [],
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false,
requiresReauthentication: existingClient?.requiresReauthentication || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
}
};
const formSchema = z.object({
name: z.string().min(2).max(50),
callbackURLs: z.array(z.string().nonempty()).default([]),
logoutCallbackURLs: z.array(z.string().nonempty()),
isPublic: z.boolean(),
pkceEnabled: z.boolean(),
requiresReauthentication: z.boolean(),
launchURL: optionalUrl,
credentials: z.object({
federatedIdentities: z.array(
z.object({
issuer: z.url(),
subject: z.string().optional(),
audience: z.string().optional(),
jwks: z.url().optional().or(z.literal(''))
})
)
})
});
type FormSchema = typeof formSchema;
const { inputs, errors, ...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;
}
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
return errors?.issues
.filter((e) => e.path[0] == 'credentials' && e.path[1] == 'federatedIdentities')
.map((e) => {
e.path.splice(0, 2);
return e;
});
}
</script>
<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"
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()}
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
/>
<OidcCallbackUrlInput
label={m.logout_callback_urls()}
description={m.logout_callback_url_description()}
class="w-full"
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error}
/>
<SwitchWithLabel
id="public-client"
label={m.public_client()}
description={m.public_clients_description()}
bind:checked={$inputs.isPublic.value}
/>
<SwitchWithLabel
id="pkce"
label={m.pkce()}
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
bind:checked={$inputs.pkceEnabled.value}
/>
<SwitchWithLabel
id="requires-reauthentication"
label={m.requires_reauthentication()}
description={m.requires_users_to_authenticate_again_on_each_authorization()}
bind:checked={$inputs.requiresReauthentication.value}
/>
</div>
<div class="mt-8">
<Label for="logo">{m.logo()}</Label>
<div class="mt-2 flex items-end gap-3">
{#if logoDataURL}
<ImageBox
class="size-24"
src={logoDataURL}
alt={m.name_logo({ name: $inputs.name.value })}
/>
{/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">
{logoDataURL ? m.change_logo() : m.upload_logo()}
</Button>
</FileInput>
{#if logoDataURL}
<Button variant="outline" onclick={resetLogo}>{m.remove_logo()}</Button>
{/if}
</div>
</div>
</div>
{#if showAdvancedOptions}
<div class="mt-5 md:col-span-2" transition:slide={{ duration: 200 }}>
<FederatedIdentitiesInput
client={existingClient}
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
/>
</div>
{/if}
<div class="relative mt-5 flex justify-center">
<Button
variant="ghost"
class="text-muted-foregroun"
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
>
{showAdvancedOptions ? m.hide_advanced_options() : m.show_advanced_options()}
<LucideChevronDown
class={cn(
'size-5 transition-transform duration-200',
showAdvancedOptions && 'rotate-180 transform'
)}
/>
</Button>
<Button {isLoading} type="submit" class="absolute right-0">{m.save()}</Button>
</div>
</form>