mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-20 18:09:05 +02:00
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
co-authored by
Elias Schneider
parent
7c55bdf115
commit
1934efa84c
@@ -6,7 +6,7 @@ import ConfirmDialog from './confirm-dialog.svelte';
|
||||
interface ConfirmDialogState {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string | AnyFormattedMessage;
|
||||
message: string | AnyFormattedMessage;
|
||||
confirm: {
|
||||
label: string;
|
||||
destructive: boolean;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { LucideMinus, LucidePlus } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
urls = $bindable(),
|
||||
error = null,
|
||||
testIdPrefix = 'url',
|
||||
disabled = false
|
||||
}: {
|
||||
urls: string[];
|
||||
error?: string | null;
|
||||
testIdPrefix?: string;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{#each urls as url, i (i)}
|
||||
<div class="flex gap-x-2">
|
||||
<Input
|
||||
aria-invalid={!!error}
|
||||
data-testid={`${testIdPrefix}-${i + 1}`}
|
||||
type="text"
|
||||
inputmode="url"
|
||||
autocomplete="url"
|
||||
bind:value={urls[i]}
|
||||
{disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={m.remove_url({ identifier: url || i + 1 })}
|
||||
onclick={() => (urls = urls.filter((_, index) => index !== i))}
|
||||
{disabled}
|
||||
>
|
||||
<LucideMinus class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => (urls = [...urls, ''])}
|
||||
{disabled}
|
||||
>
|
||||
<LucidePlus class="mr-1 size-4" />
|
||||
{urls.length === 0 ? m.add() : m.add_another()}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils/style.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -13,7 +13,7 @@
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn('text-muted-foreground text-sm mt-3', className)}
|
||||
class={cn('text-muted-foreground text-sm mt-1', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ClientApiAccess
|
||||
} from '$lib/types/api.type';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import { encodeClientIdParam } from '$lib/utils/client-id-util';
|
||||
import APIService from './api-service';
|
||||
|
||||
export default class ApisService extends APIService {
|
||||
@@ -44,12 +45,12 @@ export default class ApisService extends APIService {
|
||||
};
|
||||
|
||||
getClientAccess = async (clientId: string) => {
|
||||
const res = await this.api.get(`/api-access/${clientId}`);
|
||||
const res = await this.api.get(`/api-access/${encodeClientIdParam(clientId)}`);
|
||||
return res.data as ClientApiAccess;
|
||||
};
|
||||
|
||||
updateClientAccess = async (clientId: string, access: ClientApiAccess) => {
|
||||
const res = await this.api.put(`/api-access/${clientId}`, access);
|
||||
const res = await this.api.put(`/api-access/${encodeClientIdParam(clientId)}`, access);
|
||||
return res.data as ClientApiAccess;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '$lib/types/oidc.type';
|
||||
import type { ScimServiceProvider } from '$lib/types/scim.type';
|
||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||
import { encodeClientIdParam } from '$lib/utils/client-id-util';
|
||||
import APIService from './api-service';
|
||||
|
||||
class OidcService extends APIService {
|
||||
@@ -41,17 +42,23 @@ class OidcService extends APIService {
|
||||
(await this.api.post('/oidc/clients', client)).data as OidcClient;
|
||||
|
||||
removeClient = async (id: string) => {
|
||||
await this.api.delete(`/oidc/clients/${id}`);
|
||||
await this.api.delete(`/oidc/clients/${encodeClientIdParam(id)}`);
|
||||
};
|
||||
|
||||
getClient = async (id: string) =>
|
||||
(await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
|
||||
(await this.api.get(`/oidc/clients/${encodeClientIdParam(id)}`))
|
||||
.data as OidcClientWithAllowedUserGroups;
|
||||
|
||||
getClientMetaData = async (id: string) =>
|
||||
(await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData;
|
||||
(await this.api.get(`/oidc/clients/${encodeClientIdParam(id)}/meta`))
|
||||
.data as OidcClientMetaData;
|
||||
|
||||
updateClient = async (id: string, client: OidcClientUpdate) =>
|
||||
(await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
|
||||
(await this.api.put(`/oidc/clients/${encodeClientIdParam(id)}`, client)).data as OidcClient;
|
||||
|
||||
refreshClient = async (id: string) =>
|
||||
(await this.api.post(`/oidc/clients/${encodeClientIdParam(id)}/refresh`))
|
||||
.data as OidcClientWithAllowedUserGroups;
|
||||
|
||||
updateClientLogo = async (client: OidcClient, image: File | null, light: boolean = true) => {
|
||||
const hasLogo = light ? client.hasLogo : client.hasDarkLogo;
|
||||
@@ -67,24 +74,26 @@ class OidcService extends APIService {
|
||||
const formData = new FormData();
|
||||
formData.append('file', image!);
|
||||
|
||||
await this.api.post(`/oidc/clients/${client.id}/logo`, formData, {
|
||||
await this.api.post(`/oidc/clients/${encodeClientIdParam(client.id)}/logo`, formData, {
|
||||
params: { light }
|
||||
});
|
||||
cachedOidcClientLogo.bustCache(client.id, light);
|
||||
};
|
||||
|
||||
removeClientLogo = async (id: string, light: boolean = true) => {
|
||||
await this.api.delete(`/oidc/clients/${id}/logo`, {
|
||||
await this.api.delete(`/oidc/clients/${encodeClientIdParam(id)}/logo`, {
|
||||
params: { light }
|
||||
});
|
||||
cachedOidcClientLogo.bustCache(id, light);
|
||||
};
|
||||
|
||||
createClientSecret = async (id: string) =>
|
||||
(await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string;
|
||||
(await this.api.post(`/oidc/clients/${encodeClientIdParam(id)}/secret`)).data.secret as string;
|
||||
|
||||
updateAllowedUserGroups = async (id: string, userGroupIds: string[]) => {
|
||||
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
|
||||
const res = await this.api.put(`/oidc/clients/${encodeClientIdParam(id)}/allowed-user-groups`, {
|
||||
userGroupIds
|
||||
});
|
||||
return res.data as OidcClientWithAllowedUserGroups;
|
||||
};
|
||||
|
||||
@@ -98,9 +107,12 @@ class OidcService extends APIService {
|
||||
};
|
||||
|
||||
getClientPreview = async (id: string, userId: string, scopes: string) => {
|
||||
const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, {
|
||||
params: { scopes }
|
||||
});
|
||||
const response = await this.api.get(
|
||||
`/oidc/clients/${encodeClientIdParam(id)}/preview/${userId}`,
|
||||
{
|
||||
params: { scopes }
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -110,11 +122,13 @@ class OidcService extends APIService {
|
||||
};
|
||||
|
||||
revokeOwnAuthorizedClient = async (clientId: string) => {
|
||||
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
|
||||
await this.api.delete(`/oidc/users/me/authorized-clients/${encodeClientIdParam(clientId)}`);
|
||||
};
|
||||
|
||||
getScimResourceProvider = async (clientId: string) => {
|
||||
const res = await this.api.get(`/oidc/clients/${clientId}/scim-service-provider`);
|
||||
const res = await this.api.get(
|
||||
`/oidc/clients/${encodeClientIdParam(clientId)}/scim-service-provider`
|
||||
);
|
||||
return res.data as ScimServiceProvider;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ export type AllAppConfig = AppConfig & {
|
||||
ldapAttributeGroupName: string;
|
||||
ldapAdminGroupName: string;
|
||||
ldapSoftDeleteUsers: boolean;
|
||||
// OIDC
|
||||
cimdUrlAllowlist: string[];
|
||||
};
|
||||
|
||||
export type AppConfigRawResponse = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { UserGroup } from './user-group.type';
|
||||
|
||||
export type OidcClientType = 'standard' | 'cimd';
|
||||
|
||||
export type OidcClientMetaData = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,6 +10,7 @@ export type OidcClientMetaData = {
|
||||
hasDarkLogo: boolean;
|
||||
requiresReauthentication: boolean;
|
||||
launchURL?: string;
|
||||
clientType: OidcClientType;
|
||||
};
|
||||
|
||||
export type OidcClientFederatedIdentity = {
|
||||
@@ -55,7 +58,7 @@ export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
|
||||
|
||||
export type OidcClientUpdate = Omit<
|
||||
OidcClient,
|
||||
'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported'
|
||||
'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported' | 'clientType'
|
||||
>;
|
||||
export type OidcClientCreate = OidcClientUpdate & {
|
||||
id?: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { encodeClientIdParam } from './client-id-util';
|
||||
|
||||
type SkipCacheUntil = {
|
||||
[key: string]: number;
|
||||
};
|
||||
@@ -56,12 +58,18 @@ export const cachedProfilePicture: CachableImage = {
|
||||
|
||||
export const cachedOidcClientLogo: CachableImage = {
|
||||
getUrl: (clientId: string, light = true) => {
|
||||
const url = new URL(`/api/oidc/clients/${clientId}/logo`, window.location.origin);
|
||||
const url = new URL(
|
||||
`/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`,
|
||||
window.location.origin
|
||||
);
|
||||
if (!light) url.searchParams.set('light', 'false');
|
||||
return getCachedImageUrl(url);
|
||||
},
|
||||
bustCache: (clientId: string, light = true) => {
|
||||
const url = new URL(`/api/oidc/clients/${clientId}/logo`, window.location.origin);
|
||||
const url = new URL(
|
||||
`/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`,
|
||||
window.location.origin
|
||||
);
|
||||
if (!light) url.searchParams.set('light', 'false');
|
||||
bustImageCache(url);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Raw pocket-id client IDs match this pattern and need no encoding.
|
||||
const RAW_CLIENT_ID = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* Encodes a client ID for use as a path segment.
|
||||
*
|
||||
* CIMD client IDs are full https URLs containing slashes and colons, which
|
||||
* cannot be carried in a single path segment. Such IDs are encoded as
|
||||
* `~<base64url>`; the backend decodes them. Plain client IDs are unchanged.
|
||||
*/
|
||||
export function encodeClientIdParam(id: string): string {
|
||||
if (RAW_CLIENT_ID.test(id)) {
|
||||
return id;
|
||||
}
|
||||
const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(id)));
|
||||
const base64url = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
return '~' + base64url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses {@link encodeClientIdParam}. Decodes a `~<base64url>` value back to the
|
||||
* real client ID; returns plain values unchanged.
|
||||
*/
|
||||
export function decodeClientIdParam(param: string): string {
|
||||
if (!param.startsWith('~')) {
|
||||
return param;
|
||||
}
|
||||
try {
|
||||
const base64 = param.slice(1).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(base64);
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return param;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the host of a CIMD client's Client Identifier URL, or null for an administrator-configured client.
|
||||
*
|
||||
*/
|
||||
export function getClientIDHost(client: { id: string; clientType?: string }): string | null {
|
||||
if (client.clientType !== 'cimd') return null;
|
||||
try {
|
||||
return new URL(client.id).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { DeviceLoginVerificationInfo } from '$lib/types/device-login.type';
|
||||
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
|
||||
import { getClientIDHost } from '$lib/utils/client-id-util';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
@@ -209,7 +210,7 @@
|
||||
<FormattedMessage
|
||||
message={m.do_you_want_to_sign_in_to_client_with_your_app_name_account}
|
||||
inputs={{
|
||||
client: deviceInfo.client.name,
|
||||
client: getClientIDHost(deviceInfo!.client) ?? deviceInfo!.client.name,
|
||||
appName: $appConfigStore.appName
|
||||
}}
|
||||
/>
|
||||
@@ -221,9 +222,7 @@
|
||||
<Card.Description class="text-start">
|
||||
<FormattedMessage
|
||||
message={m.client_wants_to_access_the_following_information}
|
||||
inputs={{
|
||||
client: deviceInfo!.client.name
|
||||
}}
|
||||
inputs={{ client: getClientIDHost(deviceInfo!.client) ?? deviceInfo!.client.name }}
|
||||
/>
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { InteractionStep } from '$lib/types/oidc.type';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
import { getClientIDHost } from '$lib/utils/client-id-util';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { slide } from 'svelte/transition';
|
||||
@@ -121,13 +122,15 @@
|
||||
{:else if currentStep == 'select_account' && $userStore}
|
||||
<FormattedMessage
|
||||
message={m.account_selection_signin_confirmation}
|
||||
inputs={{ name: interactionSession.client.name }}
|
||||
inputs={{
|
||||
name: getClientIDHost(interactionSession.client) ?? interactionSession.client.name
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<FormattedMessage
|
||||
message={m.do_you_want_to_sign_in_to_client_with_your_app_name_account}
|
||||
inputs={{
|
||||
client: interactionSession.client.name,
|
||||
client: getClientIDHost(interactionSession.client) ?? interactionSession.client.name,
|
||||
appName: $appConfigStore.appName
|
||||
}}
|
||||
/>
|
||||
@@ -178,7 +181,7 @@
|
||||
<FormattedMessage
|
||||
message={m.client_wants_to_access_the_following_information}
|
||||
inputs={{
|
||||
client: interactionSession.client.name
|
||||
client: getClientIDHost(interactionSession.client) ?? interactionSession.client.name
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
title: m.login_code(),
|
||||
description: m.enter_a_login_code_to_sign_in(),
|
||||
href: '/login/alternative/code'
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideInfo } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import AppConfigDynamicClientsForm from './forms/app-config-dynamic-clients-form.svelte';
|
||||
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';
|
||||
@@ -110,6 +111,9 @@
|
||||
<Tabs.Trigger value="ldap">
|
||||
{m.ldap()}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="oidc">
|
||||
{m.oidc()}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="images">
|
||||
{m.images()}
|
||||
</Tabs.Trigger>
|
||||
@@ -165,6 +169,18 @@
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="oidc" id="application-configuration-oidc">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.client_id_metadata_documents()}</Card.Title>
|
||||
<Card.Description>{m.client_id_metadata_documents_description()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<AppConfigDynamicClientsForm {appConfig} callback={updateAppConfig} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="images" id="application-configuration-images">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import UrlListInput from '$lib/components/form/url-list-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
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 { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
appConfig,
|
||||
callback
|
||||
}: {
|
||||
appConfig: AllAppConfig;
|
||||
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let cimdUrlAllowlist: string[] = $derived(appConfig.cimdUrlAllowlist || []);
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function onSubmit() {
|
||||
isLoading = true;
|
||||
|
||||
const update: Partial<AllAppConfig> = {
|
||||
cimdUrlAllowlist: cimdUrlAllowlist.filter((u) => u.trim() !== '')
|
||||
};
|
||||
|
||||
await callback(update).finally(() => (isLoading = false));
|
||||
toast.success(m.application_configuration_updated_successfully());
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -7,6 +7,7 @@
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
import { encodeClientIdParam } from '$lib/utils/client-id-util';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideMinus, ShieldCheck, ShieldPlus } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -20,6 +21,7 @@
|
||||
|
||||
async function createOIDCClient(client: OidcClientCreateWithLogo) {
|
||||
try {
|
||||
clientSecretStore.clear();
|
||||
const createdClient = await oidcService.createClient(client);
|
||||
|
||||
const logoPromise = client.logo
|
||||
@@ -30,9 +32,11 @@
|
||||
: Promise.resolve();
|
||||
await Promise.all([logoPromise, darkLogoPromise]);
|
||||
|
||||
const clientSecret = await oidcService.createClientSecret(createdClient.id);
|
||||
clientSecretStore.set(clientSecret);
|
||||
goto(`/settings/admin/oidc-clients/${createdClient.id}`);
|
||||
if (!createdClient.isPublic) {
|
||||
const clientSecret = await oidcService.createClientSecret(createdClient.id);
|
||||
clientSecretStore.set(clientSecret);
|
||||
}
|
||||
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(createdClient.id)}`);
|
||||
toast.success(m.oidc_client_created_successfully());
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -232,6 +232,16 @@
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if client.clientType === 'cimd'}
|
||||
<Alert.Root variant="info">
|
||||
<LucideInfo class="size-4" />
|
||||
<Alert.Title>{m.cimd_client_managed_fields_title()}</Alert.Title>
|
||||
<Alert.Description>
|
||||
{m.cimd_client_managed_fields_description()}
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import type { OidcDiscoveryConfiguration } from '$lib/types/oidc.type';
|
||||
import { decodeClientIdParam } from '$lib/utils/client-id-util';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const oidcService = new OidcService();
|
||||
const id = decodeClientIdParam(params.id);
|
||||
|
||||
const clientPromise = oidcService.getClient(params.id);
|
||||
const clientPromise = oidcService.getClient(id);
|
||||
const scimServiceProviderPromise = oidcService
|
||||
.getScimResourceProvider(params.id)
|
||||
.getScimResourceProvider(id)
|
||||
.then((p) => p)
|
||||
.catch(() => undefined);
|
||||
const oidcConfigurationPromise = fetch('/.well-known/openid-configuration').then(
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
let {
|
||||
federatedIdentities = $bindable([]),
|
||||
errors,
|
||||
disabled = false,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
federatedIdentities: OidcClientFederatedIdentity[];
|
||||
errors?: z.core.$ZodIssue[];
|
||||
disabled?: boolean;
|
||||
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
@@ -62,6 +64,7 @@
|
||||
label={m.federated_client_credentials()}
|
||||
description={m.federated_client_credentials_description()}
|
||||
docsLink="https://pocket-id.org/docs/guides/oidc-client-authentication"
|
||||
{disabled}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#each federatedIdentities as identity, i (identity)}
|
||||
@@ -74,6 +77,7 @@
|
||||
size="sm"
|
||||
onclick={() => removeFederatedIdentity(i)}
|
||||
aria-label="Remove federated identity"
|
||||
{disabled}
|
||||
>
|
||||
<LucideMinus class="size-4" />
|
||||
</Button>
|
||||
@@ -89,6 +93,7 @@
|
||||
value={identity.issuer}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'issuer', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'issuer')}
|
||||
{disabled}
|
||||
/>
|
||||
{#if getFieldError(i, 'issuer')}
|
||||
<Field.Error>{getFieldError(i, 'issuer')}</Field.Error>
|
||||
@@ -103,6 +108,7 @@
|
||||
value={identity.subject || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'subject')}
|
||||
{disabled}
|
||||
/>
|
||||
{#if getFieldError(i, 'subject')}
|
||||
<Field.Error>{getFieldError(i, 'subject')}</Field.Error>
|
||||
@@ -117,6 +123,7 @@
|
||||
value={identity.audience || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'audience', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'audience')}
|
||||
{disabled}
|
||||
/>
|
||||
{#if getFieldError(i, 'audience')}
|
||||
<Field.Error>{getFieldError(i, 'audience')}</Field.Error>
|
||||
@@ -131,6 +138,7 @@
|
||||
value={identity.jwks || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'jwks', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'jwks')}
|
||||
{disabled}
|
||||
/>
|
||||
{#if getFieldError(i, 'jwks')}
|
||||
<Field.Error>{getFieldError(i, 'jwks')}</Field.Error>
|
||||
@@ -142,6 +150,7 @@
|
||||
description={m.replay_protection_description()}
|
||||
checked={identity.replayProtection}
|
||||
onCheckedChange={(checked) => updateFederatedIdentity(i, 'replayProtection', checked)}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,7 +158,14 @@
|
||||
</div>
|
||||
</FormInput>
|
||||
|
||||
<Button class="mt-3" variant="secondary" size="sm" onclick={addFederatedIdentity} type="button">
|
||||
<Button
|
||||
class="mt-3"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={addFederatedIdentity}
|
||||
type="button"
|
||||
{disabled}
|
||||
>
|
||||
<LucidePlus class="mr-1 size-4" />
|
||||
{federatedIdentities.length === 0
|
||||
? m.add_federated_client_credential()
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import UrlListInput from '$lib/components/form/url-list-input.svelte';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { LucideMinus, LucidePlus } from '@lucide/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -13,50 +10,23 @@
|
||||
description,
|
||||
callbackURLs = $bindable(),
|
||||
error = $bindable(null),
|
||||
disabled = false,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
label: string;
|
||||
description: string | Snippet;
|
||||
callbackURLs: string[];
|
||||
error?: string | null;
|
||||
disabled?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div {...restProps}>
|
||||
<FormInput {label} {description}>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{#each callbackURLs.keys() as i (i)}
|
||||
<div class="flex gap-x-2">
|
||||
<Input
|
||||
aria-invalid={!!error}
|
||||
data-testid={`callback-url-${i + 1}`}
|
||||
type="text"
|
||||
inputmode="url"
|
||||
autocomplete="url"
|
||||
bind:value={callbackURLs[i]}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (callbackURLs = callbackURLs.filter((_, index) => index !== i))}
|
||||
>
|
||||
<LucideMinus class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<FormInput {label} {description} {disabled}>
|
||||
<UrlListInput bind:urls={callbackURLs} {error} {disabled} testIdPrefix="callback-url" />
|
||||
</FormInput>
|
||||
{#if error}
|
||||
<Field.Error>{error}</Field.Error>
|
||||
{/if}
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => (callbackURLs = [...callbackURLs, ''])}
|
||||
>
|
||||
<LucidePlus class="mr-1 size-4" />
|
||||
{callbackURLs.length === 0 ? m.add() : m.add_another()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
let darkLogoDataURL: string | null = $state(
|
||||
existingClient?.hasDarkLogo ? cachedOidcClientLogo.getUrl(existingClient!.id, false) : null
|
||||
);
|
||||
const isCIMDClient = $derived(existingClient?.clientType === 'cimd');
|
||||
|
||||
const client = {
|
||||
id: '',
|
||||
@@ -202,6 +203,7 @@
|
||||
class="w-full"
|
||||
description={m.client_name_description()}
|
||||
bind:input={$inputs.name}
|
||||
disabled={isCIMDClient}
|
||||
/>
|
||||
<FormInput
|
||||
label={m.client_description()}
|
||||
@@ -222,6 +224,7 @@
|
||||
class="w-full"
|
||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||
bind:error={$inputs.callbackURLs.error}
|
||||
disabled={isCIMDClient}
|
||||
/>
|
||||
<OidcCallbackUrlInput
|
||||
label={m.logout_callback_urls()}
|
||||
@@ -229,6 +232,7 @@
|
||||
class="w-full"
|
||||
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
|
||||
bind:error={$inputs.logoutCallbackURLs.error}
|
||||
disabled={isCIMDClient}
|
||||
/>
|
||||
<div>
|
||||
<SwitchWithLabel
|
||||
@@ -241,6 +245,7 @@
|
||||
}
|
||||
}}
|
||||
bind:checked={$inputs.isPublic.value}
|
||||
disabled={isCIMDClient}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -252,7 +257,7 @@
|
||||
id="pkce"
|
||||
label={m.pkce()}
|
||||
description={m.proof_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
|
||||
disabled={$inputs.isPublic.value}
|
||||
disabled={isCIMDClient || $inputs.isPublic.value}
|
||||
bind:checked={$inputs.pkceEnabled.value}
|
||||
/>
|
||||
</div>
|
||||
@@ -334,6 +339,7 @@
|
||||
<FederatedIdentitiesInput
|
||||
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
|
||||
errors={getFederatedIdentityErrors($errors)}
|
||||
disabled={isCIMDClient}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
} from '$lib/types/advanced-table.type';
|
||||
import type { OidcClient, OidcClientWithAllowedUserGroupsCount } from '$lib/types/oidc.type';
|
||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||
import { encodeClientIdParam } from '$lib/utils/client-id-util';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||
import { LucidePencil, LucideRefreshCcw, LucideTrash } from '@lucide/svelte';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
@@ -30,6 +31,11 @@
|
||||
{ label: m.no(), value: false }
|
||||
];
|
||||
|
||||
const clientTypeFilterValues = [
|
||||
{ label: m.client_type_standard(), value: 'standard' },
|
||||
{ label: m.client_type_metadata_document(), value: 'cimd' }
|
||||
];
|
||||
|
||||
const columns: AdvancedTableColumn<OidcClientWithAllowedUserGroupsCount>[] = [
|
||||
{ label: 'ID', column: 'id', hidden: true },
|
||||
{ label: m.logo(), key: 'logo', cell: LogoCell },
|
||||
@@ -46,6 +52,14 @@
|
||||
sortable: true,
|
||||
filterableValues: booleanFilterValues
|
||||
},
|
||||
{
|
||||
label: m.client_type(),
|
||||
column: 'clientType',
|
||||
sortable: true,
|
||||
filterableValues: clientTypeFilterValues,
|
||||
value: (item) =>
|
||||
item.clientType === 'cimd' ? m.client_type_metadata_document() : m.client_type_standard()
|
||||
},
|
||||
{
|
||||
label: m.pkce(),
|
||||
column: 'pkceEnabled',
|
||||
@@ -79,12 +93,18 @@
|
||||
}
|
||||
];
|
||||
|
||||
const actions: CreateAdvancedTableActions<OidcClientWithAllowedUserGroupsCount> = () => [
|
||||
const actions: CreateAdvancedTableActions<OidcClientWithAllowedUserGroupsCount> = (client) => [
|
||||
{
|
||||
label: m.edit(),
|
||||
primary: true,
|
||||
icon: LucidePencil,
|
||||
onClick: (client) => goto(`/settings/admin/oidc-clients/${client.id}`)
|
||||
onClick: (client) => goto(`/settings/admin/oidc-clients/${encodeClientIdParam(client.id)}`)
|
||||
},
|
||||
{
|
||||
label: m.refresh(),
|
||||
icon: LucideRefreshCcw,
|
||||
hidden: client.clientType !== 'cimd',
|
||||
onClick: (client) => refreshClient(client)
|
||||
},
|
||||
{
|
||||
label: m.delete(),
|
||||
@@ -94,6 +114,16 @@
|
||||
}
|
||||
];
|
||||
|
||||
async function refreshClient(client: OidcClient) {
|
||||
try {
|
||||
await oidcService.refreshClient(client.id);
|
||||
await refresh();
|
||||
toast.success(m.oidc_client_metadata_refreshed_successfully());
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(client: OidcClient) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_name({ name: client.name }),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
|
||||
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||
import { encodeClientIdParam } from '$lib/utils/client-id-util';
|
||||
import {
|
||||
LucideBan,
|
||||
LucideEllipsisVertical,
|
||||
@@ -79,7 +80,8 @@
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if $userStore?.isAdmin}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => goto(`/settings/admin/oidc-clients/${client.id}`)}
|
||||
onclick={() =>
|
||||
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(client.id)}`)}
|
||||
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
|
||||
>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user