From 19ee222db0da87db53fa215a77761dbc6b577ef0 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 13 Oct 2026 18:04:32 +1100 Subject: [PATCH 01/11] add error handling --- .../AccessControlModal.styles.css | 24 ++- .../AccessControlModal.ts | 133 +++++++++++-- src/components/access-control-modal/types.ts | 9 + test/components/access-control-modal.test.ts | 187 ++++++++++++++++++ 4 files changed, 335 insertions(+), 18 deletions(-) diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index 481d4679e..fe74dd890 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -236,6 +236,28 @@ } } + .access-control-save-errors { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding-left: 20px; + + li { + display: flex; + align-items: baseline; + gap: 6px; + color: var(--solid-ui-color-gray-700, #374151); + font-size: var(--solid-ui-font-size-sm, .875rem); + line-height: 1.4; + } + + .access-control-save-error-label { + flex: 0 0 auto; + font-weight: 500; + } + } + .access-grants-general { display: flex; flex-direction: column; @@ -387,4 +409,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index c71228661..2d7e66389 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -3,6 +3,7 @@ import { html, nothing } from 'lit' import type { PropertyValues } from 'lit' import { property, query, state } from 'lit/decorators.js' import { label } from '@/utils/label' +import { error } from '@/lib/debug' import { findImage } from '@/widgets' import type Dialog from '@/components/dialog' import { ACCESS_ROLES, Authenticated, DEFAULT_DIRECTORY_SOURCES, PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton, type AccessMode, type AccessRole, type AccessSubject, type Authorization, type DirectoryEntry } from 'solid-logic' @@ -22,10 +23,11 @@ import '@/components/dialog-footer' import '@/components/button' import '@/components/combobox' import '@/components/combobox-option' +import '@/components/error-state' import styles from './AccessControlModal.styles.css' import type { ComboboxChangeEvent } from '@/components/combobox' -import type { AccessChange, AccessControlBadge, AccessGrantEntry, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' +import type { AccessChange, AccessControlBadge, AccessGrantEntry, AccessSaveError, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, type StringComboboxOptionData } from './helpers' function isDefined (value: T | undefined | null): value is T { @@ -76,9 +78,18 @@ export default class AccessControlModal extends WebComponent { @state() private accessor accessGrantLabels: string[] = [] + @state() + private accessor saveErrors: AccessSaveError[] = [] + @query('solid-ui-dialog') private accessor dialog: Dialog | null = null + disconnectedCallback (): void { + this.saveErrors = [] + this.failed = false + super.disconnectedCallback() + } + protected willUpdate (changedProperties: PropertyValues) { super.willUpdate(changedProperties) @@ -143,6 +154,29 @@ export default class AccessControlModal extends WebComponent { return this.getAccessGrantEntries().filter(({ authorization }) => this.getAuthorizationSubjectIris(authorization).length > 0) } + private renderSaveErrorsList () { + if (!this.saveErrors.length) { + return nothing + } + + return html` +
    + ${this.saveErrors.map(error => html` +
  • + ${error.label} + + ${error.action === 'grant' + ? `Could not grant ${error.role ?? 'access'}` + : error.action === 'revoke' + ? `Could not revoke ${error.role ?? 'access'}` + : 'Could not save access changes'} + +
  • + `)} +
+ ` + } + private renderAccessGrants () { const query = this.searchValue.trim().toLowerCase() const accessGrants = this.getSharedAccessGrantEntries().filter(({ subjectLabel }) => { @@ -517,6 +551,17 @@ export default class AccessControlModal extends WebComponent { ${this.renderAddAccessForm()} ${this.renderAccessGrantsSection()} ${this.renderGeneralAccessSection()} + ${this.saveErrors.length + ? html` + + ${this.renderSaveErrorsList()} + + ` + : nothing} @@ -528,7 +573,7 @@ export default class AccessControlModal extends WebComponent { Cancel 0} ?loading=${this.submitting} type="button" @click=${this.onSaveClick} @@ -591,28 +636,58 @@ export default class AccessControlModal extends WebComponent { } this.submitting = true + this.saveErrors = [] this.failed = false try { const accessChanges: AccessChange[] = [ ...this.pendingAccessGrants.map(grant => ({ subject: this.createAccessSubject(grant.subjectType, grant.subjectValue), - role: grant.role + role: grant.role, + label: grant.label })), ...generalAccessChanges, ...changedAccessGrants.flatMap(({ subjects, role }) => - subjects.map(subject => ({ subject, role })) + subjects.map(subject => ({ + subject, + role, + label: this.getAccessSubjectLabel(subject) + })) ) ] - for (const { subject, role } of accessChanges) { - const plan = role === 'No Access' - ? await solidLogicSingleton.acl.planRevoke(this.subjectUri, subject) - : await solidLogicSingleton.acl.planGrant(this.subjectUri, subject, subject.type === 'agentClass' && subject.iri === Public.iri - ? solidLogicSingleton.acl.modesFromPublicRole(role as (typeof PUBLIC_ACCESS_ROLES)[number]) - : this.getRoleModes(role)) + const appliedChanges: AccessChange[] = [] - await solidLogicSingleton.acl.applyPlan(plan) + for (const { subject, label, role } of accessChanges) { + try { + const plan = role === 'No Access' + ? await solidLogicSingleton.acl.planRevoke(this.subjectUri, subject) + : await solidLogicSingleton.acl.planGrant(this.subjectUri, subject, subject.type === 'agentClass' && subject.iri === Public.iri + ? solidLogicSingleton.acl.modesFromPublicRole(role as (typeof PUBLIC_ACCESS_ROLES)[number]) + : this.getRoleModes(role)) + + await solidLogicSingleton.acl.applyPlan(plan) + appliedChanges.push({ subject, role, label }) + } catch (error) { + this.saveErrors = [ + ...this.saveErrors, + { + action: role === 'No Access' ? 'revoke' : 'grant', + targetUri: subject.iri, + label, + role, + message: error instanceof Error + ? error.message + : 'Unable to apply access change.' + } + ] + } + } + + if (this.saveErrors.length) { + this.failed = true + error('Failed to save some access changes', this.saveErrors) + return } this.principalInputValue = '' @@ -620,7 +695,17 @@ export default class AccessControlModal extends WebComponent { this.dialog?.close() } catch (error) { this.failed = true - console.error('Failed to save access changes', error) + this.saveErrors = [ + ...this.saveErrors, + { + action: 'save', + targetUri: this.subjectUri ?? '', + label: 'Access changes', + message: error instanceof Error + ? error.message + : 'Unexpected error while saving access changes.' + } + ] } finally { this.submitting = false } @@ -664,14 +749,16 @@ export default class AccessControlModal extends WebComponent { ? undefined : { subject: Authenticated, - role: this.authenticatedAccessRoleValue + role: this.authenticatedAccessRoleValue, + label: 'Authenticated users' } const publicChange = this.publicAccessRoleValue === currentPublicRole ? undefined : { subject: Public, - role: this.publicAccessRoleValue + role: this.publicAccessRoleValue, + label: 'Public' } return [authenticatedChange, publicChange].filter(isDefined) @@ -773,6 +860,18 @@ export default class AccessControlModal extends WebComponent { return { type, iri } } + private getAccessSubjectLabel (subject: AccessSubject): string { + if (subject.iri === Authenticated.iri) { + return 'Authenticated users' + } + + if (subject.iri === Public.iri) { + return 'Public' + } + + return label(sym(subject.iri)).trim() || subject.iri + } + private getEventValue (event: Event, fallback = ''): string { const target = event.currentTarget as { value?: string | null } | null return typeof target?.value === 'string' ? target.value : fallback @@ -809,7 +908,7 @@ export default class AccessControlModal extends WebComponent { return await this.commitPrinciplesFromInput() } catch (error) { this.failed = true - console.error('Failed to commit pending access grants', error) + error('Failed to commit pending access grants', error) return false } } @@ -895,7 +994,7 @@ export default class AccessControlModal extends WebComponent { return } - console.error(partial ? 'Failed to add some access grants:' : 'Failed to add access grants:', failedGrants) + error(partial ? 'Failed to add some access grants:' : 'Failed to add access grants:', failedGrants) } private async resolvePendingAccessGrantLabel (subjectValue: string, fallbackLabel: string = subjectValue): Promise { @@ -1055,7 +1154,7 @@ export default class AccessControlModal extends WebComponent { try { await navigator.clipboard.writeText(this.subjectUri) } catch (error) { - console.error('Failed to copy resource link', error) + error('Failed to copy resource link', error) } } } diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index 96663ccd7..e93132141 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -38,4 +38,13 @@ export type AuthorizationSubjectSet = { export type AccessChange = { subject: AccessSubject role: AccessRole + label: string +} + +export type AccessSaveError = { + action: 'grant' | 'revoke' | 'save' + targetUri: string + label: string + role?: AccessRole + message: string } diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index dbff6df18..2f65e8b7c 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -260,6 +260,193 @@ describe('AccessControlModal submit', () => { expect(element.pendingAccessGrants).toEqual([]) }) + it('attempts every queued change and reports only the failed changes', async () => { + const failedDuringPlanning = new Set([2, 7]) + const failedDuringApply = new Set([4, 10]) + const grants = Array.from({ length: 12 }, (_, index) => ({ + subjectType: 'agent', + subjectValue: `https://example.com/agent-${index}`, + role: 'Editor', + label: `Agent ${index}` + })) + + planGrant.mockImplementation(async (subjectUri: string, subject: { iri: string }) => { + const index = Number(subject.iri.match(/agent-(\d+)$/)?.[1]) + if (failedDuringPlanning.has(index)) { + throw new Error(`planning failed for ${index}`) + } + + return { target: `${subject.iri}.acl`, deletes: [], inserts: [] } + }) + applyPlan.mockImplementation(async (plan: { target: string }) => { + const index = Number(plan.target.match(/agent-(\d+)\.acl$/)?.[1]) + if (failedDuringApply.has(index)) { + throw new Error(`apply failed for ${index}`) + } + + return new Response('ok', { status: 200 }) + }) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = grants + document.body.appendChild(element) + await element.updateComplete + + await element.onSaveClick() + await element.updateComplete + + expect(planGrant).toHaveBeenCalledTimes(grants.length) + expect(applyPlan).toHaveBeenCalledTimes(grants.length - failedDuringPlanning.size) + + const saveErrorState = element.shadowRoot.querySelector('solid-ui-error-state') + expect(saveErrorState).not.toBeNull() + const errorItems = [...saveErrorState.querySelectorAll('li')].map((item: Element) => item.textContent) + const errorLabels = [...saveErrorState.querySelectorAll('li .access-control-save-error-label')].map((label: Element) => label.textContent) + const errorText = errorItems.join(' ') + expect(errorLabels).toEqual(['Agent 2', 'Agent 4', 'Agent 7', 'Agent 10']) + expect(errorText.match(/Could not grant Editor/g)).toHaveLength(4) + + const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement + expect(saveButton.hasAttribute('disabled')).toBe(true) + + document.body.removeChild(element) + }) + + it('keeps the dialog open with partial failures and shows the successful ACL change', async () => { + const successfulGrant = { + subjectType: 'agent', + subjectValue: 'https://example.com/successful-agent', + role: 'Editor', + label: 'Successful agent' + } + const failedGrant = { + subjectType: 'agent', + subjectValue: 'https://example.com/failed-agent', + role: 'Editor', + label: 'Failed agent' + } + planGrant.mockImplementation(async (_subjectUri: string, subject: { iri: string }) => { + if (subject.iri === failedGrant.subjectValue) { + throw new Error('grant failed') + } + return { target: `${subject.iri}.acl`, deletes: [], inserts: [] } + }) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = [successfulGrant, failedGrant] + document.body.appendChild(element) + await element.updateComplete + const dialog = element.shadowRoot.querySelector('solid-ui-dialog') + const close = vi.spyOn(dialog, 'close') + + await element.onSaveClick() + await element.updateComplete + + // Partial saves do not reconcile accessGrants; the successful applyPlan call + // is the strongest observable ACL success while the dialog remains open. + expect(planGrant).toHaveBeenCalledTimes(2) + expect(applyPlan).toHaveBeenCalledTimes(1) + expect(applyPlan).toHaveBeenCalledWith(expect.objectContaining({ + target: `${successfulGrant.subjectValue}.acl` + })) + expect(close).not.toHaveBeenCalled() + expect(element.shadowRoot.querySelector('solid-ui-error-state')).not.toBeNull() + expect([...element.shadowRoot.querySelectorAll('solid-ui-error-state li .access-control-save-error-label')].map((label: Element) => label.textContent)) + .toEqual(['Failed agent']) + const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement + expect(saveButton.hasAttribute('disabled')).toBe(true) + + document.body.removeChild(element) + }) + + it('keeps the dialog open and reports every failure when all changes fail', async () => { + const grants = [0, 1, 2].map(index => ({ + subjectType: 'agent', + subjectValue: `https://example.com/failed-agent-${index}`, + role: 'Editor', + label: `Failed agent ${index}` + })) + planGrant.mockRejectedValue(new Error('all changes failed')) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = grants + document.body.appendChild(element) + await element.updateComplete + const dialog = element.shadowRoot.querySelector('solid-ui-dialog') + const close = vi.spyOn(dialog, 'close') + + await element.onSaveClick() + await element.updateComplete + + expect(planGrant).toHaveBeenCalledTimes(grants.length) + expect(applyPlan).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + expect([...element.shadowRoot.querySelectorAll('solid-ui-error-state li .access-control-save-error-label')].map((label: Element) => label.textContent)) + .toEqual(['Failed agent 0', 'Failed agent 1', 'Failed agent 2']) + + document.body.removeChild(element) + }) + + it('closes the dialog when all changes succeed', async () => { + const grants = [0, 1].map(index => ({ + subjectType: 'agent', + subjectValue: `https://example.com/successful-agent-${index}`, + role: 'Editor', + label: `Successful agent ${index}` + })) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = grants + document.body.appendChild(element) + await element.updateComplete + const dialog = element.shadowRoot.querySelector('solid-ui-dialog') + const close = vi.spyOn(dialog, 'close') + + await element.onSaveClick() + + expect(applyPlan).toHaveBeenCalledTimes(grants.length) + expect(close).toHaveBeenCalledTimes(1) + expect(element.pendingAccessGrants).toEqual([]) + + document.body.removeChild(element) + }) + + it('reports an unexpected outer save failure as a general error', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/agent', + role: 'Editor', + label: 'Agent' + }] + document.body.appendChild(element) + await element.updateComplete + const dialog = element.shadowRoot.querySelector('solid-ui-dialog') + const close = vi.spyOn(dialog, 'close') + element.createAccessSubject = () => { + throw new Error('unexpected save failure') + } + + await element.onSaveClick() + await element.updateComplete + + expect(close).not.toHaveBeenCalled() + const errorState = element.shadowRoot.querySelector('solid-ui-error-state') + expect(errorState).not.toBeNull() + expect(errorState.textContent).toContain('Could not save access changes') + expect([...errorState.querySelectorAll('li .access-control-save-error-label')].map((label: Element) => label.textContent)) + .toEqual(['Access changes']) + + document.body.removeChild(element) + }) + it('persists authenticated and public access changes on save', async () => { const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' From f7edc4c2419184563418e963930eaba3d08188ac Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 13 Oct 2026 19:19:36 +1100 Subject: [PATCH 02/11] Implement the input error handling Prompt: Validate the input for each subjectType for agent and agentGroup make sure you can load the doc and see that it has a valid agent, agentGroup, agentClass type, validate origin based on format we have the check in solidlogic already add hostname. Co-authored-by: GPT-5.6 Luna --- .../AccessControlModal.styles.css | 7 + .../AccessControlModal.ts | 142 ++++++++++-------- test/components/access-control-modal.test.ts | 13 ++ 3 files changed, 98 insertions(+), 64 deletions(-) diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index fe74dd890..6c749cffb 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -45,6 +45,13 @@ --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); } + + .access-principal-error { + margin: 0; + color: var(--solid-ui-color-red-700, #B42318); + font-size: var(--solid-ui-font-size-xs, .75rem); + line-height: 1.4; + } } } diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index 2d7e66389..d534b2a58 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -3,10 +3,10 @@ import { html, nothing } from 'lit' import type { PropertyValues } from 'lit' import { property, query, state } from 'lit/decorators.js' import { label } from '@/utils/label' -import { error } from '@/lib/debug' +import * as debug from '@/lib/debug' import { findImage } from '@/widgets' import type Dialog from '@/components/dialog' -import { ACCESS_ROLES, Authenticated, DEFAULT_DIRECTORY_SOURCES, PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton, type AccessMode, type AccessRole, type AccessSubject, type Authorization, type DirectoryEntry } from 'solid-logic' +import { ACCESS_ROLES, Authenticated, DEFAULT_DIRECTORY_SOURCES, PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton, type AccessControlSubjectKind, type AccessMode, type AccessRole, type AccessSubject, type Authorization, type DirectoryEntry } from 'solid-logic' import { defineAsyncComboboxOptionsProvider, type ComboboxOptionData } from '@/components/combobox' import { sym } from 'rdflib' @@ -35,8 +35,14 @@ function isDefined (value: T | undefined | null): value is T { } type PendingAccessGrantCreationResult = - | { grant: PendingAccessGrant; principle: string } - | { error: string; principle: string } + | { grant: PendingAccessGrant } + | { error: string } + +type ResolvedAccessTarget = { + subjectType: AccessControlSubjectKind + subjectValue: string + label: string +} @customElement('solid-ui-access-control-modal') export default class AccessControlModal extends WebComponent { @@ -63,6 +69,9 @@ export default class AccessControlModal extends WebComponent { @state() private accessor searchValue: string = '' + @state() + private accessor inputError: string = '' + @state() private accessor failed: boolean = false @@ -408,6 +417,9 @@ export default class AccessControlModal extends WebComponent { @input=${this.onPrincipalInput} @change=${this.onPrincipalSelect} > + ${this.inputError + ? html`` + : nothing} ${this.renderPendingAccessGrants()} ${this.renderModeSelector('add')} @@ -686,7 +698,7 @@ export default class AccessControlModal extends WebComponent { if (this.saveErrors.length) { this.failed = true - error('Failed to save some access changes', this.saveErrors) + debug.error('Failed to save some access changes', this.saveErrors) return } @@ -713,6 +725,7 @@ export default class AccessControlModal extends WebComponent { private onPrincipalInput (event: Event) { this.principalInputValue = this.getEventValue(event) + this.inputError = '' } private onPrincipalSelect (event: Event) { @@ -722,7 +735,7 @@ export default class AccessControlModal extends WebComponent { return } - this.queuePendingPrinciples([option.value], this.addAccessRoleValue, option.label) + this.queuePendingPrinciple(option.value, this.addAccessRoleValue, option.label) } private onSearchInput (event: Event) { @@ -900,7 +913,7 @@ export default class AccessControlModal extends WebComponent { return false } - return this.queuePendingPrinciples([rawValue], this.addAccessRoleValue, undefined, rawValue) + return this.queuePendingPrinciple(rawValue, this.addAccessRoleValue, undefined, rawValue) } private async commitPrinciplesFromInputSafely (): Promise { @@ -908,44 +921,37 @@ export default class AccessControlModal extends WebComponent { return await this.commitPrinciplesFromInput() } catch (error) { this.failed = true - error('Failed to commit pending access grants', error) + this.inputError = 'We could not verify this access target. Check the value and try again.' + debug.error('Failed to commit pending access grants', error) return false } } - private async queuePendingPrinciples ( - principles: string[], + private async queuePendingPrinciple ( + principle: string, role: AccessRole = this.addAccessRoleValue, preferredLabel?: string, inputSnapshot?: string ): Promise { const snapshot = inputSnapshot ?? this.principalInputValue.trim() - const pendingGrantResults = await Promise.all(principles.map(async principle => this.createPendingAccessGrant(principle, role, preferredLabel))) - const newGrants = pendingGrantResults.flatMap(result => ('grant' in result ? [result.grant] : [])) - const failedGrants = pendingGrantResults - .filter((result): result is { error: string; principle: string } => 'error' in result) - .map(result => `${result.principle} (${result.error})`) - - if (!newGrants.length) { - if (failedGrants.length) { - this.failed = true - } - this.logPendingGrantFailures(failedGrants, false) + const result = await this.createPendingAccessGrant(principle, role, preferredLabel) + + if ('error' in result) { + this.failed = true + this.inputError = result.error + debug.error('Failed to add access grant:', result.error) return false } - const merged = [...this.pendingAccessGrants, ...newGrants] + const merged = [...this.pendingAccessGrants, result.grant] this.pendingAccessGrants = this.dedupePendingAccessGrants(merged) - if (failedGrants.length) { - this.failed = true - } - this.logPendingGrantFailures(failedGrants, true) + this.inputError = '' - if (!failedGrants.length && this.principalInputValue.trim() === snapshot) { + if (this.principalInputValue.trim() === snapshot) { this.principalInputValue = '' } - return failedGrants.length === 0 + return true } private async createPendingAccessGrant ( @@ -955,56 +961,61 @@ export default class AccessControlModal extends WebComponent { ): Promise { try { const normalizedPrinciple = principle.trim() - const normalizedOrigin = this.normalizeOriginInput(normalizedPrinciple) - const resolvedPrinciple = normalizedOrigin - ? { kind: 'origin' as const, subjectValue: normalizedOrigin } - : await solidLogicSingleton.acl.classifyAccessControlSubject(normalizedPrinciple) - const fallbackKind = isAbsoluteHttpUri(principle) ? 'agent' : undefined - const subjectType = resolvedPrinciple?.kind ?? fallbackKind - const subjectValue = resolvedPrinciple?.subjectValue ?? normalizedPrinciple - - if (!subjectType) { + const resolvedTarget = await this.resolveAccessTarget(normalizedPrinciple, preferredLabel) + if (!resolvedTarget) { return { - principle, - error: 'Could not classify access target' + error: 'Could not verify this access target' } } - const label = preferredLabel ?? await this.resolvePendingAccessGrantLabel(subjectValue, principle) - return { - principle, grant: { - subjectType, - subjectValue, + subjectType: resolvedTarget.subjectType, + subjectValue: resolvedTarget.subjectValue, role, - label + label: resolvedTarget.label } } } catch (error) { return { - principle, error: String(error) } } } - private logPendingGrantFailures (failedGrants: string[], partial: boolean) { - if (!failedGrants.length) { - return + private async resolveAccessTarget (principle: string, preferredLabel?: string): Promise { + const normalizedOrigin = this.normalizeOriginInput(principle) + const resolvedPrinciple = normalizedOrigin + ? { kind: 'origin' as const, subjectValue: normalizedOrigin } + : await solidLogicSingleton.acl.classifyAccessControlSubject(principle) + + if (!resolvedPrinciple) { + return undefined } - error(partial ? 'Failed to add some access grants:' : 'Failed to add access grants:', failedGrants) - } + if (resolvedPrinciple.kind === 'origin') { + return { + subjectType: resolvedPrinciple.kind, + subjectValue: resolvedPrinciple.subjectValue, + label: resolvedPrinciple.subjectValue + } + } - private async resolvePendingAccessGrantLabel (subjectValue: string, fallbackLabel: string = subjectValue): Promise { try { - const subject = sym(subjectValue) + const subject = sym(resolvedPrinciple.subjectValue) await solidLogicSingleton.store.fetcher.load(subject.doc()) - const resolved = label(subject).trim() - return resolved || fallbackLabel + const resolvedLabel = preferredLabel?.trim() || label(subject).trim() + if (!resolvedLabel || resolvedLabel === resolvedPrinciple.subjectValue) { + return undefined + } + + return { + subjectType: resolvedPrinciple.kind, + subjectValue: resolvedPrinciple.subjectValue, + label: resolvedLabel + } } catch { - return label(sym(subjectValue)) || fallbackLabel + return undefined } } @@ -1033,7 +1044,13 @@ export default class AccessControlModal extends WebComponent { } } - const options = entries.map((entry: DirectoryEntry) => this.directoryEntryToOption(entry)) + const verifiedEntries = await Promise.all(entries.map(async entry => { + const resolved = await this.resolveAccessTarget(entry.uri, entry.label) + return resolved ? { entry, label: resolved.label } : undefined + })) + const options = verifiedEntries + .filter(isDefined) + .map(({ entry, label: entryLabel }) => this.directoryEntryToOption({ ...entry, label: entryLabel })) return [...preferredOptions, ...options] }) @@ -1095,16 +1112,13 @@ export default class AccessControlModal extends WebComponent { } private async createUrlOption (uri: string): Promise { - try { - await solidLogicSingleton.store.fetcher.load(sym(uri).doc()) - } catch { + const resolved = await this.resolveAccessTarget(uri) + if (!resolved) { return undefined } - const labelText = await this.resolvePendingAccessGrantLabel(uri) - return { - label: labelText === uri ? `Use ${uri}` : labelText, + label: resolved.label, value: uri } } @@ -1154,7 +1168,7 @@ export default class AccessControlModal extends WebComponent { try { await navigator.clipboard.writeText(this.subjectUri) } catch (error) { - error('Failed to copy resource link', error) + debug.error('Failed to copy resource link', error) } } } diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 2f65e8b7c..7e1740295 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -111,6 +111,19 @@ describe('AccessControlModal submit', () => { expect(element.principalInputValue).toBe('') }) + it('rejects an HTTP URL that cannot be classified', async () => { + classifyAccessControlSubject.mockResolvedValueOnce(undefined) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.principalInputValue = 'https://unknown.example/resource' + + await element.onSubmit(new Event('submit')) + + expect(element.pendingAccessGrants).toEqual([]) + expect(element.inputError).toContain('Could not verify this access target') + }) + it.each([ ['app.example', 'https://app.example'], ['www.sharonstratsianis.com', 'https://www.sharonstratsianis.com'] From f90f2c0b79ea958c745f8c5d7c55b3dd1d1eef17 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 13 Oct 2026 20:08:19 +1100 Subject: [PATCH 03/11] Clean up Prompt: Create shared ACL view models instead of reconstructing them in multiple methods. then rerun unit and e2e tests. Co-authored-by: GPT-5.6 Luna --- .../AccessControlModal.ts | 230 +++++++++--------- src/components/access-control-modal/types.ts | 5 + test/components/access-control-modal.test.ts | 4 +- 3 files changed, 122 insertions(+), 117 deletions(-) diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index d534b2a58..56a57bdd3 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -27,7 +27,7 @@ import '@/components/error-state' import styles from './AccessControlModal.styles.css' import type { ComboboxChangeEvent } from '@/components/combobox' -import type { AccessChange, AccessControlBadge, AccessGrantEntry, AccessSaveError, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' +import type { AccessChange, AccessControlBadge, AccessGrantViewModel, AccessSaveError, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, type StringComboboxOptionData } from './helpers' function isDefined (value: T | undefined | null): value is T { @@ -72,9 +72,6 @@ export default class AccessControlModal extends WebComponent { @state() private accessor inputError: string = '' - @state() - private accessor failed: boolean = false - @state() private accessor submitting: boolean = false @@ -95,7 +92,6 @@ export default class AccessControlModal extends WebComponent { disconnectedCallback (): void { this.saveErrors = [] - this.failed = false super.disconnectedCallback() } @@ -106,7 +102,14 @@ export default class AccessControlModal extends WebComponent { this.accessGrantRoles = this.accessGrants?.map(item => this.getAuthorizationRole(item)) ?? [] this.authenticatedAccessRoleValue = this.getAuthenticatedAccessRole() this.publicAccessRoleValue = this.getPublicAccessRole() - this.refreshAccessGrantLabels() + } + } + + protected updated (changedProperties: PropertyValues) { + super.updated(changedProperties) + + if (changedProperties.has('accessGrants')) { + void this.refreshAccessGrantLabels() } } @@ -135,18 +138,21 @@ export default class AccessControlModal extends WebComponent { } } - private getAccessGrantEntries () { + private getAccessGrantViewModels (): AccessGrantViewModel[] { return (this.accessGrants ?? []) .map((authorization, index) => { - authorization = this.getSharedAuthorization(authorization) - const subjectLabel = this.getAccessGrantSubjectLabel(authorization, index) + const sharedAuthorization = this.getSharedAuthorization(authorization) + const role = this.accessGrantRoles[index] ?? this.getAuthorizationRole(sharedAuthorization) + const subjectLabel = this.accessGrantLabels[index] ?? this.getAuthorizationSubjectLabel(sharedAuthorization) return { - authorization, + authorization: sharedAuthorization, index, - role: this.accessGrantRoles[index] ?? this.getAuthorizationRole(authorization), + role, + initialRole: this.getAuthorizationRole(authorization), subjectLabel, - badge: this.getAuthorizationBadge(authorization, subjectLabel) + badge: this.getAuthorizationBadge(sharedAuthorization, subjectLabel), + subjects: this.getAuthorizationSubjectEntries(sharedAuthorization) } }) .sort((left, right) => { @@ -160,7 +166,7 @@ export default class AccessControlModal extends WebComponent { } private getSharedAccessGrantEntries () { - return this.getAccessGrantEntries().filter(({ authorization }) => this.getAuthorizationSubjectIris(authorization).length > 0) + return this.getAccessGrantViewModels().filter(({ subjects }) => subjects.length > 0) } private renderSaveErrorsList () { @@ -205,7 +211,7 @@ export default class AccessControlModal extends WebComponent { ` } - private renderAccessGrant (entry: AccessGrantEntry) { + private renderAccessGrant (entry: AccessGrantViewModel) { return html`
  • ${this.renderAuthorizationBadge(entry.badge)} @@ -343,34 +349,38 @@ export default class AccessControlModal extends WebComponent { ` } - private renderModeSelector (variant: 'add' | 'authenticated' | 'public' = 'add') { - const className = variant === 'add' - ? 'access-role-select access-role-select--top' - : variant === 'authenticated' - ? 'access-role-select access-role-select--compact access-role-select--general access-grants-role access-grants-role--editable access-role-select--authenticated' - : 'access-role-select access-role-select--compact access-role-select--general access-grants-role access-grants-role--editable access-role-select--public' - const value = variant === 'add' - ? this.addAccessRoleValue - : variant === 'authenticated' - ? this.authenticatedAccessRoleValue - : this.publicAccessRoleValue - const onChange = variant === 'add' - ? this.onAddAccessRoleInput - : variant === 'authenticated' - ? this.onAuthenticatedAccessRoleInput - : this.onPublicAccessRoleInput + private renderAddModeSelector () { + return html` + + ${this.renderAddRoleOptions()} + + ` + } + + private renderAuthenticatedModeSelector () { + return html` + + ${this.renderAuthenticatedRoleOptions()} + + ` + } + private renderPublicModeSelector () { return html` - ${variant === 'add' - ? this.renderAddRoleOptions() - : variant === 'authenticated' - ? this.renderAuthenticatedRoleOptions() - : this.renderPublicRoleOptions()} + ${this.renderPublicRoleOptions()} ` } @@ -403,7 +413,7 @@ export default class AccessControlModal extends WebComponent { private renderAddAccessForm () { return html` -
    +

    Add person, group or software agent URL.

    @@ -422,9 +432,9 @@ export default class AccessControlModal extends WebComponent { : nothing} ${this.renderPendingAccessGrants()}
    - ${this.renderModeSelector('add')} + ${this.renderAddModeSelector()}
    -
    + ` } @@ -456,36 +466,38 @@ export default class AccessControlModal extends WebComponent { private renderAccessGrantsSection () { return html` -
    -

    Share with

    - - ${this.getSharedAccessGrantEntries().map(({ subjectLabel }) => html` - - ${subjectLabel} - - `)} - -
    -
    - ${this.renderAccessGrants()} -
    +
    +
    +

    Share with

    + + ${this.getSharedAccessGrantEntries().map(({ subjectLabel }) => html` + + ${subjectLabel} + + `)} + +
    +
    + ${this.renderAccessGrants()} +
    +
    ` } private renderGeneralAccessSection () { return html` -
    +
    -

    General Access

    +

    General Access

    Copy Link -
    +
    ${this.renderGeneralShareRow({ title: 'Share with Anyone Signed In', description: 'Users must sign in to SolidOS to access this shared item using the link.', @@ -523,7 +535,9 @@ export default class AccessControlModal extends WebComponent {
    - ${this.renderModeSelector(options.variant)} + ${options.variant === 'authenticated' + ? this.renderAuthenticatedModeSelector() + : this.renderPublicModeSelector()} ` } @@ -638,18 +652,15 @@ export default class AccessControlModal extends WebComponent { const generalAccessChanges = this.getGeneralAccessChanges() if (!this.pendingAccessGrants.length && !changedAccessGrants.length && !generalAccessChanges.length) { - this.failed = true return } if (!this.subjectUri) { - this.failed = true return } this.submitting = true this.saveErrors = [] - this.failed = false try { const accessChanges: AccessChange[] = [ @@ -697,7 +708,6 @@ export default class AccessControlModal extends WebComponent { } if (this.saveErrors.length) { - this.failed = true debug.error('Failed to save some access changes', this.saveErrors) return } @@ -706,7 +716,6 @@ export default class AccessControlModal extends WebComponent { this.pendingAccessGrants = [] this.dialog?.close() } catch (error) { - this.failed = true this.saveErrors = [ ...this.saveErrors, { @@ -828,34 +837,16 @@ export default class AccessControlModal extends WebComponent { ) } - private getAccessGrantSubjectLabel (authorization: Authorization, index: number): string { - return this.accessGrantLabels[index] ?? this.getAuthorizationSubjectLabel(authorization) - } - private getChangedAccessGrants (): ChangedAccessGrant[] { - const initialAccessGrantRoles = this.getInitialAccessGrantRoles() - - return (this.accessGrants ?? []) - .map((authorization, index) => { - const currentRole = this.accessGrantRoles[index] ?? this.getAuthorizationRole(authorization) - const initialRole = initialAccessGrantRoles[index] ?? this.getAuthorizationRole(authorization) - - if (currentRole === initialRole) { - return undefined - } - - const subjects = this.getAuthorizationSubjectEntries(this.getSharedAuthorization(authorization)) - if (!subjects.length) { - return undefined + return this.getAccessGrantViewModels() + .filter(({ role, initialRole, subjects }) => { + if (role === initialRole) { + return false } - return { authorization, subjects, role: currentRole } + return subjects.length > 0 }) - .filter(isDefined) - } - - private getInitialAccessGrantRoles (): AccessRole[] { - return this.accessGrants?.map(item => this.getAuthorizationRole(item)) ?? [] + .map(({ authorization, subjects, role }) => ({ authorization, subjects, role })) } private getAuthorizationSubjectEntries (authorization: Authorization): AccessSubject[] { @@ -920,7 +911,6 @@ export default class AccessControlModal extends WebComponent { try { return await this.commitPrinciplesFromInput() } catch (error) { - this.failed = true this.inputError = 'We could not verify this access target. Check the value and try again.' debug.error('Failed to commit pending access grants', error) return false @@ -937,7 +927,6 @@ export default class AccessControlModal extends WebComponent { const result = await this.createPendingAccessGrant(principle, role, preferredLabel) if ('error' in result) { - this.failed = true this.inputError = result.error debug.error('Failed to add access grant:', result.error) return false @@ -983,22 +972,19 @@ export default class AccessControlModal extends WebComponent { } } - private async resolveAccessTarget (principle: string, preferredLabel?: string): Promise { + private async classifyAccessTarget (principle: string) { const normalizedOrigin = this.normalizeOriginInput(principle) - const resolvedPrinciple = normalizedOrigin + return normalizedOrigin ? { kind: 'origin' as const, subjectValue: normalizedOrigin } : await solidLogicSingleton.acl.classifyAccessControlSubject(principle) + } - if (!resolvedPrinciple) { - return undefined - } - - if (resolvedPrinciple.kind === 'origin') { - return { - subjectType: resolvedPrinciple.kind, - subjectValue: resolvedPrinciple.subjectValue, - label: resolvedPrinciple.subjectValue - } + private async resolveAccessTargetLabel ( + resolvedPrinciple: Awaited>, + preferredLabel?: string + ): Promise { + if (!resolvedPrinciple || resolvedPrinciple.kind === 'origin') { + return resolvedPrinciple?.subjectValue } try { @@ -1009,16 +995,30 @@ export default class AccessControlModal extends WebComponent { return undefined } - return { - subjectType: resolvedPrinciple.kind, - subjectValue: resolvedPrinciple.subjectValue, - label: resolvedLabel - } + return resolvedLabel } catch { return undefined } } + private async resolveAccessTarget (principle: string, preferredLabel?: string): Promise { + const resolvedPrinciple = await this.classifyAccessTarget(principle) + if (!resolvedPrinciple) { + return undefined + } + + const resolvedLabel = await this.resolveAccessTargetLabel(resolvedPrinciple, preferredLabel) + if (!resolvedLabel) { + return undefined + } + + return { + subjectType: resolvedPrinciple.kind, + subjectValue: resolvedPrinciple.subjectValue, + label: resolvedLabel + } + } + private dedupePendingAccessGrants (draftGrants: PendingAccessGrant[]): PendingAccessGrant[] { return dedupeByKey(draftGrants, draftGrant => `${draftGrant.subjectType}:${draftGrant.subjectValue}`) } diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index e93132141..19ba40e33 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -24,6 +24,11 @@ export type AccessGrantEntry = { badge: AccessControlBadge } +export type AccessGrantViewModel = AccessGrantEntry & { + initialRole: AccessRole + subjects: AccessSubject[] +} + export type ChangedAccessGrant = { authorization: Authorization subjects: AccessSubject[] diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 7e1740295..06b2913fa 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -248,7 +248,7 @@ describe('AccessControlModal submit', () => { modes ) expect(applyPlan).toHaveBeenCalledTimes(1) - expect(element.failed).toBe(false) + expect(element.saveErrors).toEqual([]) }) it('revokes a queued no-access grant instead of granting empty modes', async () => { @@ -772,7 +772,7 @@ describe('AccessControlModal submit', () => { expect(planRevoke).not.toHaveBeenCalled() expect(applyPlan).not.toHaveBeenCalled() expect(element.principalInputValue).toBe('not-a-principal') - expect(element.failed).toBe(true) + expect(element.inputError).toBe('Could not verify this access target') document.body.removeChild(element) }) From ff093f76b62b24bf5ef2e5fbdee3a2d21ac01358 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 13 Oct 2026 20:41:17 +1100 Subject: [PATCH 04/11] code cleanup to prepare for refactor --- .../AccessControlModal.ts | 78 ++++++++++++------- src/components/access-control-modal/types.ts | 8 +- test/components/access-control-modal.test.ts | 16 ++-- 3 files changed, 63 insertions(+), 39 deletions(-) diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index 56a57bdd3..254c20090 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -1,11 +1,10 @@ -import { customElement, WebComponent } from '@/lib/components' +import { customElement, DialogComponent } from '@/lib/components' import { html, nothing } from 'lit' import type { PropertyValues } from 'lit' -import { property, query, state } from 'lit/decorators.js' +import { property, state } from 'lit/decorators.js' import { label } from '@/utils/label' import * as debug from '@/lib/debug' import { findImage } from '@/widgets' -import type Dialog from '@/components/dialog' import { ACCESS_ROLES, Authenticated, DEFAULT_DIRECTORY_SOURCES, PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton, type AccessControlSubjectKind, type AccessMode, type AccessRole, type AccessSubject, type Authorization, type DirectoryEntry } from 'solid-logic' import { defineAsyncComboboxOptionsProvider, type ComboboxOptionData } from '@/components/combobox' import { sym } from 'rdflib' @@ -27,7 +26,7 @@ import '@/components/error-state' import styles from './AccessControlModal.styles.css' import type { ComboboxChangeEvent } from '@/components/combobox' -import type { AccessChange, AccessControlBadge, AccessGrantViewModel, AccessSaveError, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' +import type { AccessChange, AccessControlBadge, AccessControlError, AccessGrantViewModel, AccessSaveError, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, type StringComboboxOptionData } from './helpers' function isDefined (value: T | undefined | null): value is T { @@ -36,7 +35,7 @@ function isDefined (value: T | undefined | null): value is T { type PendingAccessGrantCreationResult = | { grant: PendingAccessGrant } - | { error: string } + | { error: AccessControlError } type ResolvedAccessTarget = { subjectType: AccessControlSubjectKind @@ -45,7 +44,7 @@ type ResolvedAccessTarget = { } @customElement('solid-ui-access-control-modal') -export default class AccessControlModal extends WebComponent { +export default class AccessControlModal extends DialogComponent { static styles = styles @property({ attribute: false }) @@ -69,9 +68,6 @@ export default class AccessControlModal extends WebComponent { @state() private accessor searchValue: string = '' - @state() - private accessor inputError: string = '' - @state() private accessor submitting: boolean = false @@ -85,16 +81,21 @@ export default class AccessControlModal extends WebComponent { private accessor accessGrantLabels: string[] = [] @state() - private accessor saveErrors: AccessSaveError[] = [] - - @query('solid-ui-dialog') - private accessor dialog: Dialog | null = null + private accessor errors: AccessControlError[] = [] disconnectedCallback (): void { - this.saveErrors = [] + this.errors = [] super.disconnectedCallback() } + private get inputError (): string { + return this.errors.find(error => error.action === 'input')?.message ?? '' + } + + private get saveErrors (): AccessSaveError[] { + return this.errors.filter((error): error is AccessSaveError => error.action !== 'input') + } + protected willUpdate (changedProperties: PropertyValues) { super.willUpdate(changedProperties) @@ -640,7 +641,7 @@ export default class AccessControlModal extends WebComponent { } private onCancelClick () { - this.dialog?.close() + this.close() } private async saveAccessChanges () { @@ -660,7 +661,7 @@ export default class AccessControlModal extends WebComponent { } this.submitting = true - this.saveErrors = [] + this.errors = this.errors.filter(error => error.action === 'input') try { const accessChanges: AccessChange[] = [ @@ -692,8 +693,8 @@ export default class AccessControlModal extends WebComponent { await solidLogicSingleton.acl.applyPlan(plan) appliedChanges.push({ subject, role, label }) } catch (error) { - this.saveErrors = [ - ...this.saveErrors, + this.errors = [ + ...this.errors, { action: role === 'No Access' ? 'revoke' : 'grant', targetUri: subject.iri, @@ -714,10 +715,10 @@ export default class AccessControlModal extends WebComponent { this.principalInputValue = '' this.pendingAccessGrants = [] - this.dialog?.close() + this.close() } catch (error) { - this.saveErrors = [ - ...this.saveErrors, + this.errors = [ + ...this.errors, { action: 'save', targetUri: this.subjectUri ?? '', @@ -734,7 +735,7 @@ export default class AccessControlModal extends WebComponent { private onPrincipalInput (event: Event) { this.principalInputValue = this.getEventValue(event) - this.inputError = '' + this.errors = this.errors.filter(error => error.action !== 'input') } private onPrincipalSelect (event: Event) { @@ -911,7 +912,15 @@ export default class AccessControlModal extends WebComponent { try { return await this.commitPrinciplesFromInput() } catch (error) { - this.inputError = 'We could not verify this access target. Check the value and try again.' + this.errors = [ + ...this.errors.filter(item => item.action !== 'input'), + { + action: 'input', + label: 'Access target', + targetUri: this.principalInputValue.trim(), + message: 'We could not verify this access target. Check the value and try again.' + } + ] debug.error('Failed to commit pending access grants', error) return false } @@ -927,14 +936,17 @@ export default class AccessControlModal extends WebComponent { const result = await this.createPendingAccessGrant(principle, role, preferredLabel) if ('error' in result) { - this.inputError = result.error - debug.error('Failed to add access grant:', result.error) + this.errors = [ + ...this.errors.filter(item => item.action !== 'input'), + result.error + ] + debug.error('Failed to add access grant:', result.error.message) return false } const merged = [...this.pendingAccessGrants, result.grant] this.pendingAccessGrants = this.dedupePendingAccessGrants(merged) - this.inputError = '' + this.errors = this.errors.filter(error => error.action !== 'input') if (this.principalInputValue.trim() === snapshot) { this.principalInputValue = '' @@ -953,7 +965,12 @@ export default class AccessControlModal extends WebComponent { const resolvedTarget = await this.resolveAccessTarget(normalizedPrinciple, preferredLabel) if (!resolvedTarget) { return { - error: 'Could not verify this access target' + error: { + action: 'input', + label: 'Access target', + targetUri: normalizedPrinciple, + message: 'Could not verify this access target' + } } } @@ -967,7 +984,12 @@ export default class AccessControlModal extends WebComponent { } } catch (error) { return { - error: String(error) + error: { + action: 'input', + label: 'Access target', + targetUri: principle, + message: error instanceof Error ? error.message : 'Could not verify this access target' + } } } } diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index 19ba40e33..55530ad2f 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -46,10 +46,12 @@ export type AccessChange = { label: string } -export type AccessSaveError = { - action: 'grant' | 'revoke' | 'save' - targetUri: string +export type AccessControlError = { + action: 'input' | 'grant' | 'revoke' | 'save' + targetUri?: string label: string role?: AccessRole message: string } + +export type AccessSaveError = Exclude diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 06b2913fa..d6b10d63a 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -352,8 +352,8 @@ describe('AccessControlModal submit', () => { element.pendingAccessGrants = [successfulGrant, failedGrant] document.body.appendChild(element) await element.updateComplete - const dialog = element.shadowRoot.querySelector('solid-ui-dialog') - const close = vi.spyOn(dialog, 'close') + const close = vi.fn() + window.addEventListener('solid-ui:close-dialog', close) await element.onSaveClick() await element.updateComplete @@ -390,8 +390,8 @@ describe('AccessControlModal submit', () => { element.pendingAccessGrants = grants document.body.appendChild(element) await element.updateComplete - const dialog = element.shadowRoot.querySelector('solid-ui-dialog') - const close = vi.spyOn(dialog, 'close') + const close = vi.fn() + window.addEventListener('solid-ui:close-dialog', close) await element.onSaveClick() await element.updateComplete @@ -418,8 +418,8 @@ describe('AccessControlModal submit', () => { element.pendingAccessGrants = grants document.body.appendChild(element) await element.updateComplete - const dialog = element.shadowRoot.querySelector('solid-ui-dialog') - const close = vi.spyOn(dialog, 'close') + const close = vi.fn() + window.addEventListener('solid-ui:close-dialog', close) await element.onSaveClick() @@ -441,8 +441,8 @@ describe('AccessControlModal submit', () => { }] document.body.appendChild(element) await element.updateComplete - const dialog = element.shadowRoot.querySelector('solid-ui-dialog') - const close = vi.spyOn(dialog, 'close') + const close = vi.fn() + window.addEventListener('solid-ui:close-dialog', close) element.createAccessSubject = () => { throw new Error('unexpected save failure') } From 20aa9bf8e8cff54a1a0a1c05acbbdb3694306085 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 14 Oct 2026 06:09:07 +1100 Subject: [PATCH 05/11] basic refactor splitting the access modal --- .../AccessControlAddAccess.styles.css | 117 ++++ .../AccessControlAddAccess.ts | 174 ++++++ .../AccessControlErrors.styles.css | 23 + .../AccessControlErrors.ts | 52 ++ .../AccessControlGeneralAccess.styles.css | 117 ++++ .../AccessControlGeneralAccess.ts | 164 +++++ .../AccessControlModal.styles.css | 395 ------------ .../AccessControlModal.ts | 560 ++---------------- .../AccessControlSharedAccess.styles.css | 153 +++++ .../AccessControlSharedAccess.ts | 195 ++++++ .../access-control-modal/helpers.ts | 21 + src/components/access-control-modal/types.ts | 30 +- test/components/access-control-modal.test.ts | 84 ++- 13 files changed, 1151 insertions(+), 934 deletions(-) create mode 100644 src/components/access-control-modal/AccessControlAddAccess.styles.css create mode 100644 src/components/access-control-modal/AccessControlAddAccess.ts create mode 100644 src/components/access-control-modal/AccessControlErrors.styles.css create mode 100644 src/components/access-control-modal/AccessControlErrors.ts create mode 100644 src/components/access-control-modal/AccessControlGeneralAccess.styles.css create mode 100644 src/components/access-control-modal/AccessControlGeneralAccess.ts create mode 100644 src/components/access-control-modal/AccessControlSharedAccess.styles.css create mode 100644 src/components/access-control-modal/AccessControlSharedAccess.ts diff --git a/src/components/access-control-modal/AccessControlAddAccess.styles.css b/src/components/access-control-modal/AccessControlAddAccess.styles.css new file mode 100644 index 000000000..1331e3136 --- /dev/null +++ b/src/components/access-control-modal/AccessControlAddAccess.styles.css @@ -0,0 +1,117 @@ +:host { + .access-grants-form { + display: flex; + flex-direction: column; + padding: 10px; + align-items: flex-start; + gap: 10px; + align-self: stretch; + font-size: var(--solid-ui-font-size-sm, .875rem); + + --access-role-select-width: 129px; + + p { + color: var(--solid-ui-color-gray-600, #4A5565); + line-height: 1.5; + } + + .access-grants-form-main { + display: flex; + align-items: flex-start; + gap: 10px; + align-self: stretch; + + .access-grants-input { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + padding: 10px 15px; + gap: 12px; + align-items: flex-start; + border-radius: 5px; + border: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); + background: var(--solid-ui-color-white, #FFF); + + solid-ui-combobox { + width: 100%; + --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); + } + + .access-principal-error { + margin: 0; + color: var(--solid-ui-color-red-700, #B42318); + font-size: var(--solid-ui-font-size-xs, .75rem); + line-height: 1.4; + } + } + } + + .access-grants-pending { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 42px; + align-items: center; + } + + .access-grants-pending-item { + display: flex; + padding: 5px 10px; + align-items: center; + gap: 6px; + max-width: 100%; + border-radius: 5px; + border: 1px solid var(--solid-ui-color-gray-300, #D1D5DC); + background: var(--solid-ui-color-gray-50, #F9FAFB); + color: var(--solid-ui-color-gray-700, #374151); + } + + .access-grants-pending-item-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--solid-ui-color-gray-500, #6A7282); + font-size: var(--solid-ui-font-size-xs, .75rem); + line-height: 1; + } + + .access-grants-pending-item-remove { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--solid-ui-color-gray-300, #D1D5DC); + cursor: pointer; + + icon-lucide-circle-x { + width: 14px; + height: 14px; + } + } + } + + .access-grants-directory-entry { + display: inline-flex; + align-items: center; + gap: 8px; + line-height: 1; + } + + .access-role-select { + display: block; + max-width: 100%; + margin-top: 25px; + flex: 0 0 auto; + width: var(--access-role-select-width); + --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); + } + + .access-role-select--top { + margin-top: 0; + } +} diff --git a/src/components/access-control-modal/AccessControlAddAccess.ts b/src/components/access-control-modal/AccessControlAddAccess.ts new file mode 100644 index 000000000..cda932ee9 --- /dev/null +++ b/src/components/access-control-modal/AccessControlAddAccess.ts @@ -0,0 +1,174 @@ +import { customElement, WebComponent } from '@/lib/components' +import { html, nothing } from 'lit' +import { property } from 'lit/decorators.js' +import { ACCESS_ROLES, type AccessRole, type DirectoryEntry } from 'solid-logic' +import type { AsyncComboboxOptionsProvider, ComboboxChangeEvent } from '@/components/combobox' + +import '~icons/lucide/book-user' +import '~icons/lucide/user-round' +import '~icons/lucide/users' +import '~icons/lucide/circle-x' +import '@/components/button' +import '@/components/combobox' +import '@/components/combobox-option' + +import styles from './AccessControlAddAccess.styles.css' +import type { PendingAccessGrant, PendingGrantRemovedDetail, RoleChangedDetail, TargetInputDetail, TargetSelectedDetail } from './types' + +export function directoryEntryOptionTemplate (entry: DirectoryEntry) { + return html` + + ${renderDirectoryEntryIcon(entry)} + ${entry.label} + + ` +} + +function renderDirectoryEntryIcon (entry: DirectoryEntry) { + if (entry.sources.includes('contacts') || entry.sources.includes('groups')) { + return html`` + } + + if (entry.sources.includes('friends')) { + return html`` + } + + if (entry.sources.includes('catalog')) { + return html`` + } + + return nothing +} + +@customElement('solid-ui-access-control-add-access') +export default class AccessControlAddAccess extends WebComponent { + static styles = styles + + @property({ attribute: false }) + accessor principalInputValue = '' + + @property({ attribute: false }) + accessor addAccessRoleValue: AccessRole = 'Viewer' + + @property({ attribute: false }) + accessor pendingAccessGrants: PendingAccessGrant[] = [] + + @property({ attribute: false }) + accessor inputError = '' + + @property({ attribute: false }) + accessor asyncOptionsProvider: AsyncComboboxOptionsProvider | null = null + + private onPrincipalInput (event: Event) { + const value = this.getEventValue(event) + this.dispatchEvent(new CustomEvent('target-input', { + detail: { value }, + bubbles: true, + composed: true + })) + } + + private onPrincipalSelect (event: ComboboxChangeEvent) { + const option = event.detail.option + if (typeof option.value !== 'string') { + return + } + + this.dispatchEvent(new CustomEvent('target-selected', { + detail: { + value: option.value, + label: option.label + }, + bubbles: true, + composed: true + })) + } + + private onRoleChange (event: ComboboxChangeEvent) { + const role = event.detail.option.value + if (typeof role !== 'string' || !ACCESS_ROLES.includes(role as AccessRole)) { + return + } + + this.dispatchEvent(new CustomEvent('role-changed', { + detail: { role: role as AccessRole }, + bubbles: true, + composed: true + })) + } + + private onPendingGrantRemove (index: number) { + this.dispatchEvent(new CustomEvent('pending-grant-removed', { + detail: { index }, + bubbles: true, + composed: true + })) + } + + private getEventValue (event: Event): string { + const target = event.currentTarget as { value?: string | null } | null + return typeof target?.value === 'string' ? target.value : '' + } + + protected render () { + return html` +
    +

    Add person, group or software agent URL.

    +
    +
    + + ${this.inputError + ? html`` + : nothing} + ${this.renderPendingAccessGrants()} +
    + + ${ACCESS_ROLES + .filter(role => role !== 'No Access') + .map(role => html` + ${role} + `)} + +
    +
    + ` + } + + private renderPendingAccessGrants () { + if (!this.pendingAccessGrants.length) { + return nothing + } + + return html` +
    + ${this.pendingAccessGrants.map((grant, index) => html` +
    + ${grant.label} + this.onPendingGrantRemove(index)} + > + Remove ${grant.label} + + +
    + `)} +
    + ` + } +} diff --git a/src/components/access-control-modal/AccessControlErrors.styles.css b/src/components/access-control-modal/AccessControlErrors.styles.css new file mode 100644 index 000000000..c776731f3 --- /dev/null +++ b/src/components/access-control-modal/AccessControlErrors.styles.css @@ -0,0 +1,23 @@ +:host { + .access-control-save-errors { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding-left: 20px; + + li { + display: flex; + align-items: baseline; + gap: 6px; + color: var(--solid-ui-color-gray-700, #374151); + font-size: var(--solid-ui-font-size-sm, .875rem); + line-height: 1.4; + } + + .access-control-save-error-label { + flex: 0 0 auto; + font-weight: 500; + } + } +} diff --git a/src/components/access-control-modal/AccessControlErrors.ts b/src/components/access-control-modal/AccessControlErrors.ts new file mode 100644 index 000000000..fbd4b8eba --- /dev/null +++ b/src/components/access-control-modal/AccessControlErrors.ts @@ -0,0 +1,52 @@ +import { customElement, WebComponent } from '@/lib/components' +import { html } from 'lit' +import { property } from 'lit/decorators.js' + +import '@/components/error-state' + +import styles from './AccessControlErrors.styles.css' +import type { AccessSaveError } from './types' + +@customElement('solid-ui-access-control-errors') +export default class AccessControlErrors extends WebComponent { + static styles = styles + + @property({ attribute: false }) + accessor errors: AccessSaveError[] = [] + + private onClose () { + this.dispatchEvent(new CustomEvent('close', { + bubbles: true, + composed: true + })) + } + + protected render () { + if (!this.errors.length) { + return html`` + } + + return html` + +
      + ${this.errors.map(error => html` +
    • + ${error.label} + + ${error.action === 'grant' + ? `Could not grant ${error.role ?? 'access'}` + : error.action === 'revoke' + ? `Could not revoke ${error.role ?? 'access'}` + : 'Could not save access changes'} + +
    • + `)} +
    +
    + ` + } +} diff --git a/src/components/access-control-modal/AccessControlGeneralAccess.styles.css b/src/components/access-control-modal/AccessControlGeneralAccess.styles.css new file mode 100644 index 000000000..1c07efed5 --- /dev/null +++ b/src/components/access-control-modal/AccessControlGeneralAccess.styles.css @@ -0,0 +1,117 @@ +:host { + h2 { + color: var(--solid-ui-color-gray-900, #101828); + font-size: var(--solid-ui-font-size-md, 1rem); + font-weight: 600; + } + + .access-grants-general { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding: 0 10px; + align-self: stretch; + + solid-ui-combobox, + solid-ui-input { + --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); + } + + .access-grants-general-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 15px; + align-self: stretch; + + .access-grants-copy-link-button-label { + font-size: var(--solid-ui-font-size-xs, .75rem); + font-weight: 500; + } + + .access-grants-copy-link-button icon-lucide-link { + width: 12px; + height: 12px; + aspect-ratio: 1/1; + } + } + + .access-grants-general-share { + display: flex; + align-items: center; + gap: 10px; + align-self: stretch; + + .access-grants-general-share-content { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 10px; + flex: 1 0 0; + + .access-grants-general-share-icon { + display: flex; + width: 30px; + height: 30px; + padding: 5px; + justify-content: center; + align-items: center; + aspect-ratio: 1/1; + border-radius: 50px; + background: var(--solid-ui-color-violet-50, #F5F3FF); + } + + .access-grants-general-share-icon-inner { + display: flex; + width: 20px; + height: 20px; + padding: 1.25px; + justify-content: center; + align-items: center; + flex-shrink: 0; + aspect-ratio: 1/1; + + icon-lucide-globe { + width: 17.5px; + height: 17.5px; + flex-shrink: 0; + aspect-ratio: 1/1; + } + } + + .access-grants-general-share-text { + flex: 1 1 auto; + min-width: 0; + + .access-grants-general-share-text-title { + color: var(--solid-ui-color-gray-900, #101828); + font-size: var(--solid-ui-font-size-sm, .875rem); + font-weight: 600; + } + + .access-grants-general-share-text-description { + color: var(--solid-ui-color-gray-700, #364153); + font-size: var(--solid-ui-font-size-sm, .875rem); + } + } + } + } + } + + .access-role-select--general { + width: 120px; + min-width: 120px; + flex: 0 0 120px; + font-size: var(--solid-ui-font-size-xs, .75rem); + font-weight: 500; + } + + .access-role-select--compact { + display: block; + max-width: 100%; + --solid-ui-input-border-color: transparent; + margin-top: 0; + } +} diff --git a/src/components/access-control-modal/AccessControlGeneralAccess.ts b/src/components/access-control-modal/AccessControlGeneralAccess.ts new file mode 100644 index 000000000..ff969a5be --- /dev/null +++ b/src/components/access-control-modal/AccessControlGeneralAccess.ts @@ -0,0 +1,164 @@ +import { customElement, WebComponent } from '@/lib/components' +import { html } from 'lit' +import { property } from 'lit/decorators.js' +import { ACCESS_ROLES, PUBLIC_ACCESS_ROLES, type AccessRole } from 'solid-logic' +import * as debug from '@/lib/debug' +import type { ComboboxChangeEvent } from '@/components/combobox' + +import '~icons/lucide/link' +import '~icons/lucide/globe' +import '@/components/button' +import '@/components/combobox' +import '@/components/combobox-option' + +import styles from './AccessControlGeneralAccess.styles.css' +import type { RoleChangedDetail } from './types' + +@customElement('solid-ui-access-control-general-access') +export default class AccessControlGeneralAccess extends WebComponent { + static styles = styles + + @property({ attribute: false }) + accessor subjectUri: string | undefined = undefined + + @property({ attribute: false }) + accessor authenticatedAccessRoleValue: AccessRole = 'No Access' + + @property({ attribute: false }) + accessor publicAccessRoleValue: AccessRole = 'No Access' + + private onAuthenticatedRoleChange (event: ComboboxChangeEvent) { + this.dispatchRoleChange('authenticated-role-changed', event, ACCESS_ROLES) + } + + private onPublicRoleChange (event: ComboboxChangeEvent) { + this.dispatchRoleChange('public-role-changed', event, PUBLIC_ACCESS_ROLES) + } + + private dispatchRoleChange ( + eventName: 'authenticated-role-changed' | 'public-role-changed', + event: ComboboxChangeEvent, + roles: readonly string[] + ) { + const value = event.detail.option.value + if (typeof value !== 'string' || !roles.includes(value)) { + return + } + + const detail: RoleChangedDetail = { role: value as AccessRole } + this.dispatchEvent(new CustomEvent(eventName, { + detail, + bubbles: true, + composed: true + })) + } + + private async onCopyLinkClick (event: Event) { + event.preventDefault() + + if (!this.subjectUri) { + return + } + + try { + await navigator.clipboard.writeText(this.subjectUri) + } catch (error) { + debug.error('Failed to copy resource link', error) + } + } + + private renderAuthenticatedRoleOptions () { + return ACCESS_ROLES.map(role => html` + ${role} + `) + } + + private renderPublicRoleOptions () { + return PUBLIC_ACCESS_ROLES.map(role => html` + ${role} + `) + } + + private renderAuthenticatedModeSelector () { + return html` + + ${this.renderAuthenticatedRoleOptions()} + + ` + } + + private renderPublicModeSelector () { + return html` + + ${this.renderPublicRoleOptions()} + + ` + } + + private renderGeneralAccessIcon () { + return html` + + ` + } + + private renderGeneralShareRow (options: { + title: string + description: string + variant: 'authenticated' | 'public' + }) { + return html` + + ` + } + + protected render () { + return html` +
    +
    +

    General Access

    + + + Copy Link + +
    + ${this.renderGeneralShareRow({ + title: 'Share with Anyone Signed In', + description: 'Users must sign in to SolidOS to access this shared item using the link.', + variant: 'authenticated' + })} + ${this.renderGeneralShareRow({ + title: 'Anyone with the Link', + description: 'Anyone on the internet with the link can view.', + variant: 'public' + })} +
    + ` + } +} diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index 6c749cffb..32438b214 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -1,399 +1,4 @@ :host { - h2 { - color: var(--solid-ui-color-gray-900, #101828); - font-size: var(--solid-ui-font-size-md, 1rem); - font-weight: 600; - } - - .access-grants-form { - display: flex; - flex-direction: column; - padding: 10px 10px; - align-items: flex-start; - gap: 10px; - align-self: stretch; - font-size: var(--solid-ui-font-size-sm, .875rem); - - --access-role-select-width: 129px; - - p { - color: var(--solid-ui-color-gray-600, #4A5565); - line-height: 1.5; - } - - .access-grants-form-main { - display: flex; - align-items: flex-start; - gap: 10px; - align-self: stretch; - - .access-grants-input { - display: flex; - flex-direction: column; - flex: 1 1 auto; - min-width: 0; - padding: 10px 15px; - gap: 12px; - align-items: flex-start; - border-radius: 5px; - border: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); - background: var(--solid-ui-color-white, #FFF); - - solid-ui-combobox { - width: 100%; - /* color: var(--solid-ui-color-gray-300, #D1D5DC); it is so light you can't read*/ - - --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); - } - - .access-principal-error { - margin: 0; - color: var(--solid-ui-color-red-700, #B42318); - font-size: var(--solid-ui-font-size-xs, .75rem); - line-height: 1.4; - } - } - } - - .access-grants-pending { - display: flex; - flex-wrap: wrap; - gap: 8px; - min-height: 42px; - align-items: center; - } - - .access-grants-pending-item { - display: flex; - padding: 5px 10px; - align-items: center; - gap: 6px; - max-width: 100%; - border-radius: 5px; - border: 1px solid var(--solid-ui-color-gray-300, #D1D5DC); - background: var(--solid-ui-color-gray-50, #F9FAFB); - color: var(--solid-ui-color-gray-700, #374151); - } - - .access-grants-pending-item-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--solid-ui-color-gray-500, #6A7282); - font-size: var(--solid-ui-font-size-xs, .75rem); - line-height: 1; - } - - .access-grants-pending-item-remove { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - border: none; - border-radius: 999px; - background: transparent; - color: var(--solid-ui-color-gray-300, #D1D5DC); - cursor: pointer; - - } - - .access-grants-pending-item-remove icon-lucide-circle-x { - width: 14px; - height: 14px; - } - } - - .access-grants-header, - .access-grants-list, - .access-grants-general { - padding: 0 10px; - align-self: stretch; - } - - .access-grants-directory-entry { - display: inline-flex; - align-items: center; - gap: 8px; - line-height: 1; - } - - .access-grants-header { - display: flex; - align-items: center; - gap: 15px; - justify-content: space-between; - - .access-grants-search-input { - flex: 0 0 auto; - width: 12ch; - min-width: 12ch; - --solid-ui-input-border-color: transparent; - color: var(--solid-ui-color-zinc-500, #71717B); - font-size: var(--solid-ui-font-size-sm, .875rem); - font-weight: 500; - } - } - - .access-grants-list { - display: flex; - align-items: flex-start; - gap: 20px; - - ul { - display: flex; - flex-direction: column; - height: 195px; - padding: 15px; - align-items: flex-start; - gap: 15px; - flex: 1 0 0; - min-height: 0; - box-sizing: border-box; - overflow-y: auto; - overflow-x: hidden; - border-radius: 10px; - border: 1px solid var(--solid-ui-color-gray-200, #E5E7EB); - } - - li { - display: flex; - align-items: center; - gap: 10px; - flex: 0 0 auto; - width: 100%; - - .access-grants-image { - display: flex; - justify-content: center; - align-items: center; - aspect-ratio: 1/1; - width: 30px; - height: 30px; - flex: 0 0 30px; - border-radius: 999px; - overflow: hidden; - color: var(--solid-ui-color-white, #FFF); - text-align: center; - font-size: var(--solid-ui-font-size-xs, .75rem); - font-weight: 500; - - img { - display: flex; - width: 100%; - height: 100%; - border-radius: 999px; - object-fit: cover; - } - - span { - display: flex; - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - line-height: 1; - } - } - - .access-grants-image--agentGroup { - background: var(--solid-ui-color-blue-900, #083575); - } - - .access-grants-image--agent, - .access-grants-image--agentClass { - background: var(--solid-ui-color-lavender-300, #e6dcff); - } - - .access-grants-image--origin { - background: var(--solid-ui-color-green-700, #15803D); - } - - .access-grants-image--unknown { - background: var(--solid-ui-color-gray-300, #D1D5DB); - } - - h3 { - flex: 1 1 auto; - min-width: 0; - color: var(--solid-ui-color-gray-800, #1E2939); - font-size: var(--solid-ui-font-size-xs, .75rem); - font-weight: 600; - } - - span { - margin-left: auto; - flex: 0 0 auto; - text-align: right; - } - - .access-grants-role { - margin-left: auto; - flex: 0 0 auto; - font-size: var(--solid-ui-font-size-xs, .75rem); - } - - .access-grants-role--owner { - font-size: var(--solid-ui-font-size-xs, .75rem); - } - - .access-grants-role--editable { - max-width: 100%; - } - } - } - - .access-control-save-errors { - display: flex; - flex-direction: column; - gap: 8px; - margin: 0; - padding-left: 20px; - - li { - display: flex; - align-items: baseline; - gap: 6px; - color: var(--solid-ui-color-gray-700, #374151); - font-size: var(--solid-ui-font-size-sm, .875rem); - line-height: 1.4; - } - - .access-control-save-error-label { - flex: 0 0 auto; - font-weight: 500; - } - } - - .access-grants-general { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; - - solid-ui-combobox, - solid-ui-input { - --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); - } - - .access-grants-general-header { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - gap: 15px; - align-self: stretch; - - .access-grants-copy-link-button-label { - font-size: var(--solid-ui-font-size-xs, .75rem); - font-weight: 500; - } - - .access-grants-copy-link-button icon-lucide-link { - width: 12px; - height: 12px; - aspect-ratio: 1/1; - } - } - - .access-grants-general-share { - display: flex; - align-items: center; - gap: 10px; - align-self: stretch; - - .access-grants-general-share-content { - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 10px; - flex: 1 0 0; - - .access-grants-general-share-icon { - display: flex; - width: 30px; - height: 30px; - padding: 5px; - justify-content: center; - align-items: center; - aspect-ratio: 1/1; - border-radius: 50px; - background: var(--solid-ui-color-violet-50, #F5F3FF); - } - - .access-grants-general-share-icon-inner { - display: flex; - width: 20px; - height: 20px; - padding: 1.25px; - justify-content: center; - align-items: center; - flex-shrink: 0; - aspect-ratio: 1/1; - - icon-lucide-globe { - width: 17.5px; - height: 17.5px; - flex-shrink: 0; - aspect-ratio: 1/1; - } - } - - .access-grants-general-share-text { - flex: 1 1 auto; - min-width: 0; - - .access-grants-general-share-text-title { - color: var(--solid-ui-color-gray-900, #101828); - font-size: var(--solid-ui-font-size-sm, .875rem); - font-weight: 600; - } - - .access-grants-general-share-text-description { - color: var(--solid-ui-color-gray-700, #364153); - font-size: var(--solid-ui-font-size-sm, .875rem); - } - } - } - } - } - - .access-role-select { - display: block; - max-width: 100%; - margin-top: 25px; - flex: 0 0 auto; - width: var(--access-role-select-width); - --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); - } - - .access-role-select--top { - margin-top: 0; - } - - .access-role-select--compact { - width: 110px; - min-width: 110px; - flex: 0 0 110px; - max-width: 100%; - --solid-ui-input-border-color: transparent; - margin-top: 0; - } - - .access-role-select--general { - width: 120px; - min-width: 120px; - flex: 0 0 120px; - font-size: var(--solid-ui-font-size-xs, .75rem); - font-weight: 500; - } - - .access-grants-list li .access-role-select--compact { - width: 110px; - min-width: 110px; - flex: 0 0 110px; - } - .access-control-footer-actions { display: flex; justify-content: flex-end; diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index 254c20090..087c0f718 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -1,20 +1,13 @@ import { customElement, DialogComponent } from '@/lib/components' -import { html, nothing } from 'lit' +import { html } from 'lit' import type { PropertyValues } from 'lit' import { property, state } from 'lit/decorators.js' import { label } from '@/utils/label' import * as debug from '@/lib/debug' -import { findImage } from '@/widgets' -import { ACCESS_ROLES, Authenticated, DEFAULT_DIRECTORY_SOURCES, PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton, type AccessControlSubjectKind, type AccessMode, type AccessRole, type AccessSubject, type Authorization, type DirectoryEntry } from 'solid-logic' +import { Authenticated, DEFAULT_DIRECTORY_SOURCES, PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton, type AccessControlSubjectKind, type AccessMode, type AccessRole, type AccessSubject, type Authorization, type DirectoryEntry } from 'solid-logic' import { defineAsyncComboboxOptionsProvider, type ComboboxOptionData } from '@/components/combobox' import { sym } from 'rdflib' -import '~icons/lucide/chevron-down' -import '~icons/lucide/link' -import '~icons/lucide/globe' -import '~icons/lucide/book-user' -import '~icons/lucide/user-round' -import '~icons/lucide/users' import '~icons/lucide/circle-x' import '@/components/dialog' import '@/components/dialog-content' @@ -22,12 +15,14 @@ import '@/components/dialog-footer' import '@/components/button' import '@/components/combobox' import '@/components/combobox-option' -import '@/components/error-state' +import { directoryEntryOptionTemplate } from './AccessControlAddAccess' +import './AccessControlSharedAccess' +import './AccessControlGeneralAccess' +import './AccessControlErrors' import styles from './AccessControlModal.styles.css' -import type { ComboboxChangeEvent } from '@/components/combobox' -import type { AccessChange, AccessControlBadge, AccessControlError, AccessGrantViewModel, AccessSaveError, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' -import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, type StringComboboxOptionData } from './helpers' +import type { AccessChange, AccessControlError, AccessGrantRoleChangedEvent, AccessGrantViewModel, AccessSaveError, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' +import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, normalizeOriginInput, type StringComboboxOptionData } from './helpers' function isDefined (value: T | undefined | null): value is T { return value !== undefined && value !== null @@ -65,9 +60,6 @@ export default class AccessControlModal extends DialogComponent { @state() private accessor publicAccessRoleValue: AccessRole = 'No Access' - @state() - private accessor searchValue: string = '' - @state() private accessor submitting: boolean = false @@ -152,7 +144,6 @@ export default class AccessControlModal extends DialogComponent { role, initialRole: this.getAuthorizationRole(authorization), subjectLabel, - badge: this.getAuthorizationBadge(sharedAuthorization, subjectLabel), subjects: this.getAuthorizationSubjectEntries(sharedAuthorization) } }) @@ -166,121 +157,6 @@ export default class AccessControlModal extends DialogComponent { }) } - private getSharedAccessGrantEntries () { - return this.getAccessGrantViewModels().filter(({ subjects }) => subjects.length > 0) - } - - private renderSaveErrorsList () { - if (!this.saveErrors.length) { - return nothing - } - - return html` -
      - ${this.saveErrors.map(error => html` -
    • - ${error.label} - - ${error.action === 'grant' - ? `Could not grant ${error.role ?? 'access'}` - : error.action === 'revoke' - ? `Could not revoke ${error.role ?? 'access'}` - : 'Could not save access changes'} - -
    • - `)} -
    - ` - } - - private renderAccessGrants () { - const query = this.searchValue.trim().toLowerCase() - const accessGrants = this.getSharedAccessGrantEntries().filter(({ subjectLabel }) => { - if (!query) { - return true - } - - return subjectLabel.toLowerCase().includes(query) - }) - - return html` -
      - ${accessGrants.length > 0 - ? accessGrants.map(entry => this.renderAccessGrant(entry)) - : html`
    • No access grants
    • `} -
    - ` - } - - private renderAccessGrant (entry: AccessGrantViewModel) { - return html` -
  • - ${this.renderAuthorizationBadge(entry.badge)} -

    ${entry.subjectLabel}

    - ${this.renderAuthorizationRole(entry.role, entry.index)} -
  • - ` - } - - private getAuthorizationBadge (authorization: Authorization, subjectLabel?: string) { - const resolvedSubjectLabel = subjectLabel ?? this.getAuthorizationSubjectLabel(authorization) - - const agent = authorization.agent[0] - if (agent) { - const image = findImage(sym(agent)) - return { - kind: 'agent' as const, - image, - text: image ? '' : this.getInitials(resolvedSubjectLabel, 2) - } - } - - const agentGroup = authorization.agentGroup[0] - if (agentGroup) { - return { - kind: 'agentGroup' as const, - text: this.getInitials(resolvedSubjectLabel, 1) - } - } - - const agentClass = authorization.agentClass[0] - if (agentClass) { - const image = findImage(sym(agentClass)) - return { - kind: 'agentClass' as const, - image, - text: image ? '' : this.getInitials(resolvedSubjectLabel, 2) - } - } - - const origin = authorization.origin[0] - if (origin) { - return { - kind: 'origin' as const, - text: 'O' - } - } - - return { - kind: 'unknown' as const, - text: '?' - } - } - - private renderAuthorizationBadge (badge: AccessControlBadge) { - return html` -
    - ${badge.image ? html`` : html``} -
    - ` - } - - private getInitials (label: string, maxWords = 2): string { - const parts = label.split(/\s+/).filter(Boolean).slice(0, maxWords) - const initials = parts.map(part => part[0]).join('') - return (initials || label.slice(0, maxWords)).toUpperCase() - } - private getAuthorizationSubjectIris (authorization: Authorization) { return [ ...authorization.agent, @@ -326,233 +202,6 @@ export default class AccessControlModal extends DialogComponent { return solidLogicSingleton.acl.publicRoleFromModes(authorization.mode) } - private getRoleValueFromEvent (event: Event, fallback: AccessRole = 'Viewer'): AccessRole { - const selectedValue = this.getSelectedComboboxOptionValue(event) - - if (typeof selectedValue === 'string') { - return selectedValue as AccessRole - } - - const target = event.currentTarget as { value?: string | null } | null - - return typeof target?.value === 'string' ? target.value as AccessRole : fallback - } - - private renderAuthorizationRole (role: AccessRole, index: number) { - return html` - this.onAccessGrantRoleInput(index, event)} - > - ${this.renderGrantRoleOptions()} - - ` - } - - private renderAddModeSelector () { - return html` - - ${this.renderAddRoleOptions()} - - ` - } - - private renderAuthenticatedModeSelector () { - return html` - - ${this.renderAuthenticatedRoleOptions()} - - ` - } - - private renderPublicModeSelector () { - return html` - - ${this.renderPublicRoleOptions()} - - ` - } - - private renderAddRoleOptions () { - return ACCESS_ROLES - .filter(role => role !== 'No Access') - .map(role => html` - ${role} - `) - } - - private renderAuthenticatedRoleOptions () { - return ACCESS_ROLES.map(role => html` - ${role} - `) - } - - private renderPublicRoleOptions () { - return PUBLIC_ACCESS_ROLES.map(role => html` - ${role} - `) - } - - private renderGrantRoleOptions () { - return ACCESS_ROLES.map(role => html` - ${role === 'No Access' ? 'Remove' : role} - `) - } - - private renderAddAccessForm () { - return html` -
    -

    Add person, group or software agent URL.

    -
    -
    - - ${this.inputError - ? html`` - : nothing} - ${this.renderPendingAccessGrants()} -
    - ${this.renderAddModeSelector()} -
    -
    - ` - } - - private renderPendingAccessGrants () { - if (!this.pendingAccessGrants.length) { - return nothing - } - - return html` -
    - ${this.pendingAccessGrants.map((grant, index) => html` -
    - ${grant.label} - - Remove ${grant.label} - - -
    - `)} -
    - ` - } - - private renderAccessGrantsSection () { - return html` -
    -
    -

    Share with

    - - ${this.getSharedAccessGrantEntries().map(({ subjectLabel }) => html` - - ${subjectLabel} - - `)} - -
    -
    - ${this.renderAccessGrants()} -
    -
    - ` - } - - private renderGeneralAccessSection () { - return html` -
    -
    -

    General Access

    - - - Copy Link - -
    - ${this.renderGeneralShareRow({ - title: 'Share with Anyone Signed In', - description: 'Users must sign in to SolidOS to access this shared item using the link.', - variant: 'authenticated' - })} - ${this.renderGeneralShareRow({ - title: 'Anyone with the Link', - description: 'Anyone on the internet with the link can view.', - variant: 'public' - })} - - ` - } - - private renderGeneralShareRow (options: { - title: string - description: string - variant: 'authenticated' | 'public' - }) { - return html` - - ` - } - - private renderGeneralAccessIcon () { - return html` - - ` - } - private getRoleModes (role: AccessRole): AccessMode[] { return solidLogicSingleton.acl.modesFromRole(role) } @@ -575,20 +224,32 @@ export default class AccessControlModal extends DialogComponent {
    - ${this.renderAddAccessForm()} - ${this.renderAccessGrantsSection()} - ${this.renderGeneralAccessSection()} - ${this.saveErrors.length - ? html` - - ${this.renderSaveErrorsList()} - - ` - : nothing} + + + + @@ -733,31 +394,13 @@ export default class AccessControlModal extends DialogComponent { } } - private onPrincipalInput (event: Event) { - this.principalInputValue = this.getEventValue(event) + private onPrincipalInput (event: TargetInputEvent) { + this.principalInputValue = event.detail.value this.errors = this.errors.filter(error => error.action !== 'input') } - private onPrincipalSelect (event: Event) { - const option = this.getSelectedStringComboboxOption(event) - - if (!option) { - return - } - - this.queuePendingPrinciple(option.value, this.addAccessRoleValue, option.label) - } - - private onSearchInput (event: Event) { - this.searchValue = this.getEventValue(event) - } - - private onSearchSelect (event: Event) { - const option = this.getSelectedComboboxOption(event) - - if (option) { - this.searchValue = option.label - } + private onPrincipalSelect (event: TargetSelectedEvent) { + this.queuePendingPrinciple(event.detail.value, this.addAccessRoleValue, event.detail.label) } private getGeneralAccessChanges (): AccessChange[] { @@ -787,46 +430,30 @@ export default class AccessControlModal extends DialogComponent { return [authenticatedChange, publicChange].filter(isDefined) } - private onAddAccessRoleInput (event: Event) { - const nextRole = this.getRoleValueFromEvent(event) - this.addAccessRoleValue = nextRole + private onAddAccessRoleInput (event: RoleChangedEvent) { + this.addAccessRoleValue = event.detail.role this.pendingAccessGrants = this.pendingAccessGrants.map(grant => ({ ...grant, - role: nextRole + role: event.detail.role })) } - private onAuthenticatedAccessRoleInput (event: Event) { - const nextRole = this.getRoleValueFromEvent(event) - this.authenticatedAccessRoleValue = nextRole + private onAuthenticatedAccessRoleInput (event: RoleChangedEvent) { + this.authenticatedAccessRoleValue = event.detail.role } - private onPublicAccessRoleInput (event: Event) { - const nextRole = this.getRoleValueFromEvent(event, 'No Access') - this.publicAccessRoleValue = nextRole + private onPublicAccessRoleInput (event: RoleChangedEvent) { + this.publicAccessRoleValue = event.detail.role } - private onAccessGrantRoleInput (index: number, event: Event) { - const value = this.getRoleValueFromEvent(event) - + private onAccessGrantRoleInput (event: AccessGrantRoleChangedEvent) { this.accessGrantRoles = this.accessGrantRoles.map((role, roleIndex) => { - return roleIndex === index ? value : role + return roleIndex === event.detail.index ? event.detail.role : role }) } - private removePendingAccessGrant (index: number) { - this.pendingAccessGrants = this.pendingAccessGrants.filter((_, pendingIndex) => pendingIndex !== index) - } - - private onRemovePendingAccessGrantClick (event: Event) { - const target = event.currentTarget as HTMLElement | null - const index = target?.dataset.pendingGrantIndex - - if (index === undefined) { - return - } - - this.removePendingAccessGrant(Number.parseInt(index, 10)) + private onPendingGrantRemoved (event: PendingGrantRemovedEvent) { + this.pendingAccessGrants = this.pendingAccessGrants.filter((_, pendingIndex) => pendingIndex !== event.detail.index) } private hasUnsavedChanges (): boolean { @@ -877,28 +504,6 @@ export default class AccessControlModal extends DialogComponent { return label(sym(subject.iri)).trim() || subject.iri } - private getEventValue (event: Event, fallback = ''): string { - const target = event.currentTarget as { value?: string | null } | null - return typeof target?.value === 'string' ? target.value : fallback - } - - private getSelectedComboboxOption (event: Event): ComboboxChangeEvent['detail']['option'] | undefined { - return (event as ComboboxChangeEvent).detail?.option - } - - private getSelectedStringComboboxOption (event: Event): StringComboboxOptionData | undefined { - const option = this.getSelectedComboboxOption(event) - return this.isStringComboboxOptionData(option) ? option : undefined - } - - private getSelectedComboboxOptionValue (event: Event): unknown { - return this.getSelectedComboboxOption(event)?.value - } - - private isStringComboboxOptionData (option: ComboboxOptionData | undefined): option is StringComboboxOptionData { - return typeof option?.value === 'string' - } - private async commitPrinciplesFromInput (): Promise { const rawValue = this.principalInputValue.trim() if (!rawValue) { @@ -995,7 +600,7 @@ export default class AccessControlModal extends DialogComponent { } private async classifyAccessTarget (principle: string) { - const normalizedOrigin = this.normalizeOriginInput(principle) + const normalizedOrigin = normalizeOriginInput(principle) return normalizedOrigin ? { kind: 'origin' as const, subjectValue: normalizedOrigin } : await solidLogicSingleton.acl.classifyAccessControlSubject(principle) @@ -1089,7 +694,7 @@ export default class AccessControlModal extends DialogComponent { } private async getPreferredAccessPrincipleOptions (query: string): Promise { - const normalizedOrigin = this.normalizeOriginInput(query) + const normalizedOrigin = normalizeOriginInput(query) if (normalizedOrigin) { return [{ label: `Use origin ${normalizedOrigin}`, @@ -1112,27 +717,6 @@ export default class AccessControlModal extends DialogComponent { return dedupeComboboxOptions(urlOption ? [urlOption] : []) } - private normalizeOriginInput (value: string): string | undefined { - const trimmedValue = value.trim() - if (!trimmedValue || isAbsoluteHttpUri(trimmedValue)) { - return undefined - } - - try { - const parsed = new URL(`https://${trimmedValue}`) - const isHttpOrigin = parsed.protocol === 'http:' || parsed.protocol === 'https:' - const hasOnlyOriginParts = parsed.pathname === '/' && !parsed.search && !parsed.hash - const isRecognizedHost = parsed.hostname.includes('.') || - parsed.hostname.includes(':') - - return isHttpOrigin && hasOnlyOriginParts && isRecognizedHost && !parsed.username && !parsed.password - ? parsed.origin - : undefined - } catch { - return undefined - } - } - private async createUrlOption (uri: string): Promise { const resolved = await this.resolveAccessTarget(uri) if (!resolved) { @@ -1149,48 +733,8 @@ export default class AccessControlModal extends DialogComponent { return { label: entry.label, value: entry.uri, - template: this.directoryEntryToOptionTemplate(entry) + template: directoryEntryOptionTemplate(entry) } } - private directoryEntryToOptionTemplate (entry: DirectoryEntry) { - return html` - - - ${this.renderDirectoryEntryIcon(entry)} - ${entry.label} - - ` - } - - private renderDirectoryEntryIcon (entry: DirectoryEntry) { - if (entry.sources.includes('contacts') || entry.sources.includes('groups')) { - return html`` - } - - if (entry.sources.includes('friends')) { - return html`` - } - - if (entry.sources.includes('catalog')) { - return html`` - } - - return nothing - } - - private async onCopyLinkClick (event: Event) { - event.preventDefault() - - if (!this.subjectUri) { - return - } - - try { - await navigator.clipboard.writeText(this.subjectUri) - } catch (error) { - debug.error('Failed to copy resource link', error) - } - } } diff --git a/src/components/access-control-modal/AccessControlSharedAccess.styles.css b/src/components/access-control-modal/AccessControlSharedAccess.styles.css new file mode 100644 index 000000000..5c4198cec --- /dev/null +++ b/src/components/access-control-modal/AccessControlSharedAccess.styles.css @@ -0,0 +1,153 @@ +:host { + h2 { + color: var(--solid-ui-color-gray-900, #101828); + font-size: var(--solid-ui-font-size-md, 1rem); + font-weight: 600; + } + + .access-grants-header, + .access-grants-list { + padding: 0 10px; + align-self: stretch; + } + + .access-grants-header { + display: flex; + align-items: center; + gap: 15px; + justify-content: space-between; + + .access-grants-search-input { + flex: 0 0 auto; + width: 12ch; + min-width: 12ch; + --solid-ui-input-border-color: transparent; + color: var(--solid-ui-color-zinc-500, #71717B); + font-size: var(--solid-ui-font-size-sm, .875rem); + font-weight: 500; + } + } + + .access-grants-list { + display: flex; + align-items: flex-start; + gap: 20px; + + ul { + display: flex; + flex-direction: column; + height: 195px; + padding: 15px; + align-items: flex-start; + gap: 15px; + flex: 1 0 0; + min-height: 0; + box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; + border-radius: 10px; + border: 1px solid var(--solid-ui-color-gray-200, #E5E7EB); + } + + li { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + width: 100%; + + .access-grants-image { + display: flex; + justify-content: center; + align-items: center; + aspect-ratio: 1/1; + width: 30px; + height: 30px; + flex: 0 0 30px; + border-radius: 999px; + overflow: hidden; + color: var(--solid-ui-color-white, #FFF); + text-align: center; + font-size: var(--solid-ui-font-size-xs, .75rem); + font-weight: 500; + + img { + display: flex; + width: 100%; + height: 100%; + border-radius: 999px; + object-fit: cover; + } + + span { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + line-height: 1; + } + } + + .access-grants-image--agentGroup { + background: var(--solid-ui-color-blue-900, #083575); + } + + .access-grants-image--agent, + .access-grants-image--agentClass { + background: var(--solid-ui-color-lavender-300, #e6dcff); + } + + .access-grants-image--origin { + background: var(--solid-ui-color-green-700, #15803D); + } + + .access-grants-image--unknown { + background: var(--solid-ui-color-gray-300, #D1D5DB); + } + + h3 { + flex: 1 1 auto; + min-width: 0; + color: var(--solid-ui-color-gray-800, #1E2939); + font-size: var(--solid-ui-font-size-xs, .75rem); + font-weight: 600; + } + + span { + margin-left: auto; + flex: 0 0 auto; + text-align: right; + } + + .access-grants-role { + margin-left: auto; + flex: 0 0 auto; + font-size: var(--solid-ui-font-size-xs, .75rem); + } + + .access-grants-role--owner { + font-size: var(--solid-ui-font-size-xs, .75rem); + } + + .access-grants-role--editable { + max-width: 100%; + } + } + } + + .access-role-select--compact { + width: 110px; + min-width: 110px; + flex: 0 0 110px; + max-width: 100%; + --solid-ui-input-border-color: transparent; + margin-top: 0; + } + + .access-grants-list li .access-role-select--compact { + width: 110px; + min-width: 110px; + flex: 0 0 110px; + } +} diff --git a/src/components/access-control-modal/AccessControlSharedAccess.ts b/src/components/access-control-modal/AccessControlSharedAccess.ts new file mode 100644 index 000000000..4eab7e0c5 --- /dev/null +++ b/src/components/access-control-modal/AccessControlSharedAccess.ts @@ -0,0 +1,195 @@ +import { customElement, WebComponent } from '@/lib/components' +import { html } from 'lit' +import { property, state } from 'lit/decorators.js' +import { ACCESS_ROLES, type AccessRole } from 'solid-logic' +import { findImage } from '@/widgets' +import type { ComboboxChangeEvent } from '@/components/combobox' +import { sym } from 'rdflib' + +import '@/components/combobox' +import '@/components/combobox-option' + +import styles from './AccessControlSharedAccess.styles.css' +import type { AccessGrantRoleChangedDetail, AccessGrantViewModel } from './types' + +@customElement('solid-ui-access-control-shared-access') +export default class AccessControlSharedAccess extends WebComponent { + static styles = styles + + @property({ attribute: false }) + accessor accessGrantViewModels: AccessGrantViewModel[] = [] + + @state() + private accessor searchValue = '' + + private onSearchInput (event: Event) { + const target = event.currentTarget as { value?: string | null } | null + this.searchValue = typeof target?.value === 'string' ? target.value : '' + } + + private onSearchSelect (event: ComboboxChangeEvent) { + const option = event.detail.option + if (typeof option.label !== 'string') { + return + } + + this.searchValue = option.label + } + + private onAccessGrantRoleChange (index: number, event: ComboboxChangeEvent) { + const value = event.detail.option.value + if (typeof value !== 'string' || !ACCESS_ROLES.includes(value as AccessRole)) { + return + } + + const detail: AccessGrantRoleChangedDetail = { + index, + role: value as AccessRole + } + + this.dispatchEvent(new CustomEvent('access-grant-role-changed', { + detail, + bubbles: true, + composed: true + })) + } + + private getFilteredAccessGrants () { + const query = this.searchValue.trim().toLowerCase() + + return this.accessGrantViewModels.filter(({ subjectLabel, subjects }) => { + if (!subjects.length) { + return false + } + + return !query || subjectLabel.toLowerCase().includes(query) + }) + } + + private renderAccessGrant (entry: AccessGrantViewModel) { + return html` +
  • + ${this.renderAuthorizationBadge(entry.authorization, entry.subjectLabel)} +

    ${entry.subjectLabel}

    + ${this.renderAuthorizationRole(entry.role, entry.index)} +
  • + ` + } + + private renderAuthorizationBadge (authorization: AccessGrantViewModel['authorization'], subjectLabel: string) { + const badge = this.getAuthorizationBadge(authorization, subjectLabel) + + return html` +
    + ${badge.image + ? html`` + : html``} +
    + ` + } + + private getAuthorizationBadge ( + authorization: AccessGrantViewModel['authorization'], + subjectLabel: string + ) { + const agent = authorization.agent[0] + if (agent) { + const image = findImage(sym(agent)) + return { + kind: 'agent' as const, + image, + text: image ? '' : this.getInitials(subjectLabel, 2) + } + } + + const agentGroup = authorization.agentGroup[0] + if (agentGroup) { + return { + kind: 'agentGroup' as const, + text: this.getInitials(subjectLabel, 1) + } + } + + const agentClass = authorization.agentClass[0] + if (agentClass) { + const image = findImage(sym(agentClass)) + return { + kind: 'agentClass' as const, + image, + text: image ? '' : this.getInitials(subjectLabel, 2) + } + } + + if (authorization.origin[0]) { + return { + kind: 'origin' as const, + text: 'O' + } + } + + return { + kind: 'unknown' as const, + text: '?' + } + } + + private getInitials (value: string, maxWords = 2): string { + const parts = value.split(/\s+/).filter(Boolean).slice(0, maxWords) + const initials = parts.map(part => part[0]).join('') + return (initials || value.slice(0, maxWords)).toUpperCase() + } + + private renderAuthorizationRole (role: AccessRole, index: number) { + return html` + this.onAccessGrantRoleChange(index, event)} + > + ${this.renderGrantRoleOptions()} + + ` + } + + private renderGrantRoleOptions () { + return ACCESS_ROLES.map(role => html` + ${role === 'No Access' ? 'Remove' : role} + `) + } + + protected render () { + const accessGrants = this.getFilteredAccessGrants() + + return html` +
    +
    +

    Share with

    + + ${this.accessGrantViewModels + .filter(({ subjects }) => subjects.length > 0) + .map(({ subjectLabel }) => html` + + ${subjectLabel} + + `)} + +
    +
    +
      + ${accessGrants.length > 0 + ? accessGrants.map(entry => this.renderAccessGrant(entry)) + : html`
    • No access grants
    • `} +
    +
    +
    + ` + } +} diff --git a/src/components/access-control-modal/helpers.ts b/src/components/access-control-modal/helpers.ts index 53cf25ff6..330f01758 100644 --- a/src/components/access-control-modal/helpers.ts +++ b/src/components/access-control-modal/helpers.ts @@ -24,6 +24,27 @@ export function isOriginUrl (value: string): boolean { !url.password } +export function normalizeOriginInput (value: string): string | undefined { + const trimmedValue = value.trim() + if (!trimmedValue || isAbsoluteHttpUri(trimmedValue)) { + return undefined + } + + try { + const parsed = new URL(`https://${trimmedValue}`) + const isHttpOrigin = parsed.protocol === 'http:' || parsed.protocol === 'https:' + const hasOnlyOriginParts = parsed.pathname === '/' && !parsed.search && !parsed.hash + const isRecognizedHost = parsed.hostname.includes('.') || + parsed.hostname.includes(':') + + return isHttpOrigin && hasOnlyOriginParts && isRecognizedHost && !parsed.username && !parsed.password + ? parsed.origin + : undefined + } catch { + return undefined + } +} + export function dedupeByKey (items: T[], getKey: (item: T) => string | undefined): T[] { const uniqueItems = new Map() diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index 55530ad2f..b61284590 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -1,5 +1,34 @@ import type { AccessControlSubjectKind, AccessRole, AccessSubject, Authorization } from 'solid-logic' +export type TargetInputDetail = { + value: string +} + +export type TargetSelectedDetail = { + value: string + label: string +} + +export type RoleChangedDetail = { + role: AccessRole +} + +export type PendingGrantRemovedDetail = { + index: number +} + +export type TargetInputEvent = CustomEvent +export type TargetSelectedEvent = CustomEvent +export type RoleChangedEvent = CustomEvent +export type PendingGrantRemovedEvent = CustomEvent + +export type AccessGrantRoleChangedDetail = { + index: number + role: AccessRole +} + +export type AccessGrantRoleChangedEvent = CustomEvent + export type AccessControlBadgeKind = AccessControlSubjectKind | 'unknown' export type AccessControlBadge = { @@ -21,7 +50,6 @@ export type AccessGrantEntry = { index: number role: AccessRole subjectLabel: string - badge: AccessControlBadge } export type AccessGrantViewModel = AccessGrantEntry & { diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index d6b10d63a..8588275e5 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -312,7 +312,9 @@ describe('AccessControlModal submit', () => { expect(planGrant).toHaveBeenCalledTimes(grants.length) expect(applyPlan).toHaveBeenCalledTimes(grants.length - failedDuringPlanning.size) - const saveErrorState = element.shadowRoot.querySelector('solid-ui-error-state') + const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') + await errors.updateComplete + const saveErrorState = errors.shadowRoot.querySelector('solid-ui-error-state') expect(saveErrorState).not.toBeNull() const errorItems = [...saveErrorState.querySelectorAll('li')].map((item: Element) => item.textContent) const errorLabels = [...saveErrorState.querySelectorAll('li .access-control-save-error-label')].map((label: Element) => label.textContent) @@ -366,8 +368,10 @@ describe('AccessControlModal submit', () => { target: `${successfulGrant.subjectValue}.acl` })) expect(close).not.toHaveBeenCalled() - expect(element.shadowRoot.querySelector('solid-ui-error-state')).not.toBeNull() - expect([...element.shadowRoot.querySelectorAll('solid-ui-error-state li .access-control-save-error-label')].map((label: Element) => label.textContent)) + const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') + await errors.updateComplete + expect(errors.shadowRoot.querySelector('solid-ui-error-state')).not.toBeNull() + expect([...errors.shadowRoot.querySelectorAll('solid-ui-error-state li .access-control-save-error-label')].map((label: Element) => label.textContent)) .toEqual(['Failed agent']) const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement @@ -399,7 +403,9 @@ describe('AccessControlModal submit', () => { expect(planGrant).toHaveBeenCalledTimes(grants.length) expect(applyPlan).not.toHaveBeenCalled() expect(close).not.toHaveBeenCalled() - expect([...element.shadowRoot.querySelectorAll('solid-ui-error-state li .access-control-save-error-label')].map((label: Element) => label.textContent)) + const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') + await errors.updateComplete + expect([...errors.shadowRoot.querySelectorAll('solid-ui-error-state li .access-control-save-error-label')].map((label: Element) => label.textContent)) .toEqual(['Failed agent 0', 'Failed agent 1', 'Failed agent 2']) document.body.removeChild(element) @@ -451,7 +457,9 @@ describe('AccessControlModal submit', () => { await element.updateComplete expect(close).not.toHaveBeenCalled() - const errorState = element.shadowRoot.querySelector('solid-ui-error-state') + const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') + await errors.updateComplete + const errorState = errors.shadowRoot.querySelector('solid-ui-error-state') expect(errorState).not.toBeNull() expect(errorState.textContent).toContain('Could not save access changes') expect([...errorState.querySelectorAll('li .access-control-save-error-label')].map((label: Element) => label.textContent)) @@ -489,15 +497,13 @@ describe('AccessControlModal submit', () => { document.body.appendChild(element) await element.updateComplete - const roleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--top') - roleSelect.dispatchEvent(new CustomEvent('change', { + const addAccess = element.shadowRoot.querySelector('solid-ui-access-control-add-access') + await addAccess.updateComplete + addAccess.dispatchEvent(new CustomEvent('role-changed', { bubbles: true, composed: true, detail: { - option: { - value: 'Editor', - label: 'Editor' - } + role: 'Editor' } })) @@ -521,7 +527,9 @@ describe('AccessControlModal submit', () => { document.body.appendChild(element) await element.updateComplete - const sharedRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--authenticated') + const generalAccess = element.shadowRoot.querySelector('solid-ui-access-control-general-access') + await generalAccess.updateComplete + const sharedRoleSelect = generalAccess.shadowRoot.querySelector('solid-ui-combobox.access-role-select--authenticated') sharedRoleSelect.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, @@ -562,7 +570,9 @@ describe('AccessControlModal submit', () => { .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement expect(saveButton.hasAttribute('disabled')).toBe(true) - const grantRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') + await sharedAccess.updateComplete + const grantRoleSelect = sharedAccess.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') grantRoleSelect.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, @@ -586,9 +596,13 @@ describe('AccessControlModal submit', () => { document.body.appendChild(element) await element.updateComplete - const addRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--top') - const authenticatedRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--authenticated') - const publicRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--public') + const addAccess = element.shadowRoot.querySelector('solid-ui-access-control-add-access') + const generalAccess = element.shadowRoot.querySelector('solid-ui-access-control-general-access') + await generalAccess.updateComplete + const authenticatedRoleSelect = generalAccess.shadowRoot.querySelector('solid-ui-combobox.access-role-select--authenticated') + const publicRoleSelect = generalAccess.shadowRoot.querySelector('solid-ui-combobox.access-role-select--public') + await addAccess.updateComplete + const addRoleSelect = addAccess.shadowRoot.querySelector('solid-ui-combobox.access-role-select--top') const addOptions = [...addRoleSelect.querySelectorAll('solid-ui-combobox-option')].map((option: Element) => option.textContent?.trim()) const authenticatedOptions = [...authenticatedRoleSelect.querySelectorAll('solid-ui-combobox-option')].map((option: Element) => option.textContent?.trim()) @@ -616,7 +630,9 @@ describe('AccessControlModal submit', () => { document.body.appendChild(element) await element.updateComplete - const grantRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') + await sharedAccess.updateComplete + const grantRoleSelect = sharedAccess.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') const grantOptions = [...grantRoleSelect.querySelectorAll('solid-ui-combobox-option')].map((option: Element) => option.textContent?.trim()) expect(grantOptions).toContain('Remove') @@ -625,7 +641,7 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) - it('keeps authenticated and public grants out of the shared-with list', async () => { + it('keeps authenticated and public grants out of the shared-access list', async () => { const element = document.createElement('solid-ui-access-control-modal') as any element.accessGrants = [ { @@ -654,10 +670,12 @@ describe('AccessControlModal submit', () => { document.body.appendChild(element) await element.updateComplete - const sharedWithLabels = [...element.shadowRoot.querySelectorAll('.access-grants-list h3')].map((label: Element) => label.textContent?.trim()) - expect(sharedWithLabels).toEqual(['card.ttl']) + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') + await sharedAccess.updateComplete + const sharedAccessLabels = [...sharedAccess.shadowRoot.querySelectorAll('.access-grants-list h3')].map((label: Element) => label.textContent?.trim()) + expect(sharedAccessLabels).toEqual(['card.ttl']) - const searchOptions = [...element.shadowRoot.querySelectorAll('solid-ui-combobox.access-grants-search-input solid-ui-combobox-option')] + const searchOptions = [...sharedAccess.shadowRoot.querySelectorAll('solid-ui-combobox.access-grants-search-input solid-ui-combobox-option')] .map((option: Element) => option.textContent?.trim()) expect(searchOptions).toEqual(['card.ttl']) @@ -693,21 +711,23 @@ describe('AccessControlModal submit', () => { origin: [], mode: ['Read'] }) - const entries = element.getSharedAccessGrantEntries() + const entries = element.getAccessGrantViewModels().filter(({ subjects }) => subjects.length > 0) expect(entries).toHaveLength(1) expect(entries[0].authorization.agentClass).toEqual([agentClass]) expect(entries[0].subjectLabel).toBe(expectedLabel) expect(element.accessGrants[0].agentClass).toEqual([generalClass, agentClass]) - expect(element.shadowRoot.querySelector('.access-grants-list h3').textContent.trim()).toBe(expectedLabel) - const searchOptions = [...element.shadowRoot.querySelectorAll('solid-ui-combobox.access-grants-search-input solid-ui-combobox-option')] + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') + await sharedAccess.updateComplete + expect(sharedAccess.shadowRoot.querySelector('.access-grants-list h3').textContent.trim()).toBe(expectedLabel) + const searchOptions = [...sharedAccess.shadowRoot.querySelectorAll('solid-ui-combobox.access-grants-search-input solid-ui-combobox-option')] .map((option: Element) => option.textContent?.trim()) expect(searchOptions).toEqual([expectedLabel]) - element.searchValue = 'card.ttl' - await element.updateComplete - expect(element.shadowRoot.querySelector('.access-grants-list h3').textContent.trim()).toBe(expectedLabel) + sharedAccess.searchValue = 'card.ttl' + await sharedAccess.updateComplete + expect(sharedAccess.shadowRoot.querySelector('.access-grants-list h3').textContent.trim()).toBe(expectedLabel) - const roleSelect = element.shadowRoot.querySelector('.access-grants-list solid-ui-combobox') + const roleSelect = sharedAccess.shadowRoot.querySelector('.access-grants-list solid-ui-combobox') roleSelect.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, @@ -738,7 +758,9 @@ describe('AccessControlModal submit', () => { document.body.appendChild(element) await element.updateComplete - const grantRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') + await sharedAccess.updateComplete + const grantRoleSelect = sharedAccess.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') grantRoleSelect.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, @@ -791,7 +813,9 @@ describe('AccessControlModal submit', () => { document.body.appendChild(element) await element.updateComplete - const grantRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') + await sharedAccess.updateComplete + const grantRoleSelect = sharedAccess.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') grantRoleSelect.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, From 3ace3b4cd9d3613b97d13b2f0b0409ce69d2053e Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 14 Oct 2026 08:10:33 +1100 Subject: [PATCH 06/11] more refactoring and cleanup --- .../AccessControlModal.ts | 423 ++++-------------- .../AccessControlSharedAccess.ts | 10 +- .../access-control-modal/accessGrantDrafts.ts | 99 ++++ .../accessTargetResolver.ts | 149 ++++++ .../applyAccessChanges.ts | 33 ++ src/components/access-control-modal/types.ts | 6 +- test/components/access-control-modal.test.ts | 9 +- .../accessGrantDrafts.test.ts | 68 +++ .../accessTargetResolver.test.ts | 25 ++ 9 files changed, 473 insertions(+), 349 deletions(-) create mode 100644 src/components/access-control-modal/accessGrantDrafts.ts create mode 100644 src/components/access-control-modal/accessTargetResolver.ts create mode 100644 src/components/access-control-modal/applyAccessChanges.ts create mode 100644 test/components/access-control-modal/accessGrantDrafts.test.ts create mode 100644 test/components/access-control-modal/accessTargetResolver.test.ts diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index 087c0f718..2702231f8 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -4,8 +4,7 @@ import type { PropertyValues } from 'lit' import { property, state } from 'lit/decorators.js' import { label } from '@/utils/label' import * as debug from '@/lib/debug' -import { Authenticated, DEFAULT_DIRECTORY_SOURCES, PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton, type AccessControlSubjectKind, type AccessMode, type AccessRole, type AccessSubject, type Authorization, type DirectoryEntry } from 'solid-logic' -import { defineAsyncComboboxOptionsProvider, type ComboboxOptionData } from '@/components/combobox' +import { Authenticated, Public, solidLogicSingleton, type AccessRole, type AccessSubject, type Authorization } from 'solid-logic' import { sym } from 'rdflib' import '~icons/lucide/circle-x' @@ -15,14 +14,16 @@ import '@/components/dialog-footer' import '@/components/button' import '@/components/combobox' import '@/components/combobox-option' -import { directoryEntryOptionTemplate } from './AccessControlAddAccess' import './AccessControlSharedAccess' import './AccessControlGeneralAccess' import './AccessControlErrors' import styles from './AccessControlModal.styles.css' -import type { AccessChange, AccessControlError, AccessGrantRoleChangedEvent, AccessGrantViewModel, AccessSaveError, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' -import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, normalizeOriginInput, type StringComboboxOptionData } from './helpers' +import type { AccessChange, AccessControlError, AccessGrantDraft, AccessGrantRoleChangedEvent, AccessSaveError, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' +import { dedupeByKey } from './helpers' +import { createAccessGrantDraft, getAccessGrantId, getAccessGrantViewModels, getAccessSubjectLabel, getAuthenticatedAccessRole, getAuthorizationSubjectLabel, getChangedAccessGrants, getPublicAccessRole, getSharedAuthorization } from './accessGrantDrafts' +import { createAccessPrincipalOptionsProvider, resolveAccessTarget } from './accessTargetResolver' +import { applyAccessChanges } from './applyAccessChanges' function isDefined (value: T | undefined | null): value is T { return value !== undefined && value !== null @@ -30,13 +31,9 @@ function isDefined (value: T | undefined | null): value is T { type PendingAccessGrantCreationResult = | { grant: PendingAccessGrant } - | { error: AccessControlError } + | { error: InputAccessControlError } -type ResolvedAccessTarget = { - subjectType: AccessControlSubjectKind - subjectValue: string - label: string -} +type InputAccessControlError = Omit & { action: 'input' } @customElement('solid-ui-access-control-modal') export default class AccessControlModal extends DialogComponent { @@ -67,15 +64,15 @@ export default class AccessControlModal extends DialogComponent { private accessor pendingAccessGrants: PendingAccessGrant[] = [] @state() - private accessor accessGrantRoles: AccessRole[] = [] - - @state() - private accessor accessGrantLabels: string[] = [] + private accessor accessGrantDrafts: AccessGrantDraft[] = [] @state() private accessor errors: AccessControlError[] = [] + private accessGrantLabelRequestVersion = 0 + disconnectedCallback (): void { + this.accessGrantLabelRequestVersion++ this.errors = [] super.disconnectedCallback() } @@ -88,13 +85,25 @@ export default class AccessControlModal extends DialogComponent { return this.errors.filter((error): error is AccessSaveError => error.action !== 'input') } + private setInputError (error: InputAccessControlError) { + this.errors = [ + ...this.errors.filter(item => item.action !== 'input'), + error + ] + } + + private clearInputError () { + this.errors = this.errors.filter(error => error.action !== 'input') + } + protected willUpdate (changedProperties: PropertyValues) { super.willUpdate(changedProperties) if (changedProperties.has('accessGrants')) { - this.accessGrantRoles = this.accessGrants?.map(item => this.getAuthorizationRole(item)) ?? [] - this.authenticatedAccessRoleValue = this.getAuthenticatedAccessRole() - this.publicAccessRoleValue = this.getPublicAccessRole() + const accessGrants = this.accessGrants ?? [] + this.accessGrantDrafts = accessGrants.map(createAccessGrantDraft) + this.authenticatedAccessRoleValue = getAuthenticatedAccessRole(accessGrants) + this.publicAccessRoleValue = getPublicAccessRole(accessGrants) } } @@ -102,14 +111,17 @@ export default class AccessControlModal extends DialogComponent { super.updated(changedProperties) if (changedProperties.has('accessGrants')) { - void this.refreshAccessGrantLabels() + this.refreshAccessGrantLabels().catch((error: unknown) => { + debug.error('Failed to refresh access grant labels', error) + }) } } private async refreshAccessGrantLabels () { + const requestVersion = ++this.accessGrantLabelRequestVersion const grants = this.accessGrants ?? [] if (!grants.length) { - this.accessGrantLabels = [] + this.accessGrantDrafts = [] return } @@ -123,89 +135,23 @@ export default class AccessControlModal extends DialogComponent { } } - return this.getAuthorizationSubjectLabel(this.getSharedAuthorization(authorization)) + return getAuthorizationSubjectLabel(getSharedAuthorization(authorization)) })) - if (this.accessGrants === grants) { - this.accessGrantLabels = labels - } - } - - private getAccessGrantViewModels (): AccessGrantViewModel[] { - return (this.accessGrants ?? []) - .map((authorization, index) => { - const sharedAuthorization = this.getSharedAuthorization(authorization) - const role = this.accessGrantRoles[index] ?? this.getAuthorizationRole(sharedAuthorization) - const subjectLabel = this.accessGrantLabels[index] ?? this.getAuthorizationSubjectLabel(sharedAuthorization) - - return { - authorization: sharedAuthorization, - index, - role, - initialRole: this.getAuthorizationRole(authorization), - subjectLabel, - subjects: this.getAuthorizationSubjectEntries(sharedAuthorization) - } - }) - .sort((left, right) => { - const leftIsOwner = left.role === 'Owner' - const rightIsOwner = right.role === 'Owner' - - if (leftIsOwner && !rightIsOwner) return -1 - if (!leftIsOwner && rightIsOwner) return 1 - return left.index - right.index - }) - } - - private getAuthorizationSubjectIris (authorization: Authorization) { - return [ - ...authorization.agent, - ...authorization.agentGroup, - ...authorization.agentClass, - ...authorization.origin, - ] - } - - private getAuthorizationSubjectLabel (authorization: Authorization): string { - const subjects = this.getAuthorizationSubjectIris(authorization) - return subjects.length ? subjects.map(subject => label(sym(subject))).join(', ') : 'Unknown access holder' - } - - private getAuthorizationRole (authorization: Authorization): AccessRole { - return solidLogicSingleton.acl.roleFromModes(authorization.mode) - } - - private getSharedAuthorization (authorization: Authorization): Authorization { - return { - ...authorization, - agentClass: authorization.agentClass.filter(iri => iri !== Authenticated.iri && iri !== Public.iri) + if (requestVersion === this.accessGrantLabelRequestVersion && this.accessGrants === grants) { + const labelsById = new Map( + grants.map((authorization, index) => [ + getAccessGrantId(getSharedAuthorization(authorization)), + labels[index] + ]) + ) + this.accessGrantDrafts = this.accessGrantDrafts.map(draft => ({ + ...draft, + subjectLabel: labelsById.get(draft.id) ?? draft.subjectLabel + })) } } - private getAuthenticatedAccessRole (): AccessRole { - const authorization = (this.accessGrants ?? []).find(item => - item.agentClass.includes(Authenticated.iri) - ) - - return authorization ? this.getAuthorizationRole(authorization) : 'No Access' - } - - private getPublicAccessRole (): AccessRole { - const authorization = (this.accessGrants ?? []).find(item => - item.agentClass.includes(Public.iri) - ) - - if (!authorization) { - return 'No Access' - } - - return solidLogicSingleton.acl.publicRoleFromModes(authorization.mode) - } - - private getRoleModes (role: AccessRole): AccessMode[] { - return solidLogicSingleton.acl.modesFromRole(role) - } - private getDialogTitle (): string { const subject = this.subjectUri ? sym(this.subjectUri) : undefined const subjectLabel = subject ? label(subject).trim() : '' @@ -229,14 +175,14 @@ export default class AccessControlModal extends DialogComponent { .addAccessRoleValue=${this.addAccessRoleValue} .pendingAccessGrants=${this.pendingAccessGrants} .inputError=${this.inputError} - .asyncOptionsProvider=${this.accessPrincipleOptionsProvider} + .asyncOptionsProvider=${createAccessPrincipalOptionsProvider()} @target-input=${this.onPrincipalInput} @target-selected=${this.onPrincipalSelect} @role-changed=${this.onAddAccessRoleInput} @pending-grant-removed=${this.onPendingGrantRemoved} > { return } - await this.commitPrinciplesFromInputSafely() + await this.commitPrincipalFromInputSafely() } private async onSaveClick () { @@ -291,7 +237,7 @@ export default class AccessControlModal extends DialogComponent { } if (this.principalInputValue.trim()) { - const committed = await this.commitPrinciplesFromInputSafely() + const committed = await this.commitPrincipalFromInputSafely() if (!committed) { return @@ -310,7 +256,7 @@ export default class AccessControlModal extends DialogComponent { return } - const changedAccessGrants = this.getChangedAccessGrants() + const changedAccessGrants = getChangedAccessGrants(this.accessGrantDrafts) const generalAccessChanges = this.getGeneralAccessChanges() if (!this.pendingAccessGrants.length && !changedAccessGrants.length && !generalAccessChanges.length) { @@ -336,41 +282,15 @@ export default class AccessControlModal extends DialogComponent { subjects.map(subject => ({ subject, role, - label: this.getAccessSubjectLabel(subject) + label: getAccessSubjectLabel(subject) })) ) ] - const appliedChanges: AccessChange[] = [] - - for (const { subject, label, role } of accessChanges) { - try { - const plan = role === 'No Access' - ? await solidLogicSingleton.acl.planRevoke(this.subjectUri, subject) - : await solidLogicSingleton.acl.planGrant(this.subjectUri, subject, subject.type === 'agentClass' && subject.iri === Public.iri - ? solidLogicSingleton.acl.modesFromPublicRole(role as (typeof PUBLIC_ACCESS_ROLES)[number]) - : this.getRoleModes(role)) - - await solidLogicSingleton.acl.applyPlan(plan) - appliedChanges.push({ subject, role, label }) - } catch (error) { - this.errors = [ - ...this.errors, - { - action: role === 'No Access' ? 'revoke' : 'grant', - targetUri: subject.iri, - label, - role, - message: error instanceof Error - ? error.message - : 'Unable to apply access change.' - } - ] - } - } - - if (this.saveErrors.length) { - debug.error('Failed to save some access changes', this.saveErrors) + const saveErrors = await applyAccessChanges(this.subjectUri, accessChanges) + if (saveErrors.length) { + this.errors = [...this.errors, ...saveErrors] + debug.error('Failed to save some access changes', saveErrors) return } @@ -396,11 +316,11 @@ export default class AccessControlModal extends DialogComponent { private onPrincipalInput (event: TargetInputEvent) { this.principalInputValue = event.detail.value - this.errors = this.errors.filter(error => error.action !== 'input') + this.clearInputError() } private onPrincipalSelect (event: TargetSelectedEvent) { - this.queuePendingPrinciple(event.detail.value, this.addAccessRoleValue, event.detail.label) + this.queuePendingPrincipal(event.detail.value, this.addAccessRoleValue, event.detail.label) } private getGeneralAccessChanges (): AccessChange[] { @@ -408,8 +328,9 @@ export default class AccessControlModal extends DialogComponent { return [] } - const currentAuthenticatedRole = this.getAuthenticatedAccessRole() - const currentPublicRole = this.getPublicAccessRole() + const accessGrants = this.accessGrants ?? [] + const currentAuthenticatedRole = getAuthenticatedAccessRole(accessGrants) + const currentPublicRole = getPublicAccessRole(accessGrants) const authenticatedChange = this.authenticatedAccessRoleValue === currentAuthenticatedRole ? undefined @@ -447,9 +368,11 @@ export default class AccessControlModal extends DialogComponent { } private onAccessGrantRoleInput (event: AccessGrantRoleChangedEvent) { - this.accessGrantRoles = this.accessGrantRoles.map((role, roleIndex) => { - return roleIndex === event.detail.index ? event.detail.role : role - }) + this.accessGrantDrafts = this.accessGrantDrafts.map(draft => + draft.id === event.detail.id + ? { ...draft, role: event.detail.role } + : draft + ) } private onPendingGrantRemoved (event: PendingGrantRemovedEvent) { @@ -460,98 +383,57 @@ export default class AccessControlModal extends DialogComponent { return Boolean( this.pendingAccessGrants.length || this.principalInputValue.trim() || - this.getChangedAccessGrants().length || + getChangedAccessGrants(this.accessGrantDrafts).length || this.getGeneralAccessChanges().length ) } - private getChangedAccessGrants (): ChangedAccessGrant[] { - return this.getAccessGrantViewModels() - .filter(({ role, initialRole, subjects }) => { - if (role === initialRole) { - return false - } - - return subjects.length > 0 - }) - .map(({ authorization, subjects, role }) => ({ authorization, subjects, role })) - } - - private getAuthorizationSubjectEntries (authorization: Authorization): AccessSubject[] { - const subjectSets: AuthorizationSubjectSet[] = [ - { type: 'agent', iris: authorization.agent }, - { type: 'agentGroup', iris: authorization.agentGroup }, - { type: 'agentClass', iris: authorization.agentClass }, - { type: 'origin', iris: authorization.origin } - ] - - return subjectSets.flatMap(({ type, iris }) => iris.map(iri => ({ type, iri }))) - } - private createAccessSubject (type: AccessSubject['type'], iri: string): AccessSubject { return { type, iri } } - private getAccessSubjectLabel (subject: AccessSubject): string { - if (subject.iri === Authenticated.iri) { - return 'Authenticated users' - } - - if (subject.iri === Public.iri) { - return 'Public' - } - - return label(sym(subject.iri)).trim() || subject.iri - } - - private async commitPrinciplesFromInput (): Promise { + private async commitPrincipalFromInput (): Promise { const rawValue = this.principalInputValue.trim() if (!rawValue) { return false } - return this.queuePendingPrinciple(rawValue, this.addAccessRoleValue, undefined, rawValue) + return this.queuePendingPrincipal(rawValue, this.addAccessRoleValue, undefined, rawValue) } - private async commitPrinciplesFromInputSafely (): Promise { + private async commitPrincipalFromInputSafely (): Promise { try { - return await this.commitPrinciplesFromInput() + return await this.commitPrincipalFromInput() } catch (error) { - this.errors = [ - ...this.errors.filter(item => item.action !== 'input'), - { - action: 'input', - label: 'Access target', - targetUri: this.principalInputValue.trim(), - message: 'We could not verify this access target. Check the value and try again.' - } - ] + this.setInputError({ + action: 'input', + label: 'Access target', + targetUri: this.principalInputValue.trim(), + message: 'We could not verify this access target. Check the value and try again.' + }) debug.error('Failed to commit pending access grants', error) return false } } - private async queuePendingPrinciple ( - principle: string, + private async queuePendingPrincipal ( + principal: string, role: AccessRole = this.addAccessRoleValue, preferredLabel?: string, inputSnapshot?: string ): Promise { const snapshot = inputSnapshot ?? this.principalInputValue.trim() - const result = await this.createPendingAccessGrant(principle, role, preferredLabel) + const result = await this.createPendingAccessGrant(principal, role, preferredLabel) if ('error' in result) { - this.errors = [ - ...this.errors.filter(item => item.action !== 'input'), - result.error - ] + this.setInputError(result.error) debug.error('Failed to add access grant:', result.error.message) return false } const merged = [...this.pendingAccessGrants, result.grant] this.pendingAccessGrants = this.dedupePendingAccessGrants(merged) - this.errors = this.errors.filter(error => error.action !== 'input') + this.clearInputError() if (this.principalInputValue.trim() === snapshot) { this.principalInputValue = '' @@ -561,19 +443,19 @@ export default class AccessControlModal extends DialogComponent { } private async createPendingAccessGrant ( - principle: string, + principal: string, role: AccessRole = this.addAccessRoleValue, preferredLabel?: string ): Promise { try { - const normalizedPrinciple = principle.trim() - const resolvedTarget = await this.resolveAccessTarget(normalizedPrinciple, preferredLabel) + const normalizedPrincipal = principal.trim() + const resolvedTarget = await resolveAccessTarget(normalizedPrincipal, preferredLabel) if (!resolvedTarget) { return { error: { action: 'input', label: 'Access target', - targetUri: normalizedPrinciple, + targetUri: normalizedPrincipal, message: 'Could not verify this access target' } } @@ -592,149 +474,14 @@ export default class AccessControlModal extends DialogComponent { error: { action: 'input', label: 'Access target', - targetUri: principle, + targetUri: principal, message: error instanceof Error ? error.message : 'Could not verify this access target' } } } } - private async classifyAccessTarget (principle: string) { - const normalizedOrigin = normalizeOriginInput(principle) - return normalizedOrigin - ? { kind: 'origin' as const, subjectValue: normalizedOrigin } - : await solidLogicSingleton.acl.classifyAccessControlSubject(principle) - } - - private async resolveAccessTargetLabel ( - resolvedPrinciple: Awaited>, - preferredLabel?: string - ): Promise { - if (!resolvedPrinciple || resolvedPrinciple.kind === 'origin') { - return resolvedPrinciple?.subjectValue - } - - try { - const subject = sym(resolvedPrinciple.subjectValue) - await solidLogicSingleton.store.fetcher.load(subject.doc()) - const resolvedLabel = preferredLabel?.trim() || label(subject).trim() - if (!resolvedLabel || resolvedLabel === resolvedPrinciple.subjectValue) { - return undefined - } - - return resolvedLabel - } catch { - return undefined - } - } - - private async resolveAccessTarget (principle: string, preferredLabel?: string): Promise { - const resolvedPrinciple = await this.classifyAccessTarget(principle) - if (!resolvedPrinciple) { - return undefined - } - - const resolvedLabel = await this.resolveAccessTargetLabel(resolvedPrinciple, preferredLabel) - if (!resolvedLabel) { - return undefined - } - - return { - subjectType: resolvedPrinciple.kind, - subjectValue: resolvedPrinciple.subjectValue, - label: resolvedLabel - } - } - private dedupePendingAccessGrants (draftGrants: PendingAccessGrant[]): PendingAccessGrant[] { return dedupeByKey(draftGrants, draftGrant => `${draftGrant.subjectType}:${draftGrant.subjectValue}`) } - - private readonly accessPrincipleOptionsProvider = defineAsyncComboboxOptionsProvider(async (filter: string) => { - const query = filter.trim() - const preferredOptions = await this.getPreferredAccessPrincipleOptions(query) - - if (query.length < 2) { - return this.getShortQueryOptions(preferredOptions) - } - - let entries: DirectoryEntry[] = [] - - try { - entries = await solidLogicSingleton.directory.search({ - query, - sources: DEFAULT_DIRECTORY_SOURCES - }) - } catch (error) { - if (!preferredOptions.length) { - throw error - } - } - - const verifiedEntries = await Promise.all(entries.map(async entry => { - const resolved = await this.resolveAccessTarget(entry.uri, entry.label) - return resolved ? { entry, label: resolved.label } : undefined - })) - const options = verifiedEntries - .filter(isDefined) - .map(({ entry, label: entryLabel }) => this.directoryEntryToOption({ ...entry, label: entryLabel })) - return [...preferredOptions, ...options] - }) - - private getShortQueryOptions (preferredOptions: ComboboxOptionData[]): ComboboxOptionData[] { - if (preferredOptions.length) { - return preferredOptions - } - - return [{ - label: 'Type at least 2 characters to search', - value: '', - selectable: false - }] - } - - private async getPreferredAccessPrincipleOptions (query: string): Promise { - const normalizedOrigin = normalizeOriginInput(query) - if (normalizedOrigin) { - return [{ - label: `Use origin ${normalizedOrigin}`, - value: normalizedOrigin - }] - } - - if (!isAbsoluteHttpUri(query)) { - return [] - } - - if (isOriginUrl(query)) { - return [{ - label: `Use origin ${new URL(query).origin}`, - value: new URL(query).origin - }] - } - - const urlOption = await this.createUrlOption(query) - return dedupeComboboxOptions(urlOption ? [urlOption] : []) - } - - private async createUrlOption (uri: string): Promise { - const resolved = await this.resolveAccessTarget(uri) - if (!resolved) { - return undefined - } - - return { - label: resolved.label, - value: uri - } - } - - private directoryEntryToOption (entry: DirectoryEntry): ComboboxOptionData { - return { - label: entry.label, - value: entry.uri, - template: directoryEntryOptionTemplate(entry) - } - } - } diff --git a/src/components/access-control-modal/AccessControlSharedAccess.ts b/src/components/access-control-modal/AccessControlSharedAccess.ts index 4eab7e0c5..ce5f2828e 100644 --- a/src/components/access-control-modal/AccessControlSharedAccess.ts +++ b/src/components/access-control-modal/AccessControlSharedAccess.ts @@ -36,14 +36,14 @@ export default class AccessControlSharedAccess extends WebComponent { this.searchValue = option.label } - private onAccessGrantRoleChange (index: number, event: ComboboxChangeEvent) { + private onAccessGrantRoleChange (id: string, event: ComboboxChangeEvent) { const value = event.detail.option.value if (typeof value !== 'string' || !ACCESS_ROLES.includes(value as AccessRole)) { return } const detail: AccessGrantRoleChangedDetail = { - index, + id, role: value as AccessRole } @@ -71,7 +71,7 @@ export default class AccessControlSharedAccess extends WebComponent {
  • ${this.renderAuthorizationBadge(entry.authorization, entry.subjectLabel)}

    ${entry.subjectLabel}

    - ${this.renderAuthorizationRole(entry.role, entry.index)} + ${this.renderAuthorizationRole(entry.role, entry.id)}
  • ` } @@ -139,12 +139,12 @@ export default class AccessControlSharedAccess extends WebComponent { return (initials || value.slice(0, maxWords)).toUpperCase() } - private renderAuthorizationRole (role: AccessRole, index: number) { + private renderAuthorizationRole (role: AccessRole, id: string) { return html` this.onAccessGrantRoleChange(index, event)} + @change=${(event: ComboboxChangeEvent) => this.onAccessGrantRoleChange(id, event)} > ${this.renderGrantRoleOptions()} diff --git a/src/components/access-control-modal/accessGrantDrafts.ts b/src/components/access-control-modal/accessGrantDrafts.ts new file mode 100644 index 000000000..99c6f45c7 --- /dev/null +++ b/src/components/access-control-modal/accessGrantDrafts.ts @@ -0,0 +1,99 @@ +import { label } from '@/utils/label' +import { Authenticated, Public, solidLogicSingleton, type AccessRole, type AccessSubject, type Authorization } from 'solid-logic' +import { sym } from 'rdflib' +import type { AccessGrantDraft, AccessGrantViewModel, AuthorizationSubjectSet, ChangedAccessGrant } from './types' + +export function getSharedAuthorization (authorization: Authorization): Authorization { + return { + ...authorization, + agentClass: authorization.agentClass.filter(iri => iri !== Authenticated.iri && iri !== Public.iri) + } +} + +export function getAuthorizationSubjectEntries (authorization: Authorization): AccessSubject[] { + const subjectSets: AuthorizationSubjectSet[] = [ + { type: 'agent', iris: authorization.agent }, + { type: 'agentGroup', iris: authorization.agentGroup }, + { type: 'agentClass', iris: authorization.agentClass }, + { type: 'origin', iris: authorization.origin } + ] + + return subjectSets.flatMap(({ type, iris }) => iris.map(iri => ({ type, iri }))) +} + +export function getAccessGrantId (authorization: Authorization): string { + return getAuthorizationSubjectEntries(authorization) + .map(subject => `${subject.type}:${subject.iri}`) + .sort() + .join('|') +} + +export function getAuthorizationSubjectLabel (authorization: Authorization): string { + const subjects = [ + ...authorization.agent, + ...authorization.agentGroup, + ...authorization.agentClass, + ...authorization.origin + ] + return subjects.length ? subjects.map(subject => label(sym(subject))).join(', ') : 'Unknown access holder' +} + +export function getAuthorizationRole (authorization: Authorization): AccessRole { + return solidLogicSingleton.acl.roleFromModes(authorization.mode) +} + +export function createAccessGrantDraft (authorization: Authorization): AccessGrantDraft { + const sharedAuthorization = getSharedAuthorization(authorization) + return { + id: getAccessGrantId(sharedAuthorization), + authorization: sharedAuthorization, + role: getAuthorizationRole(sharedAuthorization), + initialRole: getAuthorizationRole(authorization), + subjectLabel: getAuthorizationSubjectLabel(sharedAuthorization), + subjects: getAuthorizationSubjectEntries(sharedAuthorization) + } +} + +export function createAccessGrantDrafts (accessGrants: Authorization[]): AccessGrantDraft[] { + return accessGrants.map(createAccessGrantDraft) +} + +export function getAccessGrantViewModels (drafts: AccessGrantDraft[]): AccessGrantViewModel[] { + return [...drafts] + .sort((left, right) => { + const leftIsOwner = left.role === 'Owner' + const rightIsOwner = right.role === 'Owner' + + if (leftIsOwner && !rightIsOwner) return -1 + if (!leftIsOwner && rightIsOwner) return 1 + return drafts.indexOf(left) - drafts.indexOf(right) + }) +} + +export function getChangedAccessGrants (drafts: AccessGrantDraft[]): ChangedAccessGrant[] { + return getAccessGrantViewModels(drafts) + .filter(({ role, initialRole, subjects }) => role !== initialRole && subjects.length > 0) + .map(({ authorization, subjects, role }) => ({ authorization, subjects, role })) +} + +export function getAuthenticatedAccessRole (accessGrants: Authorization[]): AccessRole { + const authorization = accessGrants.find(item => item.agentClass.includes(Authenticated.iri)) + return authorization ? getAuthorizationRole(authorization) : 'No Access' +} + +export function getPublicAccessRole (accessGrants: Authorization[]): AccessRole { + const authorization = accessGrants.find(item => item.agentClass.includes(Public.iri)) + return authorization ? solidLogicSingleton.acl.publicRoleFromModes(authorization.mode) : 'No Access' +} + +export function getAccessSubjectLabel (subject: AccessSubject): string { + if (subject.iri === Authenticated.iri) { + return 'Authenticated users' + } + + if (subject.iri === Public.iri) { + return 'Public' + } + + return label(sym(subject.iri)).trim() || subject.iri +} diff --git a/src/components/access-control-modal/accessTargetResolver.ts b/src/components/access-control-modal/accessTargetResolver.ts new file mode 100644 index 000000000..ca84b86fb --- /dev/null +++ b/src/components/access-control-modal/accessTargetResolver.ts @@ -0,0 +1,149 @@ +import { defineAsyncComboboxOptionsProvider, type AsyncComboboxOptionsProvider, type ComboboxOptionData } from '@/components/combobox' +import { directoryEntryOptionTemplate } from './AccessControlAddAccess' +import { label } from '@/utils/label' +import { DEFAULT_DIRECTORY_SOURCES, solidLogicSingleton, type AccessControlSubjectKind, type DirectoryEntry } from 'solid-logic' +import { sym } from 'rdflib' +import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, normalizeOriginInput, type StringComboboxOptionData } from './helpers' + +export type ResolvedAccessTarget = { + subjectType: AccessControlSubjectKind + subjectValue: string + label: string +} + +async function classifyAccessTarget (principal: string) { + const normalizedOrigin = normalizeOriginInput(principal) + return normalizedOrigin + ? { kind: 'origin' as const, subjectValue: normalizedOrigin } + : await solidLogicSingleton.acl.classifyAccessControlSubject(principal) +} + +async function resolveAccessTargetLabel ( + resolvedPrincipal: Awaited>, + preferredLabel?: string +): Promise { + if (!resolvedPrincipal || resolvedPrincipal.kind === 'origin') { + return resolvedPrincipal?.subjectValue + } + + try { + const subject = sym(resolvedPrincipal.subjectValue) + await solidLogicSingleton.store.fetcher.load(subject.doc()) + const resolvedLabel = preferredLabel?.trim() || label(subject).trim() + if (!resolvedLabel || resolvedLabel === resolvedPrincipal.subjectValue) { + return undefined + } + + return resolvedLabel + } catch { + return undefined + } +} + +export async function resolveAccessTarget ( + principal: string, + preferredLabel?: string +): Promise { + const resolvedPrincipal = await classifyAccessTarget(principal) + if (!resolvedPrincipal) { + return undefined + } + + const resolvedLabel = await resolveAccessTargetLabel(resolvedPrincipal, preferredLabel) + if (!resolvedLabel) { + return undefined + } + + return { + subjectType: resolvedPrincipal.kind, + subjectValue: resolvedPrincipal.subjectValue, + label: resolvedLabel + } +} + +async function createUrlOption (uri: string): Promise { + const resolved = await resolveAccessTarget(uri) + return resolved + ? { label: resolved.label, value: uri } + : undefined +} + +function directoryEntryToOption (entry: DirectoryEntry, labelValue: string): ComboboxOptionData { + return { + label: labelValue, + value: entry.uri, + template: directoryEntryOptionTemplate({ ...entry, label: labelValue }) + } +} + +async function getPreferredAccessPrincipalOptions (query: string): Promise { + const normalizedOrigin = normalizeOriginInput(query) + if (normalizedOrigin) { + return [{ + label: `Use origin ${normalizedOrigin}`, + value: normalizedOrigin + }] + } + + if (!isAbsoluteHttpUri(query)) { + return [] + } + + if (isOriginUrl(query)) { + return [{ + label: `Use origin ${new URL(query).origin}`, + value: new URL(query).origin + }] + } + + const urlOption = await createUrlOption(query) + return dedupeComboboxOptions(urlOption ? [urlOption] : []) +} + +function getShortQueryOptions (preferredOptions: ComboboxOptionData[]): ComboboxOptionData[] { + if (preferredOptions.length) { + return preferredOptions + } + + return [{ + label: 'Type at least 2 characters to search', + value: '', + selectable: false + }] +} + +export function createAccessPrincipalOptionsProvider (): AsyncComboboxOptionsProvider { + return defineAsyncComboboxOptionsProvider(async (filter: string) => { + const query = filter.trim() + const preferredOptions = await getPreferredAccessPrincipalOptions(query) + + if (query.length < 2) { + return getShortQueryOptions(preferredOptions) + } + + let entries: DirectoryEntry[] = [] + + try { + entries = await solidLogicSingleton.directory.search({ + query, + sources: DEFAULT_DIRECTORY_SOURCES + }) + } catch (error) { + if (!preferredOptions.length) { + throw error + } + } + + const verifiedEntries = await Promise.all(entries.map(async entry => { + const resolved = await resolveAccessTarget(entry.uri, entry.label) + return resolved ? { entry, label: resolved.label } : undefined + })) + const options = verifiedEntries + .filter((entry): entry is { entry: DirectoryEntry, label: string } => entry !== undefined) + .map(({ entry, label: entryLabel }) => directoryEntryToOption(entry, entryLabel)) + return dedupeByKey( + [...preferredOptions, ...options], + option => typeof option.value === 'string' ? option.value : undefined + ) + }) +} diff --git a/src/components/access-control-modal/applyAccessChanges.ts b/src/components/access-control-modal/applyAccessChanges.ts new file mode 100644 index 000000000..084514195 --- /dev/null +++ b/src/components/access-control-modal/applyAccessChanges.ts @@ -0,0 +1,33 @@ +import { PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton } from 'solid-logic' +import type { AccessChange, AccessSaveError } from './types' + +export async function applyAccessChanges ( + subjectUri: string, + accessChanges: AccessChange[] +): Promise { + const errors: AccessSaveError[] = [] + + for (const { subject, label, role } of accessChanges) { + try { + const plan = role === 'No Access' + ? await solidLogicSingleton.acl.planRevoke(subjectUri, subject) + : await solidLogicSingleton.acl.planGrant(subjectUri, subject, subject.type === 'agentClass' && subject.iri === Public.iri + ? solidLogicSingleton.acl.modesFromPublicRole(role as (typeof PUBLIC_ACCESS_ROLES)[number]) + : solidLogicSingleton.acl.modesFromRole(role)) + + await solidLogicSingleton.acl.applyPlan(plan) + } catch (error) { + errors.push({ + action: role === 'No Access' ? 'revoke' : 'grant', + targetUri: subject.iri, + label, + role, + message: error instanceof Error + ? error.message + : 'Unable to apply access change.' + }) + } + } + + return errors +} diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index b61284590..4bf83a9df 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -23,7 +23,7 @@ export type RoleChangedEvent = CustomEvent export type PendingGrantRemovedEvent = CustomEvent export type AccessGrantRoleChangedDetail = { - index: number + id: string role: AccessRole } @@ -46,8 +46,8 @@ export type PendingAccessGrant = { } export type AccessGrantEntry = { + id: string authorization: Authorization - index: number role: AccessRole subjectLabel: string } @@ -57,6 +57,8 @@ export type AccessGrantViewModel = AccessGrantEntry & { subjects: AccessSubject[] } +export type AccessGrantDraft = AccessGrantViewModel + export type ChangedAccessGrant = { authorization: Authorization subjects: AccessSubject[] diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 8588275e5..dc23c7308 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -52,9 +52,9 @@ describe('AccessControlModal submit', () => { planPublicRead.mockReset() applyPlan.mockReset() classifyAccessControlSubject.mockReset() - classifyAccessControlSubject.mockImplementation(async (principle: string) => ({ + classifyAccessControlSubject.mockImplementation(async (principal: string) => ({ kind: 'agent' as const, - subjectValue: principle + subjectValue: principal })) planGrant.mockResolvedValue({ target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }) planPublicRead.mockResolvedValue({ target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }) @@ -686,6 +686,7 @@ describe('AccessControlModal submit', () => { 'http://www.w3.org/ns/auth/acl#AuthenticatedAgent', 'http://xmlns.com/foaf/0.1/Agent' ])('retains non-general subjects from an authorization containing %s', async (generalClass) => { + const { getAccessGrantViewModels, getAuthorizationSubjectLabel } = await import('../../src/components/access-control-modal/accessGrantDrafts') const element = document.createElement('solid-ui-access-control-modal') as any const agent = 'https://alice.example.com/profile/card.ttl#me' const group = 'https://example.com/group' @@ -704,14 +705,14 @@ describe('AccessControlModal submit', () => { await element.refreshAccessGrantLabels() await element.updateComplete - const expectedLabel = element.getAuthorizationSubjectLabel({ + const expectedLabel = getAuthorizationSubjectLabel({ agent: [agent], agentGroup: [group], agentClass: [agentClass], origin: [], mode: ['Read'] }) - const entries = element.getAccessGrantViewModels().filter(({ subjects }) => subjects.length > 0) + const entries = getAccessGrantViewModels(element.accessGrantDrafts).filter(({ subjects }) => subjects.length > 0) expect(entries).toHaveLength(1) expect(entries[0].authorization.agentClass).toEqual([agentClass]) expect(entries[0].subjectLabel).toBe(expectedLabel) diff --git a/test/components/access-control-modal/accessGrantDrafts.test.ts b/test/components/access-control-modal/accessGrantDrafts.test.ts new file mode 100644 index 000000000..42e391958 --- /dev/null +++ b/test/components/access-control-modal/accessGrantDrafts.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { Authenticated, Public } from 'solid-logic' +import { + getAccessGrantId, + getChangedAccessGrants, + getSharedAuthorization +} from '../../../src/components/access-control-modal/accessGrantDrafts' + +describe('access-control grant drafts', () => { + it('removes general access classes without mutating the authorization', () => { + const authorization = { + agent: ['https://alice.example/profile#me'], + agentGroup: [], + agentClass: [Authenticated.iri, 'https://example/team', Public.iri], + origin: [], + mode: ['Read'] as const + } + + expect(getSharedAuthorization(authorization)).toEqual({ + ...authorization, + agentClass: ['https://example/team'] + }) + expect(authorization.agentClass).toEqual([Authenticated.iri, 'https://example/team', Public.iri]) + }) + + it('creates the same stable grant id regardless of subject ordering', () => { + const first = { + agent: ['https://alice.example/profile#me'], + agentGroup: ['https://example/group'], + agentClass: [], + origin: ['https://example.test'], + mode: ['Read'] + } + const second = { + ...first, + agent: [...first.agent].reverse(), + agentGroup: [...first.agentGroup].reverse(), + origin: [...first.origin].reverse() + } + + expect(getAccessGrantId(first)).toBe(getAccessGrantId(second)) + }) + + it('returns only changed drafts with real shared subjects', () => { + const changed = { + id: 'agent:https://alice.example/profile#me', + authorization: { + agent: ['https://alice.example/profile#me'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'] + }, + role: 'Editor' as const, + initialRole: 'Viewer' as const, + subjectLabel: 'Alice', + subjects: [{ type: 'agent' as const, iri: 'https://alice.example/profile#me' }] + } + const unchanged = { ...changed, id: 'unchanged', role: 'Viewer' as const } + const generalOnly = { ...changed, id: 'general', subjects: [], role: 'Owner' as const } + + expect(getChangedAccessGrants([unchanged, generalOnly, changed])).toEqual([{ + authorization: changed.authorization, + subjects: changed.subjects, + role: 'Editor' + }]) + }) +}) diff --git a/test/components/access-control-modal/accessTargetResolver.test.ts b/test/components/access-control-modal/accessTargetResolver.test.ts new file mode 100644 index 000000000..7a4ce4960 --- /dev/null +++ b/test/components/access-control-modal/accessTargetResolver.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + createAccessPrincipalOptionsProvider, + resolveAccessTarget +} from '../../../src/components/access-control-modal/accessTargetResolver' + +describe('access-control target resolver', () => { + it('normalizes a bare origin without requiring directory lookup', async () => { + await expect(resolveAccessTarget('example.test')).resolves.toEqual({ + subjectType: 'origin', + subjectValue: 'https://example.test', + label: 'https://example.test' + }) + }) + + it('returns a non-selectable prompt for a short query', async () => { + const provider = createAccessPrincipalOptionsProvider() + + await expect(provider('x')).resolves.toEqual([{ + label: 'Type at least 2 characters to search', + value: '', + selectable: false + }]) + }) +}) From bae06f936c7954e7e24e6c51bc87fa2a371720af Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 14 Oct 2026 08:23:26 +1100 Subject: [PATCH 07/11] Refresh the access dialog with the successful results when there are errors Prompt: When there are errors the results that were successful need to be shown to the user in the open dialog, but they still should not be able to retry unsuccessful updates. Co-authored-by: GPT-5.6 Terra --- .../AccessControlErrors.ts | 7 +- .../AccessControlModal.ts | 70 ++++++----- .../access-control-modal/accessGrantDrafts.ts | 4 - .../accessTargetResolver.ts | 5 +- .../buildAccessChanges.ts | 30 +++++ src/components/access-control-modal/types.ts | 31 ++--- test/components/access-control-modal.test.ts | 111 ++++++++++++++++-- .../accessTargetResolver.test.ts | 6 +- .../buildAccessChanges.test.ts | 47 ++++++++ 9 files changed, 247 insertions(+), 64 deletions(-) create mode 100644 src/components/access-control-modal/buildAccessChanges.ts create mode 100644 test/components/access-control-modal/buildAccessChanges.test.ts diff --git a/src/components/access-control-modal/AccessControlErrors.ts b/src/components/access-control-modal/AccessControlErrors.ts index fbd4b8eba..5c7afb3b8 100644 --- a/src/components/access-control-modal/AccessControlErrors.ts +++ b/src/components/access-control-modal/AccessControlErrors.ts @@ -14,6 +14,9 @@ export default class AccessControlErrors extends WebComponent { @property({ attribute: false }) accessor errors: AccessSaveError[] = [] + @property({ attribute: false }) + accessor successfulChangesRefreshed = true + private onClose () { this.dispatchEvent(new CustomEvent('close', { bubbles: true, @@ -29,7 +32,9 @@ export default class AccessControlErrors extends WebComponent { return html`
      diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index 2702231f8..fe6b1d054 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -4,7 +4,7 @@ import type { PropertyValues } from 'lit' import { property, state } from 'lit/decorators.js' import { label } from '@/utils/label' import * as debug from '@/lib/debug' -import { Authenticated, Public, solidLogicSingleton, type AccessRole, type AccessSubject, type Authorization } from 'solid-logic' +import { Authenticated, Public, solidLogicSingleton, type AccessRole, type Authorization } from 'solid-logic' import { sym } from 'rdflib' import '~icons/lucide/circle-x' @@ -19,11 +19,12 @@ import './AccessControlGeneralAccess' import './AccessControlErrors' import styles from './AccessControlModal.styles.css' -import type { AccessChange, AccessControlError, AccessGrantDraft, AccessGrantRoleChangedEvent, AccessSaveError, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' +import type { AccessChange, AccessControlError, AccessGrantDraft, AccessGrantRoleChangedEvent, AccessSaveError, InputAccessControlError, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' import { dedupeByKey } from './helpers' -import { createAccessGrantDraft, getAccessGrantId, getAccessGrantViewModels, getAccessSubjectLabel, getAuthenticatedAccessRole, getAuthorizationSubjectLabel, getChangedAccessGrants, getPublicAccessRole, getSharedAuthorization } from './accessGrantDrafts' -import { createAccessPrincipalOptionsProvider, resolveAccessTarget } from './accessTargetResolver' +import { createAccessGrantDraft, getAccessGrantId, getAccessGrantViewModels, getAuthenticatedAccessRole, getAuthorizationSubjectLabel, getChangedAccessGrants, getPublicAccessRole, getSharedAuthorization } from './accessGrantDrafts' +import { accessPrincipalOptionsProvider, resolveAccessTarget } from './accessTargetResolver' import { applyAccessChanges } from './applyAccessChanges' +import { buildAccessChanges } from './buildAccessChanges' function isDefined (value: T | undefined | null): value is T { return value !== undefined && value !== null @@ -33,8 +34,6 @@ type PendingAccessGrantCreationResult = | { grant: PendingAccessGrant } | { error: InputAccessControlError } -type InputAccessControlError = Omit & { action: 'input' } - @customElement('solid-ui-access-control-modal') export default class AccessControlModal extends DialogComponent { static styles = styles @@ -69,6 +68,9 @@ export default class AccessControlModal extends DialogComponent { @state() private accessor errors: AccessControlError[] = [] + @state() + private accessor successfulChangesRefreshed = true + private accessGrantLabelRequestVersion = 0 disconnectedCallback (): void { @@ -126,14 +128,13 @@ export default class AccessControlModal extends DialogComponent { } const labels = await Promise.all(grants.map(async authorization => { - const groupUri = authorization.agentGroup[0] - if (groupUri) { + await Promise.all(authorization.agentGroup.map(async groupUri => { try { await solidLogicSingleton.store.fetcher.load(sym(groupUri).doc()) } catch { // Keep the fallback label if the group document cannot be loaded. } - } + })) return getAuthorizationSubjectLabel(getSharedAuthorization(authorization)) })) @@ -175,7 +176,7 @@ export default class AccessControlModal extends DialogComponent { .addAccessRoleValue=${this.addAccessRoleValue} .pendingAccessGrants=${this.pendingAccessGrants} .inputError=${this.inputError} - .asyncOptionsProvider=${createAccessPrincipalOptionsProvider()} + .asyncOptionsProvider=${accessPrincipalOptionsProvider} @target-input=${this.onPrincipalInput} @target-selected=${this.onPrincipalSelect} @role-changed=${this.onAddAccessRoleInput} @@ -194,6 +195,7 @@ export default class AccessControlModal extends DialogComponent { > @@ -269,27 +271,41 @@ export default class AccessControlModal extends DialogComponent { this.submitting = true this.errors = this.errors.filter(error => error.action === 'input') + this.successfulChangesRefreshed = true try { - const accessChanges: AccessChange[] = [ - ...this.pendingAccessGrants.map(grant => ({ - subject: this.createAccessSubject(grant.subjectType, grant.subjectValue), - role: grant.role, - label: grant.label - })), - ...generalAccessChanges, - ...changedAccessGrants.flatMap(({ subjects, role }) => - subjects.map(subject => ({ - subject, - role, - label: getAccessSubjectLabel(subject) - })) - ) - ] + const accessChanges = buildAccessChanges({ + pendingAccessGrants: this.pendingAccessGrants, + generalAccessChanges, + changedAccessGrants + }) const saveErrors = await applyAccessChanges(this.subjectUri, accessChanges) if (saveErrors.length) { this.errors = [...this.errors, ...saveErrors] + this.principalInputValue = '' + this.pendingAccessGrants = [] + + if (saveErrors.length < accessChanges.length) { + try { + this.accessGrants = await solidLogicSingleton.acl.findAccessGrants(this.subjectUri) + } catch (error) { + this.successfulChangesRefreshed = false + this.errors = [ + ...this.errors, + { + action: 'save', + targetUri: this.subjectUri, + label: 'Shared with list', + message: error instanceof Error + ? error.message + : 'Unable to refresh the access list.' + } + ] + debug.error('Failed to refresh access grants after a partial save', error) + } + } + debug.error('Failed to save some access changes', saveErrors) return } @@ -388,10 +404,6 @@ export default class AccessControlModal extends DialogComponent { ) } - private createAccessSubject (type: AccessSubject['type'], iri: string): AccessSubject { - return { type, iri } - } - private async commitPrincipalFromInput (): Promise { const rawValue = this.principalInputValue.trim() if (!rawValue) { diff --git a/src/components/access-control-modal/accessGrantDrafts.ts b/src/components/access-control-modal/accessGrantDrafts.ts index 99c6f45c7..cba223d00 100644 --- a/src/components/access-control-modal/accessGrantDrafts.ts +++ b/src/components/access-control-modal/accessGrantDrafts.ts @@ -54,10 +54,6 @@ export function createAccessGrantDraft (authorization: Authorization): AccessGra } } -export function createAccessGrantDrafts (accessGrants: Authorization[]): AccessGrantDraft[] { - return accessGrants.map(createAccessGrantDraft) -} - export function getAccessGrantViewModels (drafts: AccessGrantDraft[]): AccessGrantViewModel[] { return [...drafts] .sort((left, right) => { diff --git a/src/components/access-control-modal/accessTargetResolver.ts b/src/components/access-control-modal/accessTargetResolver.ts index ca84b86fb..b6f25cd2f 100644 --- a/src/components/access-control-modal/accessTargetResolver.ts +++ b/src/components/access-control-modal/accessTargetResolver.ts @@ -112,8 +112,8 @@ function getShortQueryOptions (preferredOptions: ComboboxOptionData[]): Combobox }] } -export function createAccessPrincipalOptionsProvider (): AsyncComboboxOptionsProvider { - return defineAsyncComboboxOptionsProvider(async (filter: string) => { +export const accessPrincipalOptionsProvider: AsyncComboboxOptionsProvider = + defineAsyncComboboxOptionsProvider(async (filter: string) => { const query = filter.trim() const preferredOptions = await getPreferredAccessPrincipalOptions(query) @@ -146,4 +146,3 @@ export function createAccessPrincipalOptionsProvider (): AsyncComboboxOptionsPro option => typeof option.value === 'string' ? option.value : undefined ) }) -} diff --git a/src/components/access-control-modal/buildAccessChanges.ts b/src/components/access-control-modal/buildAccessChanges.ts new file mode 100644 index 000000000..e482e9a28 --- /dev/null +++ b/src/components/access-control-modal/buildAccessChanges.ts @@ -0,0 +1,30 @@ +import { getAccessSubjectLabel } from './accessGrantDrafts' +import type { AccessChange, ChangedAccessGrant, PendingAccessGrant } from './types' + +export type AccessChangeDrafts = { + pendingAccessGrants: PendingAccessGrant[] + generalAccessChanges: AccessChange[] + changedAccessGrants: ChangedAccessGrant[] +} + +export function buildAccessChanges ({ + pendingAccessGrants, + generalAccessChanges, + changedAccessGrants +}: AccessChangeDrafts): AccessChange[] { + return [ + ...pendingAccessGrants.map(grant => ({ + subject: { type: grant.subjectType, iri: grant.subjectValue }, + role: grant.role, + label: grant.label + })), + ...generalAccessChanges, + ...changedAccessGrants.flatMap(({ subjects, role }) => + subjects.map(subject => ({ + subject, + role, + label: getAccessSubjectLabel(subject) + })) + ) + ] +} diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index 4bf83a9df..e13812c30 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -29,16 +29,7 @@ export type AccessGrantRoleChangedDetail = { export type AccessGrantRoleChangedEvent = CustomEvent -export type AccessControlBadgeKind = AccessControlSubjectKind | 'unknown' - -export type AccessControlBadge = { - kind: AccessControlBadgeKind - image?: string - text: string -} - export type PendingAccessGrant = { - id?: string subjectType: AccessControlSubjectKind subjectValue: string role: AccessRole @@ -76,12 +67,24 @@ export type AccessChange = { label: string } -export type AccessControlError = { - action: 'input' | 'grant' | 'revoke' | 'save' - targetUri?: string +export type InputAccessControlError = { + action: 'input' + targetUri: string + label: string + message: string +} + +export type AccessSaveError = { + action: 'grant' | 'revoke' + targetUri: string + label: string + role: AccessRole + message: string +} | { + action: 'save' + targetUri: string label: string - role?: AccessRole message: string } -export type AccessSaveError = Exclude +export type AccessControlError = InputAccessControlError | AccessSaveError diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index dc23c7308..dd8b890f5 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sym } from 'rdflib' import { isAbsoluteHttpUri, isOriginUrl } from '../../src/components/access-control-modal/helpers' const planGrant = vi.fn() @@ -8,6 +9,8 @@ const planRevoke = vi.fn() const planPublicRead = vi.fn() const applyPlan = vi.fn() const classifyAccessControlSubject = vi.fn() +const findAccessGrants = vi.fn() +const load = vi.fn() vi.mock('@/components/combobox', () => ({ defineAsyncComboboxOptionsProvider: (provider: T) => provider @@ -28,13 +31,14 @@ vi.mock('solid-logic', async (importOriginal) => { planGrant, planPublicRead, planRevoke, - applyPlan + applyPlan, + findAccessGrants }, store: { ...actual.solidLogicSingleton.store, fetcher: { ...actual.solidLogicSingleton.store.fetcher, - load: vi.fn().mockResolvedValue(undefined) + load } }, resource: { @@ -52,6 +56,8 @@ describe('AccessControlModal submit', () => { planPublicRead.mockReset() applyPlan.mockReset() classifyAccessControlSubject.mockReset() + findAccessGrants.mockReset() + load.mockReset() classifyAccessControlSubject.mockImplementation(async (principal: string) => ({ kind: 'agent' as const, subjectValue: principal @@ -59,6 +65,8 @@ describe('AccessControlModal submit', () => { planGrant.mockResolvedValue({ target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }) planPublicRead.mockResolvedValue({ target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }) applyPlan.mockResolvedValue(new Response('ok', { status: 200 })) + findAccessGrants.mockResolvedValue([]) + load.mockResolvedValue(undefined) await import('../../src/components/access-control-modal/AccessControlModal') }) @@ -348,6 +356,13 @@ describe('AccessControlModal submit', () => { } return { target: `${subject.iri}.acl`, deletes: [], inserts: [] } }) + findAccessGrants.mockResolvedValue([{ + agent: [successfulGrant.subjectValue], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read', 'Write'] + }]) const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' @@ -360,19 +375,30 @@ describe('AccessControlModal submit', () => { await element.onSaveClick() await element.updateComplete - // Partial saves do not reconcile accessGrants; the successful applyPlan call - // is the strongest observable ACL success while the dialog remains open. + // The refreshed authorizations replace pending drafts, so successful + // changes are visible while failed changes cannot be retried in-place. expect(planGrant).toHaveBeenCalledTimes(2) expect(applyPlan).toHaveBeenCalledTimes(1) expect(applyPlan).toHaveBeenCalledWith(expect.objectContaining({ target: `${successfulGrant.subjectValue}.acl` })) + expect(findAccessGrants).toHaveBeenCalledWith(element.subjectUri) + expect(element.pendingAccessGrants).toEqual([]) + expect(element.accessGrants).toEqual([{ + agent: [successfulGrant.subjectValue], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read', 'Write'] + }]) expect(close).not.toHaveBeenCalled() const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') await errors.updateComplete expect(errors.shadowRoot.querySelector('solid-ui-error-state')).not.toBeNull() expect([...errors.shadowRoot.querySelectorAll('solid-ui-error-state li .access-control-save-error-label')].map((label: Element) => label.textContent)) .toEqual(['Failed agent']) + expect(errors.shadowRoot.querySelector('solid-ui-error-state').getAttribute('message')) + .toContain('Close this dialog and reopen it if you want to try again.') const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement expect(saveButton.hasAttribute('disabled')).toBe(true) @@ -411,6 +437,50 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + it('clears staged changes and explains when partial-save reconciliation fails', async () => { + const successfulGrant = { + subjectType: 'agent', + subjectValue: 'https://example.com/successful-agent', + role: 'Editor', + label: 'Successful agent' + } + const failedGrant = { + subjectType: 'agent', + subjectValue: 'https://example.com/failed-agent', + role: 'Editor', + label: 'Failed agent' + } + planGrant.mockImplementation(async (_subjectUri: string, subject: { iri: string }) => { + if (subject.iri === failedGrant.subjectValue) { + throw new Error('grant failed') + } + return { target: `${subject.iri}.acl`, deletes: [], inserts: [] } + }) + findAccessGrants.mockRejectedValue(new Error('ACL refresh failed')) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = [successfulGrant, failedGrant] + document.body.appendChild(element) + await element.updateComplete + + await element.onSaveClick() + await element.updateComplete + + expect(findAccessGrants).toHaveBeenCalledWith(element.subjectUri) + expect(element.pendingAccessGrants).toEqual([]) + expect(element.successfulChangesRefreshed).toBe(false) + const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') + await errors.updateComplete + expect(errors.shadowRoot.querySelector('solid-ui-error-state').getAttribute('message')) + .toContain('Some changes may have been saved, but we could not refresh the Shared with list.') + const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement + expect(saveButton.hasAttribute('disabled')).toBe(true) + + document.body.removeChild(element) + }) + it('closes the dialog when all changes succeed', async () => { const grants = [0, 1].map(index => ({ subjectType: 'agent', @@ -440,7 +510,9 @@ describe('AccessControlModal submit', () => { const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' element.pendingAccessGrants = [{ - subjectType: 'agent', + get subjectType () { + throw new Error('unexpected save failure') + }, subjectValue: 'https://example.com/agent', role: 'Editor', label: 'Agent' @@ -449,10 +521,6 @@ describe('AccessControlModal submit', () => { await element.updateComplete const close = vi.fn() window.addEventListener('solid-ui:close-dialog', close) - element.createAccessSubject = () => { - throw new Error('unexpected save failure') - } - await element.onSaveClick() await element.updateComplete @@ -745,6 +813,31 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + it('loads every group document before refreshing grant labels', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.accessGrants = [{ + agent: [], + agentGroup: [ + 'https://example.com/groups/first', + 'https://example.com/groups/second#members' + ], + agentClass: [], + origin: [], + mode: ['Read'] + }] + + document.body.appendChild(element) + await element.updateComplete + load.mockClear() + + await element.refreshAccessGrantLabels() + + expect(load).toHaveBeenCalledWith(sym('https://example.com/groups/first').doc()) + expect(load).toHaveBeenCalledWith(sym('https://example.com/groups/second#members').doc()) + + document.body.removeChild(element) + }) + it('persists a changed shared access grant on save', async () => { const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' diff --git a/test/components/access-control-modal/accessTargetResolver.test.ts b/test/components/access-control-modal/accessTargetResolver.test.ts index 7a4ce4960..8877af3a0 100644 --- a/test/components/access-control-modal/accessTargetResolver.test.ts +++ b/test/components/access-control-modal/accessTargetResolver.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - createAccessPrincipalOptionsProvider, + accessPrincipalOptionsProvider, resolveAccessTarget } from '../../../src/components/access-control-modal/accessTargetResolver' @@ -14,9 +14,7 @@ describe('access-control target resolver', () => { }) it('returns a non-selectable prompt for a short query', async () => { - const provider = createAccessPrincipalOptionsProvider() - - await expect(provider('x')).resolves.toEqual([{ + await expect(accessPrincipalOptionsProvider('x')).resolves.toEqual([{ label: 'Type at least 2 characters to search', value: '', selectable: false diff --git a/test/components/access-control-modal/buildAccessChanges.test.ts b/test/components/access-control-modal/buildAccessChanges.test.ts new file mode 100644 index 000000000..5d96e0bdf --- /dev/null +++ b/test/components/access-control-modal/buildAccessChanges.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { buildAccessChanges } from '../../../src/components/access-control-modal/buildAccessChanges' + +describe('buildAccessChanges', () => { + it('orders pending, general, and changed shared access changes', () => { + const pendingAccessGrant = { + subjectType: 'agent' as const, + subjectValue: 'https://alice.example/profile#me', + role: 'Editor' as const, + label: 'Alice' + } + const generalAccessChange = { + subject: { type: 'agentClass' as const, iri: 'http://www.w3.org/ns/auth/acl#AuthenticatedAgent' }, + role: 'Viewer' as const, + label: 'Authenticated users' + } + const changedAccessGrant = { + authorization: { + agent: [], + agentGroup: [], + agentClass: [], + origin: ['https://example.test'], + mode: ['Read'] + }, + subjects: [{ type: 'origin' as const, iri: 'https://example.test' }], + role: 'No Access' as const + } + + expect(buildAccessChanges({ + pendingAccessGrants: [pendingAccessGrant], + generalAccessChanges: [generalAccessChange], + changedAccessGrants: [changedAccessGrant] + })).toEqual([ + { + subject: { type: 'agent', iri: 'https://alice.example/profile#me' }, + role: 'Editor', + label: 'Alice' + }, + generalAccessChange, + { + subject: { type: 'origin', iri: 'https://example.test' }, + role: 'No Access', + label: 'example.test' + } + ]) + }) +}) From 59ce76f6cb93cb7b829203bb4df3539ab750d423 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 14 Oct 2026 13:51:53 +1100 Subject: [PATCH 08/11] adjust styles --- .../AccessControlErrors.styles.css | 37 ++++++++++++++++--- .../AccessControlErrors.ts | 2 +- .../error-state/ErrorState.styles.css | 6 +++ src/styles/theme.css | 1 + 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/components/access-control-modal/AccessControlErrors.styles.css b/src/components/access-control-modal/AccessControlErrors.styles.css index c776731f3..9d30cb117 100644 --- a/src/components/access-control-modal/AccessControlErrors.styles.css +++ b/src/components/access-control-modal/AccessControlErrors.styles.css @@ -1,23 +1,48 @@ :host { + display: block; + width: 100%; + contain: inline-size; + + solid-ui-error-state { + width: 100%; + max-width: 100%; + contain: inline-size; + } + .access-control-save-errors { - display: flex; - flex-direction: column; + display: grid; gap: 8px; + width: 100%; margin: 0; - padding-left: 20px; + padding-left: 0; + box-sizing: border-box; + list-style: none; li { - display: flex; + display: grid; + grid-template-columns: minmax(0, 18rem) minmax(0, 1fr); align-items: baseline; gap: 6px; - color: var(--solid-ui-color-gray-700, #374151); + min-width: 0; + color: var(--solid-ui-color-red-600, #be2e48); font-size: var(--solid-ui-font-size-sm, .875rem); line-height: 1.4; } .access-control-save-error-label { - flex: 0 0 auto; font-weight: 500; + overflow-wrap: anywhere; + } + + .access-control-save-error-detail { + min-width: 0; + overflow-wrap: anywhere; + } + } + + @media (max-width: 600px) { + .access-control-save-errors li { + grid-template-columns: minmax(0, 40%) minmax(0, 1fr); } } } diff --git a/src/components/access-control-modal/AccessControlErrors.ts b/src/components/access-control-modal/AccessControlErrors.ts index 5c7afb3b8..077cb7d2e 100644 --- a/src/components/access-control-modal/AccessControlErrors.ts +++ b/src/components/access-control-modal/AccessControlErrors.ts @@ -41,7 +41,7 @@ export default class AccessControlErrors extends WebComponent { ${this.errors.map(error => html`
    • ${error.label} - + ${error.action === 'grant' ? `Could not grant ${error.role ?? 'access'}` : error.action === 'revoke' diff --git a/src/components/error-state/ErrorState.styles.css b/src/components/error-state/ErrorState.styles.css index e1ce95e61..04a60d642 100644 --- a/src/components/error-state/ErrorState.styles.css +++ b/src/components/error-state/ErrorState.styles.css @@ -23,6 +23,7 @@ section { position: absolute; top: 10px; right: 10px; + color: var(--solid-ui-color-gray-500); } .icon { @@ -43,6 +44,7 @@ icon-lucide-triangle-alert { .content { min-width: 0; flex: 1; + max-width: 100%; padding-right: 28px; } @@ -55,10 +57,13 @@ h2 { } p { + width: 100%; + max-width: 70ch; margin: 0 0 8px; color: var(--solid-ui-color-gray-600, #64748b); font-size: var(--solid-ui-font-size-sm, 0.875rem); line-height: 1.5; + overflow-wrap: anywhere; } slot:not(:empty) { @@ -67,6 +72,7 @@ slot:not(:empty) { } ::slotted(*) { + max-width: 100%; color: var(--solid-ui-error-state-slot-color, var(--solid-ui-color-gray-600, #64748b)); font-size: var(--solid-ui-error-state-slot-font-size, var(--solid-ui-font-size-xs, 0.75rem)); line-height: 1.5; diff --git a/src/styles/theme.css b/src/styles/theme.css index 45e213b60..7e52057cd 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -39,6 +39,7 @@ --solid-ui-color-violet-50: #F5F3FF; --solid-ui-color-red-500: #fb2c36; + --solid-ui-color-red-600: #be2e48; --solid-ui-color-red-700: #b00020; --solid-ui-color-body-grey: #666; From 2b075d9c7bdfc24b10c4fa4d4ac72b75e17a8980 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 14 Oct 2026 14:01:03 +1100 Subject: [PATCH 09/11] address PR feedback --- .../AccessControlErrors.ts | 5 +- .../AccessControlModal.ts | 45 ++++++----- test/components/access-control-modal.test.ts | 80 +++++++++++++++++++ 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/src/components/access-control-modal/AccessControlErrors.ts b/src/components/access-control-modal/AccessControlErrors.ts index 077cb7d2e..4558b7419 100644 --- a/src/components/access-control-modal/AccessControlErrors.ts +++ b/src/components/access-control-modal/AccessControlErrors.ts @@ -17,7 +17,8 @@ export default class AccessControlErrors extends WebComponent { @property({ attribute: false }) accessor successfulChangesRefreshed = true - private onClose () { + private onClose (event: Event) { + event.stopPropagation() this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true @@ -45,7 +46,7 @@ export default class AccessControlErrors extends WebComponent { ${error.action === 'grant' ? `Could not grant ${error.role ?? 'access'}` : error.action === 'revoke' - ? `Could not revoke ${error.role ?? 'access'}` + ? 'Could not remove access' : 'Could not save access changes'}
    • diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index fe6b1d054..4eace80b5 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -286,24 +286,22 @@ export default class AccessControlModal extends DialogComponent { this.principalInputValue = '' this.pendingAccessGrants = [] - if (saveErrors.length < accessChanges.length) { - try { - this.accessGrants = await solidLogicSingleton.acl.findAccessGrants(this.subjectUri) - } catch (error) { - this.successfulChangesRefreshed = false - this.errors = [ - ...this.errors, - { - action: 'save', - targetUri: this.subjectUri, - label: 'Shared with list', - message: error instanceof Error - ? error.message - : 'Unable to refresh the access list.' - } - ] - debug.error('Failed to refresh access grants after a partial save', error) - } + try { + this.accessGrants = await solidLogicSingleton.acl.findAccessGrants(this.subjectUri) + } catch (error) { + this.successfulChangesRefreshed = false + this.errors = [ + ...this.errors, + { + action: 'save', + targetUri: this.subjectUri, + label: 'Shared with list', + message: error instanceof Error + ? error.message + : 'Unable to refresh the access list.' + } + ] + debug.error('Failed to refresh access grants after save errors', error) } debug.error('Failed to save some access changes', saveErrors) @@ -414,9 +412,16 @@ export default class AccessControlModal extends DialogComponent { } private async commitPrincipalFromInputSafely (): Promise { + const inputSnapshot = this.principalInputValue.trim() + try { return await this.commitPrincipalFromInput() } catch (error) { + if (this.principalInputValue.trim() !== inputSnapshot) { + debug.error('Ignored stale access-target validation error', error) + return false + } + this.setInputError({ action: 'input', label: 'Access target', @@ -438,7 +443,9 @@ export default class AccessControlModal extends DialogComponent { const result = await this.createPendingAccessGrant(principal, role, preferredLabel) if ('error' in result) { - this.setInputError(result.error) + if (this.principalInputValue.trim() === snapshot) { + this.setInputError(result.error) + } debug.error('Failed to add access grant:', result.error.message) return false } diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index dd8b890f5..7c0f8f319 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -68,6 +68,7 @@ describe('AccessControlModal submit', () => { findAccessGrants.mockResolvedValue([]) load.mockResolvedValue(undefined) await import('../../src/components/access-control-modal/AccessControlModal') + await import('../../src/components/access-control-modal/AccessControlErrors') }) describe('access-control URL predicates', () => { @@ -105,6 +106,30 @@ describe('AccessControlModal submit', () => { }) }) + it('forwards the error-state close event only once', async () => { + const element = document.createElement('solid-ui-access-control-errors') as any + element.errors = [{ + action: 'grant', + targetUri: 'https://example.com/alice#me', + label: 'Alice', + role: 'Editor', + message: 'Grant failed' + }] + const close = vi.fn() + element.addEventListener('close', close) + document.body.appendChild(element) + await element.updateComplete + + const errorState = element.shadowRoot.querySelector('solid-ui-error-state') + await errorState.updateComplete + const closeButton = errorState.shadowRoot.querySelector('solid-ui-button.close-button') + const innerButton = closeButton.shadowRoot.querySelector('button') + innerButton.click() + + expect(close).toHaveBeenCalledTimes(1) + document.body.removeChild(element) + }) + it('queues a grant on submit without saving it', async () => { const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' @@ -171,6 +196,25 @@ describe('AccessControlModal submit', () => { expect(element.pendingAccessGrants).toHaveLength(1) }) + it('does not show a stale validation error after newer input', async () => { + let resolveClassification!: (value: undefined) => void + classifyAccessControlSubject.mockImplementationOnce(() => new Promise(resolve => { + resolveClassification = resolve + })) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.principalInputValue = 'https://old.example.com/profile/card#me' + + const submitPromise = element.onSubmit(new Event('submit')) + element.principalInputValue = 'https://new.example.com/profile/card#me' + resolveClassification(undefined) + await submitPromise + + expect(element.principalInputValue).toBe('https://new.example.com/profile/card#me') + expect(element.inputError).toBe('') + }) + it('keeps the selected role for a queued grant even if the mode changes later', async () => { let resolveClassification!: (value: { kind: 'agent', subjectValue: string }) => void classifyAccessControlSubject.mockImplementationOnce(() => new Promise(resolve => { @@ -281,6 +325,27 @@ describe('AccessControlModal submit', () => { expect(element.pendingAccessGrants).toEqual([]) }) + it('describes revoke failures as removing access', async () => { + planRevoke.mockRejectedValue(new Error('revoke failed')) + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://alice.example.com/profile/card.ttl#me', + role: 'No Access', + label: 'Alice' + }] + document.body.appendChild(element) + + await element.onSaveClick() + await element.updateComplete + + const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') + await errors.updateComplete + expect(errors.shadowRoot.querySelector('li').textContent).toContain('Could not remove access') + document.body.removeChild(element) + }) + it('attempts every queued change and reports only the failed changes', async () => { const failedDuringPlanning = new Set([2, 7]) const failedDuringApply = new Set([4, 10]) @@ -414,6 +479,13 @@ describe('AccessControlModal submit', () => { label: `Failed agent ${index}` })) planGrant.mockRejectedValue(new Error('all changes failed')) + findAccessGrants.mockResolvedValue([{ + agent: ['https://example.com/current-agent'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'] + }]) const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl' @@ -428,6 +500,14 @@ describe('AccessControlModal submit', () => { expect(planGrant).toHaveBeenCalledTimes(grants.length) expect(applyPlan).not.toHaveBeenCalled() + expect(findAccessGrants).toHaveBeenCalledWith(element.subjectUri) + expect(element.accessGrants).toEqual([{ + agent: ['https://example.com/current-agent'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'] + }]) expect(close).not.toHaveBeenCalled() const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') await errors.updateComplete From 7a145ce5e33ace63c08d7788b463dbe412407bc4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 Oct 2026 03:08:26 +0000 Subject: [PATCH 10/11] chore: update solidos dependencies (dev: solid-logic@6.0.1-1 pane-registry@5.0.2-0) (latest: rdflib@2.4.1) --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 81fc14e76..96e3330a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6929,9 +6929,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001815", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", - "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", "dev": true, "funding": [ { @@ -7542,9 +7542,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.452", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", - "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "version": "1.5.455", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.455.tgz", + "integrity": "sha512-EDKY1GvJLJC0UQpk+zWxnXp315azNHIns3arXVNUQUOLaon6GtIzBBXWvNno7bVoDXRPnEAV2uwgMCb+RvG5hQ==", "dev": true, "license": "ISC" }, @@ -12933,9 +12933,9 @@ } }, "node_modules/storybook/node_modules/@testing-library/user-event": { - "version": "14.6.7", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", - "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", "dev": true, "license": "MIT", "engines": { From 90bafaf8635a4ed068754dc9093e44a470b76e1a Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Wed, 14 Oct 2026 14:18:05 +1100 Subject: [PATCH 11/11] Fixed test issues in CI - they were not local though for some reason Prompt: Pasted the errors from the ci into the chat - can you see why these errors would happen in ci but not locally and fix. Co-authored-by: GPT-5.6 Luna --- .../access-control-modal/accessGrantDrafts.ts | 20 +++++++++---------- src/components/access-control-modal/types.ts | 13 +++++++++--- test/components/access-control-modal.test.ts | 5 +++-- .../accessGrantDrafts.test.ts | 11 +++++----- .../buildAccessChanges.test.ts | 3 ++- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/components/access-control-modal/accessGrantDrafts.ts b/src/components/access-control-modal/accessGrantDrafts.ts index cba223d00..fd2812212 100644 --- a/src/components/access-control-modal/accessGrantDrafts.ts +++ b/src/components/access-control-modal/accessGrantDrafts.ts @@ -1,16 +1,16 @@ import { label } from '@/utils/label' -import { Authenticated, Public, solidLogicSingleton, type AccessRole, type AccessSubject, type Authorization } from 'solid-logic' +import { Authenticated, Public, solidLogicSingleton, type AccessRole, type AccessSubject } from 'solid-logic' import { sym } from 'rdflib' -import type { AccessGrantDraft, AccessGrantViewModel, AuthorizationSubjectSet, ChangedAccessGrant } from './types' +import type { AccessAuthorization, AccessGrantDraft, AccessGrantViewModel, AuthorizationSubjectSet, ChangedAccessGrant } from './types' -export function getSharedAuthorization (authorization: Authorization): Authorization { +export function getSharedAuthorization (authorization: AccessAuthorization): AccessAuthorization { return { ...authorization, agentClass: authorization.agentClass.filter(iri => iri !== Authenticated.iri && iri !== Public.iri) } } -export function getAuthorizationSubjectEntries (authorization: Authorization): AccessSubject[] { +export function getAuthorizationSubjectEntries (authorization: AccessAuthorization): AccessSubject[] { const subjectSets: AuthorizationSubjectSet[] = [ { type: 'agent', iris: authorization.agent }, { type: 'agentGroup', iris: authorization.agentGroup }, @@ -21,14 +21,14 @@ export function getAuthorizationSubjectEntries (authorization: Authorization): A return subjectSets.flatMap(({ type, iris }) => iris.map(iri => ({ type, iri }))) } -export function getAccessGrantId (authorization: Authorization): string { +export function getAccessGrantId (authorization: AccessAuthorization): string { return getAuthorizationSubjectEntries(authorization) .map(subject => `${subject.type}:${subject.iri}`) .sort() .join('|') } -export function getAuthorizationSubjectLabel (authorization: Authorization): string { +export function getAuthorizationSubjectLabel (authorization: AccessAuthorization): string { const subjects = [ ...authorization.agent, ...authorization.agentGroup, @@ -38,11 +38,11 @@ export function getAuthorizationSubjectLabel (authorization: Authorization): str return subjects.length ? subjects.map(subject => label(sym(subject))).join(', ') : 'Unknown access holder' } -export function getAuthorizationRole (authorization: Authorization): AccessRole { +export function getAuthorizationRole (authorization: AccessAuthorization): AccessRole { return solidLogicSingleton.acl.roleFromModes(authorization.mode) } -export function createAccessGrantDraft (authorization: Authorization): AccessGrantDraft { +export function createAccessGrantDraft (authorization: AccessAuthorization): AccessGrantDraft { const sharedAuthorization = getSharedAuthorization(authorization) return { id: getAccessGrantId(sharedAuthorization), @@ -72,12 +72,12 @@ export function getChangedAccessGrants (drafts: AccessGrantDraft[]): ChangedAcce .map(({ authorization, subjects, role }) => ({ authorization, subjects, role })) } -export function getAuthenticatedAccessRole (accessGrants: Authorization[]): AccessRole { +export function getAuthenticatedAccessRole (accessGrants: AccessAuthorization[]): AccessRole { const authorization = accessGrants.find(item => item.agentClass.includes(Authenticated.iri)) return authorization ? getAuthorizationRole(authorization) : 'No Access' } -export function getPublicAccessRole (accessGrants: Authorization[]): AccessRole { +export function getPublicAccessRole (accessGrants: AccessAuthorization[]): AccessRole { const authorization = accessGrants.find(item => item.agentClass.includes(Public.iri)) return authorization ? solidLogicSingleton.acl.publicRoleFromModes(authorization.mode) : 'No Access' } diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index e13812c30..43b376834 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -1,4 +1,11 @@ -import type { AccessControlSubjectKind, AccessRole, AccessSubject, Authorization } from 'solid-logic' +import type { AccessControlSubjectKind, AccessMode, AccessRole, AccessSubject, Authorization } from 'solid-logic' + +export type AccessAuthorization = Omit< + Pick, + 'mode' +> & { + mode: readonly AccessMode[] +} export type TargetInputDetail = { value: string @@ -38,7 +45,7 @@ export type PendingAccessGrant = { export type AccessGrantEntry = { id: string - authorization: Authorization + authorization: AccessAuthorization role: AccessRole subjectLabel: string } @@ -51,7 +58,7 @@ export type AccessGrantViewModel = AccessGrantEntry & { export type AccessGrantDraft = AccessGrantViewModel export type ChangedAccessGrant = { - authorization: Authorization + authorization: AccessAuthorization subjects: AccessSubject[] role: AccessRole } diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 7c0f8f319..6fa33c064 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { sym } from 'rdflib' +import type { AccessMode } from 'solid-logic' import { isAbsoluteHttpUri, isOriginUrl } from '../../src/components/access-control-modal/helpers' const planGrant = vi.fn() @@ -484,7 +485,7 @@ describe('AccessControlModal submit', () => { agentGroup: [], agentClass: [], origin: [], - mode: ['Read'] + mode: ['Read'] as AccessMode[] }]) const element = document.createElement('solid-ui-access-control-modal') as any @@ -506,7 +507,7 @@ describe('AccessControlModal submit', () => { agentGroup: [], agentClass: [], origin: [], - mode: ['Read'] + mode: ['Read'] as AccessMode[] }]) expect(close).not.toHaveBeenCalled() const errors = element.shadowRoot.querySelector('solid-ui-access-control-errors') diff --git a/test/components/access-control-modal/accessGrantDrafts.test.ts b/test/components/access-control-modal/accessGrantDrafts.test.ts index 42e391958..98800a26d 100644 --- a/test/components/access-control-modal/accessGrantDrafts.test.ts +++ b/test/components/access-control-modal/accessGrantDrafts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Authenticated, Public } from 'solid-logic' +import { Authenticated, Public, type AccessMode } from 'solid-logic' +import type { AccessAuthorization } from '../../../src/components/access-control-modal/types' import { getAccessGrantId, getChangedAccessGrants, @@ -8,7 +9,7 @@ import { describe('access-control grant drafts', () => { it('removes general access classes without mutating the authorization', () => { - const authorization = { + const authorization: AccessAuthorization = { agent: ['https://alice.example/profile#me'], agentGroup: [], agentClass: [Authenticated.iri, 'https://example/team', Public.iri], @@ -24,12 +25,12 @@ describe('access-control grant drafts', () => { }) it('creates the same stable grant id regardless of subject ordering', () => { - const first = { + const first: AccessAuthorization = { agent: ['https://alice.example/profile#me'], agentGroup: ['https://example/group'], agentClass: [], origin: ['https://example.test'], - mode: ['Read'] + mode: ['Read'] as AccessMode[] } const second = { ...first, @@ -49,7 +50,7 @@ describe('access-control grant drafts', () => { agentGroup: [], agentClass: [], origin: [], - mode: ['Read'] + mode: ['Read'] as AccessMode[] }, role: 'Editor' as const, initialRole: 'Viewer' as const, diff --git a/test/components/access-control-modal/buildAccessChanges.test.ts b/test/components/access-control-modal/buildAccessChanges.test.ts index 5d96e0bdf..d9739bc71 100644 --- a/test/components/access-control-modal/buildAccessChanges.test.ts +++ b/test/components/access-control-modal/buildAccessChanges.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { AccessMode } from 'solid-logic' import { buildAccessChanges } from '../../../src/components/access-control-modal/buildAccessChanges' describe('buildAccessChanges', () => { @@ -20,7 +21,7 @@ describe('buildAccessChanges', () => { agentGroup: [], agentClass: [], origin: ['https://example.test'], - mode: ['Read'] + mode: ['Read'] as AccessMode[] }, subjects: [{ type: 'origin' as const, iri: 'https://example.test' }], role: 'No Access' as const