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": { 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`${this.inputError}` + : 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..9d30cb117 --- /dev/null +++ b/src/components/access-control-modal/AccessControlErrors.styles.css @@ -0,0 +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: grid; + gap: 8px; + width: 100%; + margin: 0; + padding-left: 0; + box-sizing: border-box; + list-style: none; + + li { + display: grid; + grid-template-columns: minmax(0, 18rem) minmax(0, 1fr); + align-items: baseline; + gap: 6px; + 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 { + 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 new file mode 100644 index 000000000..4558b7419 --- /dev/null +++ b/src/components/access-control-modal/AccessControlErrors.ts @@ -0,0 +1,58 @@ +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[] = [] + + @property({ attribute: false }) + accessor successfulChangesRefreshed = true + + private onClose (event: Event) { + event.stopPropagation() + 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 remove 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` + + + ${this.renderGeneralAccessIcon()} + + ${options.title} + ${options.description} + + + ${options.variant === 'authenticated' + ? this.renderAuthenticatedModeSelector() + : this.renderPublicModeSelector()} + + ` + } + + 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 481d4679e..32438b214 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -1,370 +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-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-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; @@ -387,4 +21,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..4eace80b5 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -1,20 +1,12 @@ -import { customElement, WebComponent } from '@/lib/components' -import { html, nothing } from 'lit' +import { customElement, DialogComponent } from '@/lib/components' +import { html } 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 { 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 { defineAsyncComboboxOptionsProvider, type ComboboxOptionData } from '@/components/combobox' +import * as debug from '@/lib/debug' +import { Authenticated, Public, solidLogicSingleton, type AccessRole, type Authorization } from 'solid-logic' 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,22 +14,28 @@ import '@/components/dialog-footer' import '@/components/button' import '@/components/combobox' import '@/components/combobox-option' +import './AccessControlSharedAccess' +import './AccessControlGeneralAccess' +import './AccessControlErrors' import styles from './AccessControlModal.styles.css' -import type { ComboboxChangeEvent } from '@/components/combobox' -import type { AccessChange, AccessControlBadge, AccessGrantEntry, AuthorizationSubjectSet, ChangedAccessGrant, PendingAccessGrant } from './types' -import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, type StringComboboxOptionData } from './helpers' +import type { AccessChange, AccessControlError, AccessGrantDraft, AccessGrantRoleChangedEvent, AccessSaveError, InputAccessControlError, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' +import { dedupeByKey } from './helpers' +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 } type PendingAccessGrantCreationResult = - | { grant: PendingAccessGrant; principle: string } - | { error: string; principle: string } + | { grant: PendingAccessGrant } + | { error: InputAccessControlError } @customElement('solid-ui-access-control-modal') -export default class AccessControlModal extends WebComponent { +export default class AccessControlModal extends DialogComponent { static styles = styles @property({ attribute: false }) @@ -58,12 +56,6 @@ export default class AccessControlModal extends WebComponent { @state() private accessor publicAccessRoleValue: AccessRole = 'No Access' - @state() - private accessor searchValue: string = '' - - @state() - private accessor failed: boolean = false - @state() private accessor submitting: boolean = false @@ -71,429 +63,94 @@ export default class AccessControlModal extends WebComponent { private accessor pendingAccessGrants: PendingAccessGrant[] = [] @state() - private accessor accessGrantRoles: AccessRole[] = [] + private accessor accessGrantDrafts: AccessGrantDraft[] = [] @state() - private accessor accessGrantLabels: string[] = [] - - @query('solid-ui-dialog') - private accessor dialog: Dialog | null = null - - 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() - this.refreshAccessGrantLabels() - } - } - - private async refreshAccessGrantLabels () { - const grants = this.accessGrants ?? [] - if (!grants.length) { - this.accessGrantLabels = [] - return - } - - const labels = await Promise.all(grants.map(async authorization => { - const groupUri = authorization.agentGroup[0] - if (groupUri) { - try { - await solidLogicSingleton.store.fetcher.load(sym(groupUri).doc()) - } catch { - // Keep the fallback label if the group document cannot be loaded. - } - } - - return this.getAuthorizationSubjectLabel(this.getSharedAuthorization(authorization)) - })) - - if (this.accessGrants === grants) { - this.accessGrantLabels = labels - } - } - - private getAccessGrantEntries () { - return (this.accessGrants ?? []) - .map((authorization, index) => { - authorization = this.getSharedAuthorization(authorization) - 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' - - if (leftIsOwner && !rightIsOwner) return -1 - if (!leftIsOwner && rightIsOwner) return 1 - return left.index - right.index - }) - } - - private getSharedAccessGrantEntries () { - return this.getAccessGrantEntries().filter(({ authorization }) => this.getAuthorizationSubjectIris(authorization).length > 0) - } - - 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: AccessGrantEntry) { - 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) + private accessor errors: AccessControlError[] = [] - 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) - } - } + @state() + private accessor successfulChangesRefreshed = true - const origin = authorization.origin[0] - if (origin) { - return { - kind: 'origin' as const, - text: 'O' - } - } + private accessGrantLabelRequestVersion = 0 - return { - kind: 'unknown' as const, - text: '?' - } + disconnectedCallback (): void { + this.accessGrantLabelRequestVersion++ + this.errors = [] + super.disconnectedCallback() } - private renderAuthorizationBadge (badge: AccessControlBadge) { - return html` - - ${badge.image ? html`` : html`${badge.text}`} - - ` + private get inputError (): string { + return this.errors.find(error => error.action === 'input')?.message ?? '' } - 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 get saveErrors (): AccessSaveError[] { + return this.errors.filter((error): error is AccessSaveError => error.action !== 'input') } - private getAuthorizationSubjectIris (authorization: Authorization) { - return [ - ...authorization.agent, - ...authorization.agentGroup, - ...authorization.agentClass, - ...authorization.origin, + private setInputError (error: InputAccessControlError) { + this.errors = [ + ...this.errors.filter(item => item.action !== 'input'), + error ] } - private getAuthorizationSubjectLabel (authorization: Authorization): string { - const subjects = this.getAuthorizationSubjectIris(authorization) - return subjects.length ? subjects.map(subject => label(sym(subject))).join(', ') : 'Unknown access holder' + private clearInputError () { + this.errors = this.errors.filter(error => error.action !== 'input') } - private getAuthorizationRole (authorization: Authorization): AccessRole { - return solidLogicSingleton.acl.roleFromModes(authorization.mode) - } + protected willUpdate (changedProperties: PropertyValues) { + super.willUpdate(changedProperties) - private getSharedAuthorization (authorization: Authorization): Authorization { - return { - ...authorization, - agentClass: authorization.agentClass.filter(iri => iri !== Authenticated.iri && iri !== Public.iri) + if (changedProperties.has('accessGrants')) { + const accessGrants = this.accessGrants ?? [] + this.accessGrantDrafts = accessGrants.map(createAccessGrantDraft) + this.authenticatedAccessRoleValue = getAuthenticatedAccessRole(accessGrants) + this.publicAccessRoleValue = getPublicAccessRole(accessGrants) } } - private getAuthenticatedAccessRole (): AccessRole { - const authorization = (this.accessGrants ?? []).find(item => - item.agentClass.includes(Authenticated.iri) - ) + protected updated (changedProperties: PropertyValues) { + super.updated(changedProperties) - 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' + if (changedProperties.has('accessGrants')) { + this.refreshAccessGrantLabels().catch((error: unknown) => { + debug.error('Failed to refresh access grant labels', error) + }) } - - 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 + private async refreshAccessGrantLabels () { + const requestVersion = ++this.accessGrantLabelRequestVersion + const grants = this.accessGrants ?? [] + if (!grants.length) { + this.accessGrantDrafts = [] + return } - 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 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 - - return html` - - ${variant === 'add' - ? this.renderAddRoleOptions() - : variant === 'authenticated' - ? this.renderAuthenticatedRoleOptions() - : 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} - `) - } + const labels = await Promise.all(grants.map(async authorization => { + 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. + } + })) - private renderAddAccessForm () { - return html` - - Add person, group or software agent URL. - - - - ${this.renderPendingAccessGrants()} - - ${this.renderModeSelector('add')} - - - ` - } + return getAuthorizationSubjectLabel(getSharedAuthorization(authorization)) + })) - private renderPendingAccessGrants () { - if (!this.pendingAccessGrants.length) { - return nothing + 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 + })) } - - 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` - - - ${this.renderGeneralAccessIcon()} - - ${options.title} - ${options.description} - - - ${this.renderModeSelector(options.variant)} - - ` - } - - private renderGeneralAccessIcon () { - return html` - - - - - - ` - } - - private getRoleModes (role: AccessRole): AccessMode[] { - return solidLogicSingleton.acl.modesFromRole(role) } private getDialogTitle (): string { @@ -514,9 +171,33 @@ export default class AccessControlModal extends WebComponent { - ${this.renderAddAccessForm()} - ${this.renderAccessGrantsSection()} - ${this.renderGeneralAccessSection()} + + + + @@ -528,7 +209,7 @@ export default class AccessControlModal extends WebComponent { Cancel 0} ?loading=${this.submitting} type="button" @click=${this.onSaveClick} @@ -549,7 +230,7 @@ export default class AccessControlModal extends WebComponent { return } - await this.commitPrinciplesFromInputSafely() + await this.commitPrincipalFromInputSafely() } private async onSaveClick () { @@ -558,7 +239,7 @@ export default class AccessControlModal extends WebComponent { } if (this.principalInputValue.trim()) { - const committed = await this.commitPrinciplesFromInputSafely() + const committed = await this.commitPrincipalFromInputSafely() if (!committed) { return @@ -569,7 +250,7 @@ export default class AccessControlModal extends WebComponent { } private onCancelClick () { - this.dialog?.close() + this.close() } private async saveAccessChanges () { @@ -577,79 +258,83 @@ export default class AccessControlModal extends WebComponent { return } - const changedAccessGrants = this.getChangedAccessGrants() + const changedAccessGrants = getChangedAccessGrants(this.accessGrantDrafts) 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.failed = false + 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 - })), - ...generalAccessChanges, - ...changedAccessGrants.flatMap(({ subjects, role }) => - subjects.map(subject => ({ subject, role })) - ) - ] + const accessChanges = buildAccessChanges({ + pendingAccessGrants: this.pendingAccessGrants, + generalAccessChanges, + changedAccessGrants + }) - 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 saveErrors = await applyAccessChanges(this.subjectUri, accessChanges) + if (saveErrors.length) { + this.errors = [...this.errors, ...saveErrors] + this.principalInputValue = '' + this.pendingAccessGrants = [] - await solidLogicSingleton.acl.applyPlan(plan) + 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) + return } this.principalInputValue = '' this.pendingAccessGrants = [] - this.dialog?.close() + this.close() } catch (error) { - this.failed = true - console.error('Failed to save access changes', error) + this.errors = [ + ...this.errors, + { + action: 'save', + targetUri: this.subjectUri ?? '', + label: 'Access changes', + message: error instanceof Error + ? error.message + : 'Unexpected error while saving access changes.' + } + ] } finally { this.submitting = false } } - private onPrincipalInput (event: Event) { - this.principalInputValue = this.getEventValue(event) - } - - private onPrincipalSelect (event: Event) { - const option = this.getSelectedStringComboboxOption(event) - - if (!option) { - return - } - - this.queuePendingPrinciples([option.value], this.addAccessRoleValue, option.label) - } - - private onSearchInput (event: Event) { - this.searchValue = this.getEventValue(event) + private onPrincipalInput (event: TargetInputEvent) { + this.principalInputValue = event.detail.value + this.clearInputError() } - private onSearchSelect (event: Event) { - const option = this.getSelectedComboboxOption(event) - - if (option) { - this.searchValue = option.label - } + private onPrincipalSelect (event: TargetSelectedEvent) { + this.queuePendingPrincipal(event.detail.value, this.addAccessRoleValue, event.detail.label) } private getGeneralAccessChanges (): AccessChange[] { @@ -657,405 +342,165 @@ export default class AccessControlModal extends WebComponent { 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 : { 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) } - 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) - - this.accessGrantRoles = this.accessGrantRoles.map((role, roleIndex) => { - return roleIndex === index ? value : role - }) + private onAccessGrantRoleInput (event: AccessGrantRoleChangedEvent) { + this.accessGrantDrafts = this.accessGrantDrafts.map(draft => + draft.id === event.detail.id + ? { ...draft, role: event.detail.role } + : draft + ) } - 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 { return Boolean( this.pendingAccessGrants.length || this.principalInputValue.trim() || - this.getChangedAccessGrants().length || + getChangedAccessGrants(this.accessGrantDrafts).length || this.getGeneralAccessChanges().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(this.getSharedAuthorization(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 }, - { 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 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 { + private async commitPrincipalFromInput (): Promise { const rawValue = this.principalInputValue.trim() if (!rawValue) { return false } - return this.queuePendingPrinciples([rawValue], this.addAccessRoleValue, undefined, rawValue) + return this.queuePendingPrincipal(rawValue, this.addAccessRoleValue, undefined, rawValue) } - private async commitPrinciplesFromInputSafely (): Promise { + private async commitPrincipalFromInputSafely (): Promise { + const inputSnapshot = this.principalInputValue.trim() + try { - return await this.commitPrinciplesFromInput() + return await this.commitPrincipalFromInput() } catch (error) { - this.failed = true - console.error('Failed to commit pending access grants', error) + if (this.principalInputValue.trim() !== inputSnapshot) { + debug.error('Ignored stale access-target validation error', error) + return false + } + + 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 queuePendingPrinciples ( - principles: string[], + private async queuePendingPrincipal ( + principal: 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 + const result = await this.createPendingAccessGrant(principal, role, preferredLabel) + + if ('error' in result) { + if (this.principalInputValue.trim() === snapshot) { + this.setInputError(result.error) } - this.logPendingGrantFailures(failedGrants, false) + debug.error('Failed to add access grant:', result.error.message) 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.clearInputError() - if (!failedGrants.length && this.principalInputValue.trim() === snapshot) { + if (this.principalInputValue.trim() === snapshot) { this.principalInputValue = '' } - return failedGrants.length === 0 + return true } private async createPendingAccessGrant ( - principle: string, + principal: string, role: AccessRole = this.addAccessRoleValue, preferredLabel?: string ): 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 normalizedPrincipal = principal.trim() + const resolvedTarget = await resolveAccessTarget(normalizedPrincipal, preferredLabel) + if (!resolvedTarget) { return { - principle, - error: 'Could not classify access target' + error: { + action: 'input', + label: 'Access target', + targetUri: normalizedPrincipal, + message: '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) + error: { + action: 'input', + label: 'Access target', + targetUri: principal, + message: error instanceof Error ? error.message : 'Could not verify this access target' + } } } } - private logPendingGrantFailures (failedGrants: string[], partial: boolean) { - if (!failedGrants.length) { - return - } - - console.error(partial ? 'Failed to add some access grants:' : 'Failed to add access grants:', failedGrants) - } - - private async resolvePendingAccessGrantLabel (subjectValue: string, fallbackLabel: string = subjectValue): Promise { - try { - const subject = sym(subjectValue) - await solidLogicSingleton.store.fetcher.load(subject.doc()) - const resolved = label(subject).trim() - return resolved || fallbackLabel - } catch { - return label(sym(subjectValue)) || fallbackLabel - } - } - 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 options = entries.map((entry: DirectoryEntry) => this.directoryEntryToOption(entry)) - 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 = this.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 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 { - try { - await solidLogicSingleton.store.fetcher.load(sym(uri).doc()) - } catch { - return undefined - } - - const labelText = await this.resolvePendingAccessGrantLabel(uri) - - return { - label: labelText === uri ? `Use ${uri}` : labelText, - value: uri - } - } - - private directoryEntryToOption (entry: DirectoryEntry): ComboboxOptionData { - return { - label: entry.label, - value: entry.uri, - template: this.directoryEntryToOptionTemplate(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) { - console.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..ce5f2828e --- /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 (id: string, event: ComboboxChangeEvent) { + const value = event.detail.option.value + if (typeof value !== 'string' || !ACCESS_ROLES.includes(value as AccessRole)) { + return + } + + const detail: AccessGrantRoleChangedDetail = { + id, + 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.id)} + + ` + } + + private renderAuthorizationBadge (authorization: AccessGrantViewModel['authorization'], subjectLabel: string) { + const badge = this.getAuthorizationBadge(authorization, subjectLabel) + + return html` + + ${badge.image + ? html`` + : html`${badge.text}`} + + ` + } + + 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, id: string) { + return html` + this.onAccessGrantRoleChange(id, 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/accessGrantDrafts.ts b/src/components/access-control-modal/accessGrantDrafts.ts new file mode 100644 index 000000000..fd2812212 --- /dev/null +++ b/src/components/access-control-modal/accessGrantDrafts.ts @@ -0,0 +1,95 @@ +import { label } from '@/utils/label' +import { Authenticated, Public, solidLogicSingleton, type AccessRole, type AccessSubject } from 'solid-logic' +import { sym } from 'rdflib' +import type { AccessAuthorization, AccessGrantDraft, AccessGrantViewModel, AuthorizationSubjectSet, ChangedAccessGrant } from './types' + +export function getSharedAuthorization (authorization: AccessAuthorization): AccessAuthorization { + return { + ...authorization, + agentClass: authorization.agentClass.filter(iri => iri !== Authenticated.iri && iri !== Public.iri) + } +} + +export function getAuthorizationSubjectEntries (authorization: AccessAuthorization): 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: AccessAuthorization): string { + return getAuthorizationSubjectEntries(authorization) + .map(subject => `${subject.type}:${subject.iri}`) + .sort() + .join('|') +} + +export function getAuthorizationSubjectLabel (authorization: AccessAuthorization): 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: AccessAuthorization): AccessRole { + return solidLogicSingleton.acl.roleFromModes(authorization.mode) +} + +export function createAccessGrantDraft (authorization: AccessAuthorization): 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 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: AccessAuthorization[]): AccessRole { + const authorization = accessGrants.find(item => item.agentClass.includes(Authenticated.iri)) + return authorization ? getAuthorizationRole(authorization) : 'No Access' +} + +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' +} + +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..b6f25cd2f --- /dev/null +++ b/src/components/access-control-modal/accessTargetResolver.ts @@ -0,0 +1,148 @@ +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 const accessPrincipalOptionsProvider: AsyncComboboxOptionsProvider = + 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/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/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 96663ccd7..43b376834 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -1,15 +1,42 @@ -import type { AccessControlSubjectKind, AccessRole, AccessSubject, Authorization } from 'solid-logic' +import type { AccessControlSubjectKind, AccessMode, AccessRole, AccessSubject, Authorization } from 'solid-logic' -export type AccessControlBadgeKind = AccessControlSubjectKind | 'unknown' +export type AccessAuthorization = Omit< + Pick, + 'mode' +> & { + mode: readonly AccessMode[] +} + +export type TargetInputDetail = { + value: string +} + +export type TargetSelectedDetail = { + value: string + label: string +} -export type AccessControlBadge = { - kind: AccessControlBadgeKind - image?: string - text: 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 = { + id: string + role: AccessRole +} + +export type AccessGrantRoleChangedEvent = CustomEvent + export type PendingAccessGrant = { - id?: string subjectType: AccessControlSubjectKind subjectValue: string role: AccessRole @@ -17,15 +44,21 @@ export type PendingAccessGrant = { } export type AccessGrantEntry = { - authorization: Authorization - index: number + id: string + authorization: AccessAuthorization role: AccessRole subjectLabel: string - badge: AccessControlBadge } +export type AccessGrantViewModel = AccessGrantEntry & { + initialRole: AccessRole + subjects: AccessSubject[] +} + +export type AccessGrantDraft = AccessGrantViewModel + export type ChangedAccessGrant = { - authorization: Authorization + authorization: AccessAuthorization subjects: AccessSubject[] role: AccessRole } @@ -38,4 +71,27 @@ export type AuthorizationSubjectSet = { export type AccessChange = { subject: AccessSubject role: AccessRole + label: 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 + message: string +} + +export type AccessControlError = InputAccessControlError | AccessSaveError 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; diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index dbff6df18..6fa33c064 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -1,6 +1,8 @@ // @vitest-environment jsdom 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() @@ -8,6 +10,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 +32,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,14 +57,19 @@ describe('AccessControlModal submit', () => { planPublicRead.mockReset() applyPlan.mockReset() classifyAccessControlSubject.mockReset() - classifyAccessControlSubject.mockImplementation(async (principle: string) => ({ + findAccessGrants.mockReset() + load.mockReset() + 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: [] }) applyPlan.mockResolvedValue(new Response('ok', { status: 200 })) + 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', () => { @@ -97,6 +107,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' @@ -111,6 +145,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'] @@ -150,6 +197,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 => { @@ -235,7 +301,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 () => { @@ -260,6 +326,297 @@ 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]) + 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 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) + 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: [] } + }) + 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' + element.pendingAccessGrants = [successfulGrant, failedGrant] + document.body.appendChild(element) + await element.updateComplete + const close = vi.fn() + window.addEventListener('solid-ui:close-dialog', close) + + await element.onSaveClick() + await element.updateComplete + + // 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) + + 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')) + findAccessGrants.mockResolvedValue([{ + agent: ['https://example.com/current-agent'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'] as AccessMode[] + }]) + + 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 close = vi.fn() + window.addEventListener('solid-ui:close-dialog', close) + + await element.onSaveClick() + await element.updateComplete + + 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'] as AccessMode[] + }]) + expect(close).not.toHaveBeenCalled() + 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) + }) + + 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', + 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 close = vi.fn() + window.addEventListener('solid-ui:close-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 = [{ + get subjectType () { + throw new Error('unexpected save failure') + }, + subjectValue: 'https://example.com/agent', + role: 'Editor', + label: 'Agent' + }] + document.body.appendChild(element) + await element.updateComplete + const close = vi.fn() + window.addEventListener('solid-ui:close-dialog', close) + await element.onSaveClick() + await element.updateComplete + + expect(close).not.toHaveBeenCalled() + 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)) + .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' @@ -289,15 +646,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' } })) @@ -321,7 +676,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, @@ -362,7 +719,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, @@ -386,9 +745,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()) @@ -416,7 +779,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') @@ -425,7 +790,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 = [ { @@ -454,10 +819,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']) @@ -468,6 +835,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' @@ -486,28 +854,30 @@ 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.getSharedAccessGrantEntries() + 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) 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, @@ -524,6 +894,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' @@ -538,7 +933,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, @@ -572,7 +969,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) }) @@ -591,7 +988,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, 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..98800a26d --- /dev/null +++ b/test/components/access-control-modal/accessGrantDrafts.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { Authenticated, Public, type AccessMode } from 'solid-logic' +import type { AccessAuthorization } from '../../../src/components/access-control-modal/types' +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: AccessAuthorization = { + 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: AccessAuthorization = { + agent: ['https://alice.example/profile#me'], + agentGroup: ['https://example/group'], + agentClass: [], + origin: ['https://example.test'], + mode: ['Read'] as AccessMode[] + } + 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'] as AccessMode[] + }, + 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..8877af3a0 --- /dev/null +++ b/test/components/access-control-modal/accessTargetResolver.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { + accessPrincipalOptionsProvider, + 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 () => { + 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..d9739bc71 --- /dev/null +++ b/test/components/access-control-modal/buildAccessChanges.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { AccessMode } from 'solid-logic' +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'] as AccessMode[] + }, + 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' + } + ]) + }) +})
Add person, group or software agent URL.
${this.inputError}
${options.title}
${options.description}