mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-30 12:39:03 +01:00
feat: add ability to deselect scopes on authorization
This commit is contained in:
@@ -1,19 +1,23 @@
|
||||
<script lang="ts">
|
||||
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
|
||||
import * as Item from '#lib/components/ui/item/index.js';
|
||||
import { cn } from '#lib/utils/style.js';
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
icon: LucideIcon;
|
||||
name: string;
|
||||
description: string;
|
||||
selected?: boolean;
|
||||
onSelectedChange?: (selected: boolean) => void;
|
||||
}
|
||||
|
||||
let { icon, name, description }: Props = $props();
|
||||
let { icon, name, description, selected = true, onSelectedChange }: Props = $props();
|
||||
|
||||
const SvelteComponent = $derived(icon);
|
||||
</script>
|
||||
|
||||
<Item.Root class="px-0 py-1.5">
|
||||
{#snippet content()}
|
||||
<Item.Media class="bg-muted h-full !translate-y-0 !self-center rounded-lg p-2">
|
||||
<SvelteComponent class="size-5" />
|
||||
</Item.Media>
|
||||
@@ -21,4 +25,30 @@
|
||||
<Item.Title class="font-semibold">{name}</Item.Title>
|
||||
<Item.Description>{description}</Item.Description>
|
||||
</Item.Content>
|
||||
</Item.Root>
|
||||
{/snippet}
|
||||
|
||||
{#if onSelectedChange}
|
||||
<!-- The whole row is a label so clicking anywhere on it toggles the scope -->
|
||||
<Item.Root class="px-0 py-1.5">
|
||||
{#snippet child({ props })}
|
||||
<label
|
||||
{...props}
|
||||
class={cn(props.class as string, 'cursor-pointer', !selected && 'opacity-60')}
|
||||
data-testid="scope-item"
|
||||
>
|
||||
{@render content()}
|
||||
<Item.Actions>
|
||||
<Checkbox
|
||||
aria-label={name}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked: boolean) => onSelectedChange(checked)}
|
||||
/>
|
||||
</Item.Actions>
|
||||
</label>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
{:else}
|
||||
<Item.Root class="px-0 py-1.5">
|
||||
{@render content()}
|
||||
</Item.Root>
|
||||
{/if}
|
||||
|
||||
@@ -7,23 +7,49 @@
|
||||
|
||||
let {
|
||||
scopes,
|
||||
scopeInfo = []
|
||||
}: { scopes?: string[] | null; scopeInfo?: InteractionScopeInfo[] | null } = $props();
|
||||
scopeInfo = [],
|
||||
selectedScopes = $bindable()
|
||||
}: {
|
||||
scopes?: string[] | null;
|
||||
scopeInfo?: InteractionScopeInfo[] | null;
|
||||
// When bound, every displayed scope gets a checkbox so the user can deselect it
|
||||
selectedScopes?: string[];
|
||||
} = $props();
|
||||
|
||||
const standardScopes = ['openid', 'profile', 'email', 'groups', 'offline_access'];
|
||||
const infoByKey = $derived(new Map((scopeInfo || []).map((info) => [info.key, info])));
|
||||
const customScopes = $derived((scopes || []).filter((scope) => !standardScopes.includes(scope)));
|
||||
|
||||
function selectionProps(scope: string) {
|
||||
if (!selectedScopes) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
selected: selectedScopes.includes(scope),
|
||||
onSelectedChange: (selected: boolean) => {
|
||||
const others = (selectedScopes ?? []).filter((s) => s !== scope);
|
||||
selectedScopes = selected ? [...others, scope] : others;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<Item.Group data-testid="scopes" class="gap-1">
|
||||
{#if (scopes || []).includes('email')}
|
||||
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
|
||||
<ScopeItem
|
||||
icon={LucideMail}
|
||||
name={m.email()}
|
||||
description={m.view_your_email_address()}
|
||||
{...selectionProps('email')}
|
||||
/>
|
||||
{/if}
|
||||
{#if (scopes || []).includes('profile')}
|
||||
<ScopeItem
|
||||
icon={LucideUser}
|
||||
name={m.profile()}
|
||||
description={m.view_your_profile_information()}
|
||||
{...selectionProps('profile')}
|
||||
/>
|
||||
{/if}
|
||||
{#if (scopes || []).includes('groups')}
|
||||
@@ -31,6 +57,7 @@
|
||||
icon={LucideUsers}
|
||||
name={m.groups()}
|
||||
description={m.view_the_groups_you_are_a_member_of()}
|
||||
{...selectionProps('groups')}
|
||||
/>
|
||||
{/if}
|
||||
{#each customScopes as scope (scope)}
|
||||
@@ -38,6 +65,7 @@
|
||||
icon={LucideKeyRound}
|
||||
name={infoByKey.get(scope)?.name ?? scope}
|
||||
description={infoByKey.get(scope)?.description || m.access_an_api_on_your_behalf()}
|
||||
{...selectionProps(scope)}
|
||||
/>
|
||||
{/each}
|
||||
</Item.Group>
|
||||
|
||||
@@ -27,10 +27,14 @@ class OidcService extends APIService {
|
||||
return data;
|
||||
};
|
||||
|
||||
completeAuthorizeInteractionStep = async (id: string, step: InteractionStep) => {
|
||||
completeAuthorizeInteractionStep = async (
|
||||
id: string,
|
||||
step: InteractionStep,
|
||||
scopes?: string[]
|
||||
) => {
|
||||
const { data } = await this.api.post<CompleteInteractionResponse>(
|
||||
`/oidc/interactions/${id}/complete`,
|
||||
{ step }
|
||||
{ step, scopes }
|
||||
);
|
||||
return data;
|
||||
};
|
||||
@@ -116,8 +120,8 @@ class OidcService extends APIService {
|
||||
return res.data as OidcClientWithAllowedUserGroups;
|
||||
};
|
||||
|
||||
verifyDeviceCode = async (userCode: string) => {
|
||||
return await this.api.post(`/oidc/device/verify?code=${userCode}`);
|
||||
verifyDeviceCode = async (userCode: string, scopes?: string[]) => {
|
||||
return await this.api.post(`/oidc/device/verify?code=${userCode}`, { scopes });
|
||||
};
|
||||
|
||||
getDeviceCodeInfo = async (userCode: string): Promise<OidcDeviceCodeInfo> => {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
let deviceLoginDecision: 'approve' | 'deny' | undefined = $state();
|
||||
let errorMessage: string | null = $state(null);
|
||||
let authorizationRequired = $state(false);
|
||||
let selectedScopes: string[] = $state([]);
|
||||
let reauthenticationRequired = $state(false);
|
||||
let reauthenticated = $state(false);
|
||||
let normalizedUserCode = $derived(
|
||||
@@ -79,6 +80,8 @@
|
||||
|
||||
if (info.authorizationRequired && !authorizationRequired) {
|
||||
authorizationRequired = true;
|
||||
// Every requested scope starts selected so the user only has to deselect the ones they do not want to grant
|
||||
selectedScopes = [...(info.scope ?? [])];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,7 +95,10 @@
|
||||
reauthenticated = true;
|
||||
}
|
||||
|
||||
await oidcService.verifyDeviceCode(normalizedUserCode);
|
||||
await oidcService.verifyDeviceCode(
|
||||
normalizedUserCode,
|
||||
authorizationRequired ? selectedScopes : undefined
|
||||
);
|
||||
success = true;
|
||||
} catch (error) {
|
||||
errorMessage = getWebauthnErrorMessage(error);
|
||||
@@ -236,7 +242,11 @@
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content data-testid="scopes">
|
||||
<ScopeList scopes={deviceInfo!.scope || []} scopeInfo={deviceInfo!.scopeInfo || []} />
|
||||
<ScopeList
|
||||
scopes={deviceInfo!.scope || []}
|
||||
scopeInfo={deviceInfo!.scopeInfo || []}
|
||||
bind:selectedScopes
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
let errorMessage: string | null = $state(null);
|
||||
let errorCode: string | undefined = $state();
|
||||
let currentStep = $derived(interactionSession.currentStep);
|
||||
// Every requested scope starts selected so the user only has to deselect the ones they do not want to grant
|
||||
let selectedScopes = $state([...(data.interactionSession.scopes ?? [])]);
|
||||
|
||||
const fullName = $derived.by(() => {
|
||||
if (!$userStore) {
|
||||
@@ -93,7 +95,11 @@
|
||||
}
|
||||
|
||||
async function completeInteraction(step: InteractionStep, skipRedirect = false) {
|
||||
const result = await oidcService.completeAuthorizeInteractionStep(interactionSession.id, step);
|
||||
const result = await oidcService.completeAuthorizeInteractionStep(
|
||||
interactionSession.id,
|
||||
step,
|
||||
step === 'consent' ? selectedScopes : undefined
|
||||
);
|
||||
if (result.interaction) {
|
||||
interactionSession = result.interaction;
|
||||
if (interactionSession.currentStep == 'reauthenticate') {
|
||||
@@ -193,6 +199,7 @@
|
||||
<ScopeList
|
||||
scopes={interactionSession.scopes || []}
|
||||
scopeInfo={interactionSession.scopeInfo ?? []}
|
||||
bind:selectedScopes
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
Reference in New Issue
Block a user