diff --git a/packages/showcase/src/stories/AccessPicker.stories.ts b/packages/showcase/src/stories/AccessPicker.stories.ts new file mode 100644 index 00000000..df3d15ee --- /dev/null +++ b/packages/showcase/src/stories/AccessPicker.stories.ts @@ -0,0 +1,175 @@ +import { + AccessPickerComponent, + type AccessOrg, + type AccessPreset, + type AccessProduct, + type AccessSelection, +} from '@ferrlabs/ui-ng'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { moduleMetadata } from '@storybook/angular'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; + +const orgs: readonly AccessOrg[] = [ + { id: 'org-1', name: 'Acme', slug: 'acme' }, + { id: 'org-2', name: 'Globex', slug: 'globex' }, +]; + +const catalogue: readonly AccessProduct[] = [ + { + product: 'flow', + label: 'FerrFlow', + scopes: [ + { scope: 'flow:read', access: 'read' }, + { scope: 'flow:write', access: 'write' }, + { scope: 'flow:admin', access: 'admin' }, + ], + }, + { + product: 'vault', + label: 'FerrVault', + scopes: [ + { scope: 'vault:read', access: 'read' }, + { scope: 'vault:write', access: 'write' }, + ], + }, +]; + +const presets: readonly AccessPreset[] = [ + { id: 'read_only', scopes: ['flow:read', 'vault:read'] }, + { id: 'read_write', scopes: ['flow:read', 'flow:write', 'vault:read', 'vault:write'] }, + { + id: 'full', + scopes: ['flow:read', 'flow:write', 'flow:admin', 'vault:read', 'vault:write'], + }, +]; + +const meta: Meta = { + title: 'Forms/Access picker', + component: AccessPickerComponent, + decorators: [moduleMetadata({ imports: [AccessPickerComponent] })], + args: { orgs, catalogue, presets, initialScopes: [] }, + render: (args) => { + const output = { text: 'pending' }; + return { + props: { + ...args, + output, + report: (value: AccessSelection | null) => (output.text = JSON.stringify(value)), + }, + template: ` +
{{ output.text }}
`, + }; + }, +}; + +export default meta; +type Story = StoryObj; + +async function expectLast( + canvasElement: HTMLElement, + expected: AccessSelection | null, +): Promise { + await waitFor(() => + expect(within(canvasElement).getByTestId('selection')).toHaveTextContent( + JSON.stringify(expected), + ), + ); +} + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expectLast(canvasElement, { + orgIds: null, + scopes: ['flow:read', 'vault:read'], + }); + await waitFor(() => + expect(canvas.getByRole('button', { name: 'Read only' })).toHaveAttribute( + 'aria-pressed', + 'true', + ), + ); + }, +}; + +export const InitialScopes: Story = { + args: { initialScopes: ['flow:write', 'unknown:scope'] }, + play: async ({ canvasElement }) => { + await expectLast(canvasElement, { + orgIds: null, + scopes: ['flow:write'], + }); + }, +}; + +export const OnlyUnknownInitialScopesFallBackToReadOnly: Story = { + args: { initialScopes: ['unknown:scope'] }, + play: async ({ canvasElement }) => { + await expectLast(canvasElement, { + orgIds: null, + scopes: ['flow:read', 'vault:read'], + }); + }, +}; + +export const ChosenOrgsNeedAtLeastOne: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('radio', { name: 'Only these organizations' })); + await expectLast(canvasElement, null); + await expect(await canvas.findByText('Pick at least one organization.')).toBeVisible(); + + await userEvent.click(canvas.getByRole('checkbox', { name: /Globex/ })); + await expectLast(canvasElement, { + orgIds: ['org-2'], + scopes: ['flow:read', 'vault:read'], + }); + + await userEvent.click(canvas.getByRole('radio', { name: /All my organizations/ })); + await expectLast(canvasElement, { + orgIds: null, + scopes: ['flow:read', 'vault:read'], + }); + }, +}; + +export const PresetsAndManualToggles: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const pressed = (name: string, value: string) => + waitFor(() => + expect(canvas.getByRole('button', { name })).toHaveAttribute('aria-pressed', value), + ); + + await userEvent.click(canvas.getByRole('button', { name: 'Full access' })); + await expectLast(canvasElement, { + orgIds: null, + scopes: ['flow:admin', 'flow:read', 'flow:write', 'vault:read', 'vault:write'], + }); + await pressed('Full access', 'true'); + + await userEvent.click(canvas.getByRole('checkbox', { name: 'flow:admin' })); + await pressed('Read and write', 'true'); + + await userEvent.click(canvas.getByRole('checkbox', { name: 'vault:write' })); + for (const name of ['Read only', 'Read and write', 'Full access']) { + await pressed(name, 'false'); + } + }, +}; + +export const NoScopeEmitsNull: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('checkbox', { name: 'flow:read' })); + await userEvent.click(canvas.getByRole('checkbox', { name: 'vault:read' })); + await expectLast(canvasElement, null); + await expect(await canvas.findByText('Pick at least one permission.')).toBeVisible(); + }, +}; diff --git a/packages/ui-ng/src/lib/access-picker/access-picker.component.css b/packages/ui-ng/src/lib/access-picker/access-picker.component.css new file mode 100644 index 00000000..dc68141b --- /dev/null +++ b/packages/ui-ng/src/lib/access-picker/access-picker.component.css @@ -0,0 +1,137 @@ +:host { + display: block; +} + +.flr-ap__group { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.flr-ap__group + .flr-ap__group { + margin-top: 20px; +} + +.flr-ap__legend { + padding: 0; + color: var(--color-ink, #1e293b); + font-size: 14px; + font-weight: 600; +} + +.flr-ap__row { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 8px; + color: var(--color-ink-2, #334155); + font-size: 14px; + cursor: pointer; +} + +.flr-ap__row--center { + align-items: center; + margin-top: 0; +} + +.flr-ap__row input { + accent-color: var(--color-accent, #1e293b); +} + +.flr-ap__row input[type='radio'] { + margin-top: 4px; +} + +.flr-ap__row input:focus-visible { + outline: 2px solid var(--color-accent, #1e293b); + outline-offset: 2px; +} + +.flr-ap__orgs { + display: grid; + gap: 4px; + margin: 8px 0 0 24px; + padding: 0; + list-style: none; +} + +.flr-ap__slug, +.flr-ap__scope { + font-family: var(--font-mono, 'DM Mono', ui-monospace, monospace); +} + +.flr-ap__slug { + color: var(--color-ink-3, #64748b); + font-size: 12px; +} + +.flr-ap__presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.flr-ap__preset { + padding: 4px 12px; + border: 1px solid var(--color-rule-strong, rgba(30, 41, 59, 0.28)); + border-radius: 999px; + background: transparent; + color: var(--color-ink-2, #334155); + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.flr-ap__preset:focus-visible { + outline: 2px solid var(--color-accent, #1e293b); + outline-offset: 2px; +} + +.flr-ap__preset--on { + border-color: var(--color-accent, #1e293b); + background: var(--color-accent, #1e293b); + color: var(--color-on-accent, #ffffff); +} + +.flr-ap__products { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.flr-ap__product { + margin: 0; + color: var(--color-ink-3, #64748b); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.flr-ap__scopes { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin-top: 4px; +} + +.flr-ap__scope { + font-size: 14px; +} + +.flr-ap__scope--admin { + color: var(--color-warning-fg, #78350f); +} + +.flr-ap__warn { + margin: 8px 0 0; + color: var(--color-warning-fg, #78350f); + font-size: 12px; +} + +.flr-ap__warn--indented { + margin-left: 24px; +} diff --git a/packages/ui-ng/src/lib/access-picker/access-picker.component.html b/packages/ui-ng/src/lib/access-picker/access-picker.component.html new file mode 100644 index 00000000..0c59ca45 --- /dev/null +++ b/packages/ui-ng/src/lib/access-picker/access-picker.component.html @@ -0,0 +1,72 @@ +
+ {{ labels().orgsTitle }} + + + @if (!allOrgs()) { +
    + @for (org of orgs(); track org.id) { +
  • + +
  • + } +
+ @if (pickedOrgs().size === 0) { +

{{ labels().noOrgPicked }}

+ } + } +
+ +
+ {{ labels().scopesTitle }} +
+ @for (preset of presets(); track preset.id) { + + } +
+
+ @for (product of catalogue(); track product.product) { +
+

{{ product.label }}

+
+ @for (entry of product.scopes; track entry.scope) { + + } +
+
+ } +
+ @if (pickedScopes().size === 0) { +

{{ labels().noScopePicked }}

+ } +
diff --git a/packages/ui-ng/src/lib/access-picker/access-picker.component.ts b/packages/ui-ng/src/lib/access-picker/access-picker.component.ts new file mode 100644 index 00000000..d7d6d26c --- /dev/null +++ b/packages/ui-ng/src/lib/access-picker/access-picker.component.ts @@ -0,0 +1,93 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + linkedSignal, + output, + signal, +} from '@angular/core'; + +import { + ACCESS_PICKER_LABELS_EN, + buildAccessSelection, + initialScopeSet, + matchingPreset, + toggled, + type AccessOrg, + type AccessPickerLabels, + type AccessPreset, + type AccessPresetId, + type AccessProduct, + type AccessSelection, +} from './access-picker.model'; + +let nextId = 0; + +@Component({ + selector: 'flr-access-picker', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './access-picker.component.html', + styleUrl: './access-picker.component.css', +}) +export class AccessPickerComponent { + readonly orgs = input.required(); + readonly catalogue = input.required(); + readonly presets = input.required(); + readonly initialScopes = input([]); + readonly labels = input(ACCESS_PICKER_LABELS_EN); + + readonly selectionChange = output(); + + protected readonly groupName = `flr-access-picker-${nextId++}`; + protected readonly allOrgs = signal(true); + protected readonly pickedOrgs = signal>(new Set()); + protected readonly pickedScopes = linkedSignal>(() => + initialScopeSet(this.initialScopes(), this.catalogue(), this.presets()), + ); + + protected readonly activePreset = computed(() => + matchingPreset(this.presets(), this.pickedScopes()), + ); + + private readonly selection = computed(() => + buildAccessSelection(this.allOrgs(), this.pickedOrgs(), this.pickedScopes(), this.orgs()), + ); + + constructor() { + effect(() => this.selectionChange.emit(this.selection())); + } + + protected useAllOrgs(): void { + this.allOrgs.set(true); + } + + protected useChosenOrgs(): void { + this.allOrgs.set(false); + } + + protected toggleOrg(id: string): void { + this.pickedOrgs.update((current) => toggled(current, id)); + } + + protected toggleScope(scope: string): void { + this.pickedScopes.update((current) => toggled(current, scope)); + } + + protected applyPreset(preset: AccessPreset): void { + this.pickedScopes.set(new Set(preset.scopes)); + } + + protected presetLabel(id: AccessPresetId): string { + const labels = this.labels(); + switch (id) { + case 'read_only': + return labels.presetReadOnly; + case 'read_write': + return labels.presetReadWrite; + case 'full': + return labels.presetFull; + } + } +} diff --git a/packages/ui-ng/src/lib/access-picker/access-picker.model.ts b/packages/ui-ng/src/lib/access-picker/access-picker.model.ts new file mode 100644 index 00000000..1cc1e027 --- /dev/null +++ b/packages/ui-ng/src/lib/access-picker/access-picker.model.ts @@ -0,0 +1,109 @@ +export type AccessLevel = 'read' | 'write' | 'admin'; + +export type AccessPresetId = 'read_only' | 'read_write' | 'full'; + +export interface AccessOrg { + readonly id: string; + readonly name: string; + readonly slug: string; +} + +export interface AccessScope { + readonly scope: string; + readonly access: AccessLevel; +} + +export interface AccessProduct { + readonly product: string; + readonly label: string; + readonly scopes: readonly AccessScope[]; +} + +export interface AccessPreset { + readonly id: AccessPresetId; + readonly scopes: readonly string[]; +} + +export interface AccessSelection { + readonly orgIds: string[] | null; + readonly scopes: string[]; +} + +export interface AccessPickerLabels { + readonly orgsTitle: string; + readonly allOrgs: string; + readonly someOrgs: string; + readonly noOrgPicked: string; + readonly scopesTitle: string; + readonly presetsLabel: string; + readonly noScopePicked: string; + readonly presetReadOnly: string; + readonly presetReadWrite: string; + readonly presetFull: string; +} + +export const ACCESS_PICKER_LABELS_EN: AccessPickerLabels = { + orgsTitle: 'Organizations', + allOrgs: 'All my organizations, including future ones', + someOrgs: 'Only these organizations', + noOrgPicked: 'Pick at least one organization.', + scopesTitle: 'Permissions', + presetsLabel: 'Presets', + noScopePicked: 'Pick at least one permission.', + presetReadOnly: 'Read only', + presetReadWrite: 'Read and write', + presetFull: 'Full access', +}; + +export const ACCESS_PICKER_LABELS_FR: AccessPickerLabels = { + orgsTitle: 'Organisations', + allOrgs: 'Toutes mes organisations, y compris les futures', + someOrgs: 'Uniquement ces organisations', + noOrgPicked: 'Choisissez au moins une organisation.', + scopesTitle: 'Autorisations', + presetsLabel: 'Préréglages', + noScopePicked: 'Choisissez au moins une autorisation.', + presetReadOnly: 'Lecture seule', + presetReadWrite: 'Lecture et écriture', + presetFull: 'Accès complet', +}; + +export function buildAccessSelection( + allOrgs: boolean, + pickedOrgs: ReadonlySet, + pickedScopes: ReadonlySet, + orgs: readonly AccessOrg[], +): AccessSelection | null { + const scopes = [...pickedScopes].sort(); + const listed = new Set(orgs.map((o) => o.id)); + const orgIds = allOrgs ? null : [...pickedOrgs].filter((id) => listed.has(id)).sort(); + if (scopes.length === 0 || (orgIds !== null && orgIds.length === 0)) return null; + return { orgIds, scopes }; +} + +export function matchingPreset( + presets: readonly AccessPreset[], + picked: ReadonlySet, +): AccessPresetId | null { + const match = presets.find( + (p) => p.scopes.length === picked.size && p.scopes.every((s) => picked.has(s)), + ); + return match?.id ?? null; +} + +export function initialScopeSet( + initial: readonly string[], + catalogue: readonly AccessProduct[], + presets: readonly AccessPreset[], +): ReadonlySet { + const known = new Set(catalogue.flatMap((p) => p.scopes.map((s) => s.scope))); + const wanted = initial.filter((s) => known.has(s)); + const readOnly = presets.find((p) => p.id === 'read_only')?.scopes ?? []; + return new Set(wanted.length > 0 ? wanted : readOnly); +} + +export function toggled(current: ReadonlySet, value: string): ReadonlySet { + const next = new Set(current); + if (!next.delete(value)) next.add(value); + return next; +} diff --git a/packages/ui-ng/src/public-api.ts b/packages/ui-ng/src/public-api.ts index 4b341330..993b3c3a 100644 --- a/packages/ui-ng/src/public-api.ts +++ b/packages/ui-ng/src/public-api.ts @@ -232,3 +232,19 @@ export { WAITLIST_FORM_LABELS_FR, } from './lib/waitlist-form/waitlist-form.model'; export type { WaitlistFormLabels } from './lib/waitlist-form/waitlist-form.model'; + +export { AccessPickerComponent } from './lib/access-picker/access-picker.component'; +export { + ACCESS_PICKER_LABELS_EN, + ACCESS_PICKER_LABELS_FR, +} from './lib/access-picker/access-picker.model'; +export type { + AccessLevel, + AccessOrg, + AccessPickerLabels, + AccessPreset, + AccessPresetId, + AccessProduct, + AccessScope, + AccessSelection, +} from './lib/access-picker/access-picker.model';