diff --git a/package-lock.json b/package-lock.json index 727f97b55..eef92a3ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11349,9 +11349,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.57", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", - "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, "license": "MIT", "engines": { diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index 670d0571c..b76dd5f47 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -115,6 +115,9 @@ 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; } } @@ -188,10 +191,6 @@ background: var(--solid-ui-color-lavender-300, #e6dcff); } - .access-grants-image--origin { - background: var(--solid-ui-color-gray-200, #E5E7EB); - } - .access-grants-image--unknown { background: var(--solid-ui-color-gray-300, #D1D5DB); } @@ -217,7 +216,6 @@ } .access-grants-role--owner { - color: var(--solid-ui-color-gray-400, #99A1AF); font-size: var(--solid-ui-font-size-xs, .75rem); } diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index 0f9af364e..423f25cbf 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -5,8 +5,8 @@ import { property, query, state } from 'lit/decorators.js' import { label } from '@/utils/label' import { findImage } from '@/widgets' import type Dialog from '@/components/dialog' -import { ACCESS_ROLES, DEFAULT_DIRECTORY_SOURCES, solidLogicSingleton, type AccessMode, type AccessRole, type Authorization, type DirectoryEntry } from 'solid-logic' -import { defineAsyncComboboxOptionsProvider, type ComboboxChangeEvent, type ComboboxOptionData } from '@/components/combobox' +import { ACCESS_ROLES, DEFAULT_DIRECTORY_SOURCES, solidLogicSingleton, 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' @@ -24,7 +24,12 @@ import '@/components/combobox' import '@/components/combobox-option' import styles from './AccessControlModal.styles.css' -import type { AccessControlBadgeKind, PendingAccessGrant } from './types' +import type { ComboboxChangeEvent } from '@/components/combobox' +import type { AccessChange, AccessControlBadge, AccessGrantEntry, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' + +function isDefined (value: T | undefined | null): value is T { + return value !== undefined && value !== null +} @customElement('solid-ui-access-control-modal') export default class AccessControlModal extends WebComponent { @@ -102,12 +107,17 @@ export default class AccessControlModal extends WebComponent { private getAccessGrantEntries () { return (this.accessGrants ?? []) - .map((authorization, index) => ({ - authorization, - index, - role: this.accessGrantRoles[index] ?? this.getAuthorizationRole(authorization), - subjectLabel: this.accessGrantLabels[index] ?? this.getAuthorizationSubjectLabel(authorization) - })) + .map((authorization, index) => { + const subjectLabel = this.getAccessGrantSubjectLabel(authorization, index) + + return { + authorization, + index, + role: this.accessGrantRoles[index] ?? this.getAuthorizationRole(authorization), + subjectLabel, + badge: this.getAuthorizationBadge(authorization, subjectLabel) + } + }) .sort((left, right) => { const leftIsOwner = left.role === 'Owner' const rightIsOwner = right.role === 'Owner' @@ -131,34 +141,32 @@ export default class AccessControlModal extends WebComponent { return html` ` } - private renderAccessGrant (authorization: Authorization, index: number) { - const badge = this.getAuthorizationBadge(authorization) - const role = this.accessGrantRoles[index] ?? this.getAuthorizationRole(authorization) - const subjectLabel = this.accessGrantLabels[index] ?? this.getAuthorizationSubjectLabel(authorization) - + private renderAccessGrant (entry: AccessGrantEntry) { return html`
  • - ${this.renderAuthorizationBadge(badge)} -

    ${subjectLabel}

    - ${this.renderAuthorizationRole(role, index)} + ${this.renderAuthorizationBadge(entry.badge)} +

    ${entry.subjectLabel}

    + ${this.renderAuthorizationRole(entry.role, entry.index)}
  • ` } - private getAuthorizationBadge (authorization: Authorization) { + 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(this.getAuthorizationSubjectLabel(authorization), 2) + text: image ? '' : this.getInitials(resolvedSubjectLabel, 2) } } @@ -166,7 +174,7 @@ export default class AccessControlModal extends WebComponent { if (agentGroup) { return { kind: 'group' as const, - text: this.getInitials(this.getAuthorizationSubjectLabel(authorization), 1) + text: this.getInitials(resolvedSubjectLabel, 1) } } @@ -176,7 +184,7 @@ export default class AccessControlModal extends WebComponent { return { kind: 'agentClass' as const, image, - text: image ? '' : this.getInitials(this.getAuthorizationSubjectLabel(authorization), 2) + text: image ? '' : this.getInitials(resolvedSubjectLabel, 2) } } @@ -186,7 +194,7 @@ export default class AccessControlModal extends WebComponent { } } - private renderAuthorizationBadge (badge: { kind: AccessControlBadgeKind, image?: string, text: string }) { + private renderAuthorizationBadge (badge: AccessControlBadge) { return html`
    ${badge.image ? html`` : html``} @@ -200,7 +208,7 @@ export default class AccessControlModal extends WebComponent { return (initials || label.slice(0, maxWords)).toUpperCase() } - private getAuthorizationSubjects (authorization: Authorization) { + private getAuthorizationSubjectIris (authorization: Authorization) { return [ ...authorization.agent, ...authorization.agentGroup, @@ -209,7 +217,7 @@ export default class AccessControlModal extends WebComponent { } private getAuthorizationSubjectLabel (authorization: Authorization): string { - const subjects = this.getAuthorizationSubjects(authorization) + const subjects = this.getAuthorizationSubjectIris(authorization) return subjects.length ? subjects.map(subject => label(sym(subject))).join(', ') : 'Unknown access holder' } @@ -218,8 +226,7 @@ export default class AccessControlModal extends WebComponent { } private getRoleValueFromEvent (event: Event, fallback: AccessRole = 'Viewer'): AccessRole { - const customEvent = event as ComboboxChangeEvent - const selectedValue = customEvent.detail?.option?.value + const selectedValue = this.getSelectedComboboxOptionValue(event) if (typeof selectedValue === 'string') { return selectedValue as AccessRole @@ -231,13 +238,9 @@ export default class AccessControlModal extends WebComponent { } private renderAuthorizationRole (role: AccessRole, index: number) { - if (role === 'Owner') { - return html`${role}` - } - return html` this.onAccessGrantRoleInput(index, event)} > @@ -348,7 +351,8 @@ export default class AccessControlModal extends WebComponent { @change=${this.onSearchSelect} > ${accessGrantOptions.map(option => html` - + ${option.label} `)} @@ -434,7 +438,7 @@ export default class AccessControlModal extends WebComponent { Cancel ({ + subject: this.createAccessSubject(grant.subjectType, grant.subjectValue), + role: grant.role + })), + ...changedAccessGrants.flatMap(({ subjects, role }) => + subjects.map(subject => ({ subject, role })) + ) + ] + + 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, this.getRoleModes(draftGrant.role)) + : await solidLogicSingleton.acl.planGrant(this.subjectUri, subject, this.getRoleModes(role)) await solidLogicSingleton.acl.applyPlan(plan) } @@ -515,13 +529,11 @@ export default class AccessControlModal extends WebComponent { } private onPrincipalInput (event: Event) { - const target = event.currentTarget as { value?: string } | null - this.principalInputValue = target?.value ?? '' + this.principalInputValue = this.getEventValue(event) } private onPrincipalSelect (event: Event) { - const customEvent = event as ComboboxChangeEvent - const option = customEvent.detail?.option + const option = this.getSelectedComboboxOption(event) if (!option) { return @@ -535,13 +547,11 @@ export default class AccessControlModal extends WebComponent { } private onSearchInput (event: Event) { - const target = event.currentTarget as { value?: string } | null - this.searchValue = target?.value ?? '' + this.searchValue = this.getEventValue(event) } private onSearchSelect (event: Event) { - const customEvent = event as ComboboxChangeEvent - const option = customEvent.detail?.option + const option = this.getSelectedComboboxOption(event) if (option && typeof option.label === 'string') { this.searchValue = option.label @@ -574,6 +584,71 @@ export default class AccessControlModal extends WebComponent { this.pendingAccessGrants = this.pendingAccessGrants.filter((_, pendingIndex) => pendingIndex !== index) } + private hasUnsavedChanges (): boolean { + return Boolean( + this.pendingAccessGrants.length || + this.principalInputValue.trim() || + this.getChangedAccessGrants().length + ) + } + + 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(authorization) + if (!subjects.length) { + return undefined + } + + return { authorization, subjects, role: currentRole } + }) + .filter(isDefined) + } + + private getInitialAccessGrantRoles (): AccessRole[] { + return this.accessGrants?.map(item => this.getAuthorizationRole(item)) ?? [] + } + + private getAuthorizationSubjectEntries (authorization: Authorization): AccessSubject[] { + const subjectSets: AuthorizationSubjectSet[] = [ + { type: 'agent', iris: authorization.agent }, + { type: 'agentGroup', iris: authorization.agentGroup }, + { type: 'agentClass', iris: authorization.agentClass } + ] + + return subjectSets.flatMap(({ type, iris }) => iris.map(iri => ({ type, iri }))) + } + + private createAccessSubject (type: AccessSubject['type'], iri: string): AccessSubject { + return { type, 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 getSelectedComboboxOptionValue (event: Event): unknown { + return this.getSelectedComboboxOption(event)?.value + } + private async commitPrinciplesFromInput (): Promise { const rawValue = this.principalInputValue.trim() if (!rawValue) { @@ -592,7 +667,7 @@ export default class AccessControlModal extends WebComponent { ) { const snapshot = inputSnapshot ?? this.principalInputValue.trim() const pendingGrants = await Promise.all(principles.map(async principle => this.createPendingAccessGrant(principle, role, preferredLabel))) - const newGrants = pendingGrants.filter((grant): grant is PendingAccessGrant => Boolean(grant)) + const newGrants = pendingGrants.filter(isDefined) if (!newGrants.length) { return diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index ca697ce59..887b4a8fb 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -1,8 +1,14 @@ -import type { AccessControlSubjectKind, AccessRole } from 'solid-logic' +import type { AccessControlSubjectKind, AccessRole, AccessSubject, Authorization } from 'solid-logic' export type AccessControlBadgeKind = 'agent' | 'group' | 'agentClass' | 'unknown' export type PendingAccessSubjectKind = Exclude +export type AccessControlBadge = { + kind: AccessControlBadgeKind + image?: string + text: string +} + export type PendingAccessGrant = { id?: string subjectType: PendingAccessSubjectKind @@ -10,3 +16,27 @@ export type PendingAccessGrant = { role: AccessRole label: string } + +export type AccessGrantEntry = { + authorization: Authorization + index: number + role: AccessRole + subjectLabel: string + badge: AccessControlBadge +} + +export type ChangedAccessGrant = { + authorization: Authorization + subjects: AccessSubject[] + role: AccessRole +} + +export type AuthorizationSubjectSet = { + type: PendingAccessSubjectKind + iris: string[] +} + +export type AccessChange = { + subject: AccessSubject + role: AccessRole +} diff --git a/src/styles/theme.css b/src/styles/theme.css index 037b18856..f6a312edc 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -18,6 +18,7 @@ --solid-ui-color-gray-300: #d1d5dc; --solid-ui-color-gray-400: #99a1af; --solid-ui-color-gray-500: #6a7282; + --solid-ui-color-zinc-500: #71717b; --solid-ui-color-gray-600: #4a5565; --solid-ui-color-gray-700: #364153; --solid-ui-color-gray-800: #101828; diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index af68eb008..186552fc3 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -239,6 +239,35 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + it('enables save when a shared access grant is changed', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.accessGrants = [{ + agent: ['https://alice.example.com/profile/card.ttl#me'], + agentGroup: [], + agentClass: [], + mode: ['Read'] + }] + + document.body.appendChild(element) + await element.updateComplete + + const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .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') + grantRoleSelect.dispatchEvent(new CustomEvent('change', { + bubbles: true, + composed: true, + detail: { option: { value: 'Editor', label: 'Editor' } } + })) + + await element.updateComplete + expect(saveButton.hasAttribute('disabled')).toBe(false) + + document.body.removeChild(element) + }) + it('uses the default share title when no subject uri is present', async () => { const element = document.createElement('solid-ui-access-control-modal') as any @@ -269,7 +298,6 @@ describe('AccessControlModal submit', () => { agent: ['https://alice.example.com/profile/card.ttl#me'], agentGroup: [], agentClass: [], - origin: [], mode: ['Read'] }] @@ -284,4 +312,75 @@ describe('AccessControlModal submit', () => { 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' + element.accessGrants = [{ + agent: ['https://alice.example.com/profile/card.ttl#me'], + agentGroup: [], + agentClass: [], + mode: ['Read'] + }] + + document.body.appendChild(element) + await element.updateComplete + + const grantRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') + grantRoleSelect.dispatchEvent(new CustomEvent('change', { + bubbles: true, + composed: true, + detail: { option: { value: 'Editor', label: 'Editor' } } + })) + + await element.onSaveClick() + + expect(planGrant).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://alice.example.com/profile/card.ttl#me' }, + ['Read', 'Write'] + ) + + document.body.removeChild(element) + }) + + it('applies a shared row change to every subject represented by the authorization', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.accessGrants = [{ + agent: ['https://alice.example.com/profile/card.ttl#me'], + agentGroup: ['https://example.com/group'], + agentClass: [], + mode: ['Read'] + }] + + document.body.appendChild(element) + await element.updateComplete + + const grantRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-grants-role--editable') + grantRoleSelect.dispatchEvent(new CustomEvent('change', { + bubbles: true, + composed: true, + detail: { option: { value: 'Editor', label: 'Editor' } } + })) + + await element.onSaveClick() + + expect(planGrant).toHaveBeenNthCalledWith( + 1, + element.subjectUri, + { type: 'agent', iri: 'https://alice.example.com/profile/card.ttl#me' }, + ['Read', 'Write'] + ) + expect(planGrant).toHaveBeenNthCalledWith( + 2, + element.subjectUri, + { type: 'agentGroup', iri: 'https://example.com/group' }, + ['Read', 'Write'] + ) + expect(planRevoke).not.toHaveBeenCalled() + expect(applyPlan).toHaveBeenCalledTimes(2) + + document.body.removeChild(element) + }) })