feat: add settings for limiting what passkeys can be used (#1662)

Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
Elias Schneider
2026-08-07 11:17:38 +02:00
committed by GitHub
co-authored by Alessandro Segala
parent 448d271c94
commit d6a47243db
22 changed files with 521 additions and 57 deletions
+2 -2
View File
@@ -271,10 +271,10 @@
<Button
class="flex-1"
variant="secondary"
disabled={isLoading}
isLoading={deviceLoginDecision === 'deny' || isLoading}
onclick={() => decideDeviceLogin('deny')}
>
{#if deviceLoginDecision === 'deny'}<Spinner data-icon="inline-start" />{/if}
<Spinner data-icon="inline-start" />
{m.deny()}
</Button>
<Button class="flex-1" {isLoading} onclick={() => decideDeviceLogin('approve')}>
@@ -13,6 +13,7 @@
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
import AppConfigPasskeysForm from './forms/app-config-passkeys-form.svelte';
import AppConfigSignupDefaultsForm from './forms/app-config-signup-defaults-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
@@ -105,6 +106,9 @@
<Tabs.Trigger value="user-creation">
{m.user_creation()}
</Tabs.Trigger>
<Tabs.Trigger value="passkeys">
{m.passkeys()}
</Tabs.Trigger>
<Tabs.Trigger value="email">
{m.email()}
</Tabs.Trigger>
@@ -114,13 +118,10 @@
<Tabs.Trigger value="oidc">
{m.oidc()}
</Tabs.Trigger>
<Tabs.Trigger value="images">
{m.images()}
</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content value="general" id="application-configuration-general">
<Tabs.Content value="general" id="application-configuration-general" class="flex flex-col gap-4">
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
@@ -129,6 +130,16 @@
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
<Card.Root id="application-configuration-images">
<Card.Header>
<Card.Title>{m.images()}</Card.Title>
<Card.Description>{m.configure_application_images()}</Card.Description>
</Card.Header>
<Card.Content>
<UpdateApplicationImages callback={updateImages} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="user-creation" id="application-configuration-signup-defaults">
@@ -143,6 +154,18 @@
</Card.Root>
</Tabs.Content>
<Tabs.Content value="passkeys" id="application-configuration-passkeys">
<Card.Root>
<Card.Header>
<Card.Title>{m.passkeys()}</Card.Title>
<Card.Description>{m.configure_passkey_settings()}</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigPasskeysForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="email" id="application-configuration-email">
<Card.Root>
<Card.Header>
@@ -180,16 +203,4 @@
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="images" id="application-configuration-images">
<Card.Root>
<Card.Header>
<Card.Title>{m.images()}</Card.Title>
<Card.Description>{m.configure_application_images()}</Card.Description>
</Card.Header>
<Card.Content>
<UpdateApplicationImages callback={updateImages} />
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
@@ -1,5 +1,6 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import UrlListInput from '$lib/components/form/url-list-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
@@ -16,7 +17,9 @@
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let cimdUrlAllowlist: string[] = $derived(appConfig.cimdUrlAllowlist || []);
let cimdUrlAllowlist: string[] = $derived(
appConfig.cimdUrlAllowlist?.length ? appConfig.cimdUrlAllowlist : ['']
);
let isLoading = $state(false);
async function onSubmit() {
@@ -31,10 +34,14 @@
}
</script>
{#snippet cimdUrlAllowlistDescription()}
<FormattedMessage message={m.cimd_url_allowlist_description} />
{/snippet}
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<FormInput label={m.cimd_url_allowlist()} description={m.cimd_url_allowlist_description()}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" />
<FormInput label={m.cimd_url_allowlist()} description={cimdUrlAllowlistDescription}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" keepAtLeastOne />
</FormInput>
<div class="flex justify-end pt-2">
@@ -0,0 +1,175 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod/v4';
let {
callback,
appConfig
}: {
appConfig: AllAppConfig;
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
const formSchema = z.object({
webauthnUserVerification: z.enum(['required', 'preferred']),
webauthnAllowSyncedPasskeys: z.boolean(),
webauthnAuthenticatorAttachment: z.enum(['any', 'platform', 'cross-platform'])
});
const initialConfig = {
webauthnUserVerification: appConfig.webauthnUserVerification,
webauthnAllowSyncedPasskeys: appConfig.webauthnAllowSyncedPasskeys,
webauthnAuthenticatorAttachment: appConfig.webauthnAuthenticatorAttachment
};
const userVerificationOptions = {
required: {
label: m.user_verification_required(),
description: m.user_verification_required_description()
},
preferred: {
label: m.user_verification_preferred(),
description: m.user_verification_preferred_description()
}
};
const authenticatorAttachmentOptions = {
any: {
label: m.any_authenticator(),
description: m.any_authenticator_description()
},
platform: {
label: m.device_passkeys_only(),
description: m.device_passkeys_only_description()
},
'cross-platform': {
label: m.external_security_keys_only(),
description: m.external_security_keys_only_description()
}
};
let { inputs, ...form } = $derived(createForm(formSchema, initialConfig));
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
try {
await callback(data);
toast.success(m.passkey_configuration_updated_successfully());
} finally {
isLoading = false;
}
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
<Field.Group>
<Field.Field data-invalid={!!$inputs.webauthnUserVerification.error}>
<div>
<Field.Label for="passkey-user-verification">{m.user_verification()}</Field.Label>
<Field.Description>{m.user_verification_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnUserVerification.value}
onValueChange={(value) =>
($inputs.webauthnUserVerification.value = value as 'required' | 'preferred')}
>
<Select.Trigger
id="passkey-user-verification"
class="w-full"
aria-label={m.user_verification()}
aria-invalid={!!$inputs.webauthnUserVerification.error}
placeholder={m.user_verification()}
>
{userVerificationOptions[$inputs.webauthnUserVerification.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(userVerificationOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Content>
<div>
<Field.Label for="allow-synced-passkeys">{m.allow_synced_passkeys()}</Field.Label>
<Field.Description>{m.allow_synced_passkeys_description()}</Field.Description>
</div>
</Field.Content>
<Switch
id="allow-synced-passkeys"
class="my-auto"
bind:checked={$inputs.webauthnAllowSyncedPasskeys.value}
/>
</Field.Field>
<Field.Field data-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}>
<div>
<Field.Label for="passkey-authenticator-type"
>{m.allowed_authenticator_type()}</Field.Label
>
<Field.Description>{m.allowed_authenticator_type_description()}</Field.Description>
</div>
<Select.Root
type="single"
value={$inputs.webauthnAuthenticatorAttachment.value}
onValueChange={(value) =>
($inputs.webauthnAuthenticatorAttachment.value = value as
'any' | 'platform' | 'cross-platform')}
>
<Select.Trigger
id="passkey-authenticator-type"
class="w-full"
aria-label={m.allowed_authenticator_type()}
aria-invalid={!!$inputs.webauthnAuthenticatorAttachment.error}
placeholder={m.allowed_authenticator_type()}
>
{authenticatorAttachmentOptions[$inputs.webauthnAuthenticatorAttachment.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each Object.entries(authenticatorAttachmentOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal" class="justify-end">
<Button type="submit" disabled={$appConfigStore.uiConfigDisabled} {isLoading}>
{m.save()}
</Button>
</Field.Field>
</Field.Group>
</fieldset>
</form>
@@ -59,10 +59,12 @@
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<div class="grid gap-2">
<Field.Field>
<Field.Label for="enable-user-signup">{m.enable_user_signups()}</Field.Label>
<Field.Description>
{m.enable_user_signups_description()}
</Field.Description>
<div>
<Field.Label for="enable-user-signup">{m.enable_user_signups()}</Field.Label>
<Field.Description>
{m.enable_user_signups_description()}
</Field.Description>
</div>
<Select.Root
type="single"
value={allowUserSignups}
@@ -107,17 +109,21 @@
</div>
<Field.Field>
<Field.Label for="default-groups">{m.user_groups()}</Field.Label>
<Field.Description>
{m.user_creation_groups_description()}
</Field.Description>
<div>
<Field.Label for="default-groups">{m.user_groups()}</Field.Label>
<Field.Description>
{m.user_creation_groups_description()}
</Field.Description>
</div>
<UserGroupInput bind:selectedGroupIds />
</Field.Field>
<Field.Field>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
<div>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
</div>
<CustomClaimsInput bind:customClaims />
</Field.Field>