mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-29 22:39:05 +02:00
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:
co-authored by
Alessandro Segala
parent
448d271c94
commit
d6a47243db
@@ -8,13 +8,24 @@
|
||||
urls = $bindable(),
|
||||
error = null,
|
||||
testIdPrefix = 'url',
|
||||
disabled = false
|
||||
disabled = false,
|
||||
keepAtLeastOne = false
|
||||
}: {
|
||||
urls: string[];
|
||||
error?: string | null;
|
||||
testIdPrefix?: string;
|
||||
disabled?: boolean;
|
||||
keepAtLeastOne?: boolean;
|
||||
} = $props();
|
||||
|
||||
function removeUrl(index: number) {
|
||||
if (keepAtLeastOne && urls.length === 1) {
|
||||
urls = [''];
|
||||
return;
|
||||
}
|
||||
|
||||
urls = urls.filter((_, urlIndex) => urlIndex !== index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -34,7 +45,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={m.remove_url({ identifier: url || i + 1 })}
|
||||
onclick={() => (urls = urls.filter((_, index) => index !== i))}
|
||||
onclick={() => removeUrl(i)}
|
||||
{disabled}
|
||||
>
|
||||
<LucideMinus class="size-4" />
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{preventScroll}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
'px-1 py-2 text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 min-w-36 rounded-3xl shadow-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 isolate z-50 overflow-x-hidden overflow-y-auto animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!',
|
||||
'p-1.5 text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 dark:ring-foreground/10 min-w-36 rounded-2xl shadow-lg ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 isolate z-50 overflow-x-hidden overflow-y-auto animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
<SelectPrimitive.Group
|
||||
bind:ref
|
||||
data-slot="select-group"
|
||||
class={cn('scroll-my-1.5 p-1.5', className)}
|
||||
class={cn('scroll-my-1.5', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-2xl py-1.5 pr-8 pl-3 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 focus:bg-accent data-highlighted:bg-accent data-highlighted:text-accent-foreground focus:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2.5 rounded-xl py-1.5 pr-8 pl-3 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 focus:bg-accent data-highlighted:bg-accent data-highlighted:text-accent-foreground focus:text-accent-foreground relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -52,6 +52,10 @@ export type AllAppConfig = AppConfig & {
|
||||
ldapAttributeGroupName: string;
|
||||
ldapAdminGroupName: string;
|
||||
ldapSoftDeleteUsers: boolean;
|
||||
// WebAuthn
|
||||
webauthnUserVerification: 'required' | 'preferred';
|
||||
webauthnAllowSyncedPasskeys: boolean;
|
||||
webauthnAuthenticatorAttachment: 'any' | 'platform' | 'cross-platform';
|
||||
// OIDC
|
||||
cimdUrlAllowlist: string[];
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ const codeMessages: Record<string, () => string> = {
|
||||
invalid_webauthn_response: () => m.passkey_response_invalid(),
|
||||
webauthn_authentication_failed: () => m.passkey_verification_failed(),
|
||||
passkey_user_verification_required: () => m.passkey_user_verification_required(),
|
||||
synced_passkey_not_allowed: () => m.synced_passkeys_not_allowed(),
|
||||
device_login_expired: () => m.device_login_request_expired()
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+10
-3
@@ -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">
|
||||
|
||||
+175
@@ -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>
|
||||
+18
-12
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user