diff --git a/package-lock.json b/package-lock.json index eef92a3ff..b99773310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "mime-types": "^3.0.2", "pane-registry": "5.0.2-0", "rdflib": "2.4.1", - "solid-logic": "6.0.1-0", + "solid-logic": "6.0.1-1", "solid-namespace": "^0.5.4", "uuid": "^14.0.0" }, @@ -2343,9 +2343,9 @@ } }, "node_modules/@dokieli/web-access-control": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@dokieli/web-access-control/-/web-access-control-1.0.0.tgz", - "integrity": "sha512-KEmpqbEtGTxk2+YUK69GHWwgO5c34foOF6AClTfvqk3O9r5RaEAYa+L0sRaOLfWs1eCXt/quqqFpFzEwC7IP6Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@dokieli/web-access-control/-/web-access-control-1.1.0.tgz", + "integrity": "sha512-LId2cNz0pKafc6dwohaX6j+mr886uEV2o8L0VrAWUDxU0LPN4+8Yc8Yx3D94zrsRSJuBWRtQzSjgtEqhYg5y/w==", "license": "Apache-2.0", "dependencies": { "@rdfjs/data-model": "^2.1.1", @@ -6732,9 +6732,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", - "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7109,15 +7109,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", - "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz", + "integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==", "dev": true, "license": "MIT", "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.9.0", + "shell-quote": "1.12.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" @@ -7542,9 +7542,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.451", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.451.tgz", - "integrity": "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "dev": true, "license": "ISC" }, @@ -11067,9 +11067,9 @@ "license": "MIT" }, "node_modules/n3": { - "version": "2.13.10", - "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.10.tgz", - "integrity": "sha512-GunqYLhMwtrLbpSYpE+7sZyH8A7/7KCpgL4KAGD4RDn0rAiZGxLkyskdAlZGVyJcpwlsjeHJRlFFSUJKHpeOjA==", + "version": "2.13.11", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.11.tgz", + "integrity": "sha512-RZ+iINCXqZm4Tr6rA1/WQI573hPrwbkiWQspz5TViIWLWEs5y6+AVWoNQ7preieLhqdi9H1E+AOR+LvlhKxsOg==", "license": "MIT", "dependencies": { "buffer": "^6.0.3", @@ -11704,6 +11704,22 @@ "solid-logic": "6.0.1-0" } }, + "node_modules/pane-registry/node_modules/solid-logic": { + "version": "6.0.1-0", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-0.tgz", + "integrity": "sha512-ZEJ4aRK5Q16sYoNTiH8aJ2ctvpZPwvsPYKawL9pmPC7qvF6wVnOucuNLp1EHHYhdX1Wkp9a25mp/my1IsQgofA==", + "license": "MIT", + "dependencies": { + "@dokieli/web-access-control": "^1.0.0", + "@uvdsl/solid-oidc-client-browser": "^0.2.3", + "solid-namespace": "^0.5.4", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12638,9 +12654,9 @@ } }, "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "dev": true, "license": "MIT", "engines": { @@ -12743,12 +12759,12 @@ } }, "node_modules/solid-logic": { - "version": "6.0.1-0", - "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-0.tgz", - "integrity": "sha512-ZEJ4aRK5Q16sYoNTiH8aJ2ctvpZPwvsPYKawL9pmPC7qvF6wVnOucuNLp1EHHYhdX1Wkp9a25mp/my1IsQgofA==", + "version": "6.0.1-1", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-1.tgz", + "integrity": "sha512-k0EB26f3whZWfvePl6qHcHhOa4TWfnjPLphAa++s2ZR7+FvruM9Cc9eyvzJqEF6b3HF72jH8V7ENiRseFI51Ow==", "license": "MIT", "dependencies": { - "@dokieli/web-access-control": "^1.0.0", + "@dokieli/web-access-control": "^1.1.0", "@uvdsl/solid-oidc-client-browser": "^0.2.3", "solid-namespace": "^0.5.4", "vite": "^8.0.16", @@ -13907,15 +13923,15 @@ } }, "node_modules/vite": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", - "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.11", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/package.json b/package.json index 450556207..5b0b8d37f 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "mime-types": "^3.0.2", "pane-registry": "5.0.2-0", "rdflib": "2.4.1", - "solid-logic": "6.0.1-0", + "solid-logic": "6.0.1-1", "solid-namespace": "^0.5.4", "uuid": "^14.0.0" }, diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index b76dd5f47..cb0d0c17b 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -104,6 +104,13 @@ 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; @@ -231,8 +238,6 @@ align-items: flex-start; gap: 10px; - --access-role-select-width: 110px; - solid-ui-combobox, solid-ui-input { --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); @@ -245,12 +250,23 @@ 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: 15px; + gap: 10px; align-self: stretch; .access-grants-general-share-content { @@ -331,6 +347,14 @@ 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; diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index 423f25cbf..b6f30e48d 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -5,7 +5,7 @@ import { property, query, state } from 'lit/decorators.js' import { label } from '@/utils/label' import { findImage } from '@/widgets' import type Dialog from '@/components/dialog' -import { ACCESS_ROLES, DEFAULT_DIRECTORY_SOURCES, solidLogicSingleton, type AccessMode, type AccessRole, type AccessSubject, type Authorization, type DirectoryEntry } from 'solid-logic' +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 { sym } from 'rdflib' @@ -26,11 +26,16 @@ import '@/components/combobox-option' 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, isHttpUri, type StringComboboxOptionData } from './helpers' function isDefined (value: T | undefined | null): value is T { return value !== undefined && value !== null } +type PendingAccessGrantCreationResult = + | { grant: PendingAccessGrant; principle: string } + | { error: string; principle: string } + @customElement('solid-ui-access-control-modal') export default class AccessControlModal extends WebComponent { static styles = styles @@ -48,7 +53,10 @@ export default class AccessControlModal extends WebComponent { private accessor addAccessRoleValue: AccessRole = 'Viewer' @state() - private accessor sharedAccessRoleValue: AccessRole = 'No Access' + private accessor authenticatedAccessRoleValue: AccessRole = 'No Access' + + @state() + private accessor publicAccessRoleValue: AccessRole = 'No Access' @state() private accessor searchValue: string = '' @@ -76,7 +84,9 @@ export default class AccessControlModal extends WebComponent { if (changedProperties.has('accessGrants')) { this.accessGrantRoles = this.accessGrants?.map(item => this.getAuthorizationRole(item)) ?? [] - void this.refreshAccessGrantLabels() + this.authenticatedAccessRoleValue = this.getAuthenticatedAccessRole() + this.publicAccessRoleValue = this.getPublicAccessRole() + this.refreshAccessGrantLabels() } } @@ -97,7 +107,7 @@ export default class AccessControlModal extends WebComponent { } } - return this.getAuthorizationSubjectLabel(authorization) + return this.getAuthorizationSubjectLabel(this.getSharedAuthorization(authorization)) })) if (this.accessGrants === grants) { @@ -108,6 +118,7 @@ export default class AccessControlModal extends WebComponent { private getAccessGrantEntries () { return (this.accessGrants ?? []) .map((authorization, index) => { + authorization = this.getSharedAuthorization(authorization) const subjectLabel = this.getAccessGrantSubjectLabel(authorization, index) return { @@ -128,9 +139,13 @@ export default class AccessControlModal extends WebComponent { }) } - private renderAccessGrants() { + private getSharedAccessGrantEntries () { + return this.getAccessGrantEntries().filter(({ authorization }) => this.getAuthorizationSubjectIris(authorization).length > 0) + } + + private renderAccessGrants () { const query = this.searchValue.trim().toLowerCase() - const accessGrants = this.getAccessGrantEntries().filter(({ subjectLabel }) => { + const accessGrants = this.getSharedAccessGrantEntries().filter(({ subjectLabel }) => { if (!query) { return true } @@ -225,6 +240,33 @@ export default class AccessControlModal extends WebComponent { return solidLogicSingleton.acl.roleFromModes(authorization.mode) } + private getSharedAuthorization (authorization: Authorization): Authorization { + return { + ...authorization, + agentClass: authorization.agentClass.filter(iri => iri !== Authenticated.iri && iri !== Public.iri) + } + } + + private getAuthenticatedAccessRole (): AccessRole { + const authorization = (this.accessGrants ?? []).find(item => + item.agentClass.includes(Authenticated.iri) + ) + + return authorization ? this.getAuthorizationRole(authorization) : 'No Access' + } + + private getPublicAccessRole (): AccessRole { + const authorization = (this.accessGrants ?? []).find(item => + item.agentClass.includes(Public.iri) + ) + + if (!authorization) { + return 'No Access' + } + + return solidLogicSingleton.acl.publicRoleFromModes(authorization.mode) + } + private getRoleValueFromEvent (event: Event, fallback: AccessRole = 'Viewer'): AccessRole { const selectedValue = this.getSelectedComboboxOptionValue(event) @@ -249,19 +291,34 @@ export default class AccessControlModal extends WebComponent { ` } - private renderModeSelector (variant: 'add' | 'general' = 'add') { + private renderModeSelector (variant: 'add' | 'authenticated' | 'public' = 'add') { const className = variant === 'add' ? 'access-role-select access-role-select--top' - : 'access-role-select access-role-select--compact' - const value = variant === 'add' ? this.addAccessRoleValue : this.sharedAccessRoleValue + : 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() : this.renderGeneralRoleOptions()} + ${variant === 'add' + ? this.renderAddRoleOptions() + : variant === 'authenticated' + ? this.renderAuthenticatedRoleOptions() + : this.renderPublicRoleOptions()} ` } @@ -274,12 +331,18 @@ export default class AccessControlModal extends WebComponent { `) } - private renderGeneralRoleOptions () { + 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} @@ -324,7 +387,8 @@ export default class AccessControlModal extends WebComponent { type="button" variant="ghost" class="access-grants-pending-item-remove" - @click=${() => this.removePendingAccessGrant(index)} + data-pending-grant-index=${index} + @click=${this.onRemovePendingAccessGrantClick} > Remove ${grant.label} @@ -336,8 +400,6 @@ export default class AccessControlModal extends WebComponent { } private renderAccessGrantsSection () { - const accessGrantOptions = this.getAccessGrantSearchOptions() - return html`

Share with

@@ -350,10 +412,10 @@ export default class AccessControlModal extends WebComponent { @input=${this.onSearchInput} @change=${this.onSearchSelect} > - ${accessGrantOptions.map(option => html` + ${this.getSharedAccessGrantEntries().map(({ subjectLabel }) => html` - ${option.label} + .value=${subjectLabel}> + ${subjectLabel} `)} @@ -375,19 +437,38 @@ export default class AccessControlModal extends WebComponent { @click=${this.onCopyLinkClick} > - Copy Link + Copy Link
-
- + ` + } + + private renderGeneralShareRow (options: { + title: string + description: string + variant: 'authenticated' | 'public' + }) { + return html` + ` } @@ -416,39 +497,39 @@ export default class AccessControlModal extends WebComponent { return `Share "${subjectLabel}"` } - + protected render () { const dialogTitle = this.getDialogTitle() - + return html` - -
- - ${this.renderAddAccessForm()} - ${this.renderAccessGrantsSection()} - ${this.renderGeneralAccessSection()} - - - - - -
-
+ +
+ + ${this.renderAddAccessForm()} + ${this.renderAccessGrantsSection()} + ${this.renderGeneralAccessSection()} + + + + + +
+
` } @@ -459,7 +540,7 @@ export default class AccessControlModal extends WebComponent { return } - await this.commitPrinciplesFromInput() + await this.commitPrinciplesFromInputSafely() } private async onSaveClick () { @@ -468,7 +549,11 @@ export default class AccessControlModal extends WebComponent { } if (this.principalInputValue.trim()) { - await this.commitPrinciplesFromInput() + const committed = await this.commitPrinciplesFromInputSafely() + + if (!committed) { + return + } } await this.saveAccessChanges() @@ -484,8 +569,9 @@ export default class AccessControlModal extends WebComponent { } const changedAccessGrants = this.getChangedAccessGrants() + const generalAccessChanges = this.getGeneralAccessChanges() - if (!this.pendingAccessGrants.length && !changedAccessGrants.length) { + if (!this.pendingAccessGrants.length && !changedAccessGrants.length && !generalAccessChanges.length) { this.failed = true return } @@ -504,6 +590,7 @@ export default class AccessControlModal extends WebComponent { subject: this.createAccessSubject(grant.subjectType, grant.subjectValue), role: grant.role })), + ...generalAccessChanges, ...changedAccessGrants.flatMap(({ subjects, role }) => subjects.map(subject => ({ subject, role })) ) @@ -512,7 +599,9 @@ export default class AccessControlModal extends WebComponent { for (const { subject, role } of accessChanges) { const plan = role === 'No Access' ? await solidLogicSingleton.acl.planRevoke(this.subjectUri, subject) - : await solidLogicSingleton.acl.planGrant(this.subjectUri, subject, this.getRoleModes(role)) + : await solidLogicSingleton.acl.planGrant(this.subjectUri, subject, subject.type === 'agentClass' && subject.iri === Public.iri + ? solidLogicSingleton.acl.modesFromPublicRole(role as (typeof PUBLIC_ACCESS_ROLES)[number]) + : this.getRoleModes(role)) await solidLogicSingleton.acl.applyPlan(plan) } @@ -533,17 +622,13 @@ export default class AccessControlModal extends WebComponent { } private onPrincipalSelect (event: Event) { - const option = this.getSelectedComboboxOption(event) + const option = this.getSelectedStringComboboxOption(event) if (!option) { return } - if (typeof option.value !== 'string' || !option.value) { - return - } - - void this.queuePendingPrinciples([option.value], this.addAccessRoleValue, option.label) + this.queuePendingPrinciples([option.value], this.addAccessRoleValue, option.label) } private onSearchInput (event: Event) { @@ -553,11 +638,36 @@ export default class AccessControlModal extends WebComponent { private onSearchSelect (event: Event) { const option = this.getSelectedComboboxOption(event) - if (option && typeof option.label === 'string') { + if (option) { this.searchValue = option.label } } + private getGeneralAccessChanges (): AccessChange[] { + if (!this.subjectUri) { + return [] + } + + const currentAuthenticatedRole = this.getAuthenticatedAccessRole() + const currentPublicRole = this.getPublicAccessRole() + + const authenticatedChange = this.authenticatedAccessRoleValue === currentAuthenticatedRole + ? undefined + : { + subject: Authenticated, + role: this.authenticatedAccessRoleValue + } + + const publicChange = this.publicAccessRoleValue === currentPublicRole + ? undefined + : { + subject: Public, + role: this.publicAccessRoleValue + } + + return [authenticatedChange, publicChange].filter(isDefined) + } + private onAddAccessRoleInput (event: Event) { const nextRole = this.getRoleValueFromEvent(event) this.addAccessRoleValue = nextRole @@ -567,9 +677,14 @@ export default class AccessControlModal extends WebComponent { })) } - private onSharedAccessRoleInput (event: Event) { + private onAuthenticatedAccessRoleInput (event: Event) { const nextRole = this.getRoleValueFromEvent(event) - this.sharedAccessRoleValue = nextRole + this.authenticatedAccessRoleValue = nextRole + } + + private onPublicAccessRoleInput (event: Event) { + const nextRole = this.getRoleValueFromEvent(event, 'No Access') + this.publicAccessRoleValue = nextRole } private onAccessGrantRoleInput (index: number, event: Event) { @@ -584,11 +699,23 @@ export default class AccessControlModal extends WebComponent { 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 hasUnsavedChanges (): boolean { return Boolean( this.pendingAccessGrants.length || this.principalInputValue.trim() || - this.getChangedAccessGrants().length + this.getChangedAccessGrants().length || + this.getGeneralAccessChanges().length ) } @@ -608,7 +735,7 @@ export default class AccessControlModal extends WebComponent { return undefined } - const subjects = this.getAuthorizationSubjectEntries(authorization) + const subjects = this.getAuthorizationSubjectEntries(this.getSharedAuthorization(authorization)) if (!subjects.length) { return undefined } @@ -645,18 +772,36 @@ export default class AccessControlModal extends WebComponent { return (event as ComboboxChangeEvent).detail?.option } + private getSelectedStringComboboxOption (event: Event): StringComboboxOptionData | undefined { + const option = this.getSelectedComboboxOption(event) + return this.isStringComboboxOptionData(option) ? option : undefined + } + private getSelectedComboboxOptionValue (event: Event): unknown { return this.getSelectedComboboxOption(event)?.value } + private isStringComboboxOptionData (option: ComboboxOptionData | undefined): option is StringComboboxOptionData { + return typeof option?.value === 'string' + } + private async commitPrinciplesFromInput (): Promise { const rawValue = this.principalInputValue.trim() if (!rawValue) { return false } - await this.queuePendingPrinciples([rawValue], this.addAccessRoleValue, undefined, rawValue) - return true + return this.queuePendingPrinciples([rawValue], this.addAccessRoleValue, undefined, rawValue) + } + + private async commitPrinciplesFromInputSafely (): Promise { + try { + return await this.commitPrinciplesFromInput() + } catch (error) { + this.failed = true + console.error('Failed to commit pending access grants', error) + return false + } } private async queuePendingPrinciples ( @@ -664,55 +809,90 @@ export default class AccessControlModal extends WebComponent { role: AccessRole = this.addAccessRoleValue, preferredLabel?: string, inputSnapshot?: string - ) { + ): Promise { const snapshot = inputSnapshot ?? this.principalInputValue.trim() - const pendingGrants = await Promise.all(principles.map(async principle => this.createPendingAccessGrant(principle, role, preferredLabel))) - const newGrants = pendingGrants.filter(isDefined) + 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) { - return + if (failedGrants.length) { + this.failed = true + } + this.logPendingGrantFailures(failedGrants, false) + return false } const merged = [...this.pendingAccessGrants, ...newGrants] this.pendingAccessGrants = this.dedupePendingAccessGrants(merged) + if (failedGrants.length) { + this.failed = true + } + this.logPendingGrantFailures(failedGrants, true) - if (this.principalInputValue.trim() === snapshot) { + if (!failedGrants.length && this.principalInputValue.trim() === snapshot) { this.principalInputValue = '' } + + return failedGrants.length === 0 } private async createPendingAccessGrant ( principle: string, role: AccessRole = this.addAccessRoleValue, preferredLabel?: string - ): Promise { - const normalizedPrinciple = this.normalizeAccessPrincipleInput(principle) - const resolvedPrinciple = await solidLogicSingleton.acl.classifyAccessControlSubject(normalizedPrinciple) - const fallbackKind = this.isHttpUri(principle) ? 'agent' : undefined - const subjectType = resolvedPrinciple?.kind ?? fallbackKind - const subjectValue = resolvedPrinciple?.subjectValue ?? normalizedPrinciple - - if (!subjectType) { - console.error(`Could not classify access target: ${principle}`) - return undefined - } + ): Promise { + try { + const normalizedPrinciple = principle.trim() + const resolvedPrinciple = await solidLogicSingleton.acl.classifyAccessControlSubject(normalizedPrinciple) + const fallbackKind = this.isHttpUri(principle) ? 'agent' : undefined + const subjectType = resolvedPrinciple?.kind ?? fallbackKind + const subjectValue = resolvedPrinciple?.subjectValue ?? normalizedPrinciple - if (subjectType === 'origin') { - console.error(`Origin access grants are not supported yet: ${principle}`) - return undefined - } + if (!subjectType) { + return { + principle, + error: 'Could not classify access target' + } + } + + if (subjectType === 'origin') { + return { + principle, + error: 'Origin access grants are not supported yet' + } + } - const label = preferredLabel ?? await this.resolvePendingAccessGrantLabel(subjectValue, principle) + const label = preferredLabel ?? await this.resolvePendingAccessGrantLabel(subjectValue, principle) - return { - subjectType, - subjectValue, - role, - label + return { + principle, + grant: { + subjectType, + subjectValue, + role, + label + } + } + } catch (error) { + return { + principle, + error: String(error) + } + } + } + + 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): Promise { + private async resolvePendingAccessGrantLabel (subjectValue: string, fallbackLabel: string = subjectValue): Promise { try { const subject = sym(subjectValue) await solidLogicSingleton.store.fetcher.load(subject.doc()) @@ -724,30 +904,15 @@ export default class AccessControlModal extends WebComponent { } private dedupePendingAccessGrants (draftGrants: PendingAccessGrant[]): PendingAccessGrant[] { - const seen = new Set() - - return draftGrants.filter(draftGrant => { - const key = `${draftGrant.subjectType}:${draftGrant.subjectValue}` - if (seen.has(key)) { - return false - } - - seen.add(key) - return true - }) + return dedupeByKey(draftGrants, draftGrant => `${draftGrant.subjectType}:${draftGrant.subjectValue}`) } private readonly accessPrincipleOptionsProvider = defineAsyncComboboxOptionsProvider(async (filter: string) => { - const query = this.getPrincipleSearchTerm(filter) - const urlOption = this.isHttpUri(query) ? await this.createUrlOption(query) : undefined - const preferredOptions = this.dedupeComboboxOptions([urlOption].filter((option): option is ComboboxOptionData => Boolean(option))) + const query = filter.trim() + const preferredOptions = await this.getPreferredAccessPrincipleOptions(query) if (query.length < 2) { - return preferredOptions.length ? preferredOptions : [{ - label: 'Type at least 2 characters to search', - value: '', - selectable: false - }] + return this.getShortQueryOptions(preferredOptions) } let entries: DirectoryEntry[] = [] @@ -758,7 +923,7 @@ export default class AccessControlModal extends WebComponent { sources: DEFAULT_DIRECTORY_SOURCES }) } catch (error) { - if (!urlOption) { + if (!preferredOptions.length) { throw error } } @@ -767,14 +932,35 @@ export default class AccessControlModal extends WebComponent { return [...preferredOptions, ...options] }) - private async createUrlOption (uri: string): Promise { + 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 { + if (!isHttpUri(query)) { + return [] + } + + const urlOption = await this.createUrlOption(query) + return dedupeComboboxOptions(urlOption ? [urlOption] : []) + } + + private async createUrlOption (uri: string): Promise { try { await solidLogicSingleton.store.fetcher.load(sym(uri).doc()) } catch { return undefined } - const labelText = await this.resolvePendingAccessGrantLabel(uri, uri) + const labelText = await this.resolvePendingAccessGrantLabel(uri) return { label: labelText === uri ? `Use ${uri}` : labelText, @@ -782,23 +968,6 @@ export default class AccessControlModal extends WebComponent { } } - private dedupeComboboxOptions (options: ComboboxOptionData[]): ComboboxOptionData[] { - const seen = new Set() - - return options.filter(option => { - if (typeof option.value !== 'string' || !option.value) { - return false - } - - if (seen.has(option.value)) { - return false - } - - seen.add(option.value) - return true - }) - } - private directoryEntryToOption (entry: DirectoryEntry): ComboboxOptionData { return { label: entry.label, @@ -809,7 +978,9 @@ export default class AccessControlModal extends WebComponent { private directoryEntryToOptionTemplate (entry: DirectoryEntry) { return html` - + + ${this.renderDirectoryEntryIcon(entry)} ${entry.label} @@ -832,30 +1003,10 @@ export default class AccessControlModal extends WebComponent { return nothing } - private getAccessGrantSearchOptions (): ComboboxOptionData[] { - return this.getAccessGrantEntries().map(({ subjectLabel }) => ({ - label: subjectLabel, - value: subjectLabel - })) - } - - private getPrincipleSearchTerm (value: string): string { - const lastCommaIndex = value.lastIndexOf(',') - if (lastCommaIndex < 0) { - return value.trim() - } - - return value.slice(lastCommaIndex + 1).trim() - } - private isHttpUri (value: string): boolean { return value.startsWith('http://') || value.startsWith('https://') } - private normalizeAccessPrincipleInput (value: string): string { - return value.trim() - } - private async onCopyLinkClick (event: Event) { event.preventDefault() diff --git a/src/components/access-control-modal/helpers.ts b/src/components/access-control-modal/helpers.ts new file mode 100644 index 000000000..6a6b77f91 --- /dev/null +++ b/src/components/access-control-modal/helpers.ts @@ -0,0 +1,24 @@ +import type { ComboboxOptionData } from '@/components/combobox' + +export type StringComboboxOptionData = ComboboxOptionData & { value: string } + +export function isHttpUri (possibleUri: string): boolean { + return possibleUri.startsWith('http://') || possibleUri.startsWith('https://') +} + +export function dedupeByKey (items: T[], getKey: (item: T) => string | undefined): T[] { + const uniqueItems = new Map() + + for (const item of items) { + const key = getKey(item) + if (key && !uniqueItems.has(key)) { + uniqueItems.set(key, item) + } + } + + return [...uniqueItems.values()] +} + +export function dedupeComboboxOptions (options: StringComboboxOptionData[]): StringComboboxOptionData[] { + return dedupeByKey(options, option => option.value) +} diff --git a/src/components/access-control-modal/index.ts b/src/components/access-control-modal/index.ts index b8513f270..8b0b7eacd 100644 --- a/src/components/access-control-modal/index.ts +++ b/src/components/access-control-modal/index.ts @@ -1,4 +1,4 @@ import AccessControlModal from './AccessControlModal' export { AccessControlModal } -export default AccessControlModal \ No newline at end of file +export default AccessControlModal diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index e4ea5593c..864e79468 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -158,6 +158,7 @@ export default class Combobox extends FormControlComponent { .value=${this.displayValue} @keydown=${this.onInputKeyDown} @focus=${this.onInputFocus} + @click=${this.onInputClick} @input=${() => this.selectOnly ? this.updateDisplayValue(this.controlElement?.value ?? '') : this.controlTrait.onInput()} /> @@ -445,6 +446,10 @@ export default class Combobox extends FormControlComponent { this.show() } + private onInputClick () { + this.show() + } + private openPopupFromKey (focusLast: boolean) { if (!this.open) { this.show({ focusLast }) diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 186552fc3..3f7928999 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const planGrant = vi.fn() const planRevoke = vi.fn() +const planPublicRead = vi.fn() const applyPlan = vi.fn() const classifyAccessControlSubject = vi.fn() @@ -24,6 +25,7 @@ vi.mock('solid-logic', async (importOriginal) => { ...actual.solidLogicSingleton.acl, classifyAccessControlSubject, planGrant, + planPublicRead, planRevoke, applyPlan }, @@ -46,6 +48,7 @@ describe('AccessControlModal submit', () => { beforeEach(async () => { planGrant.mockReset() planRevoke.mockReset() + planPublicRead.mockReset() applyPlan.mockReset() classifyAccessControlSubject.mockReset() classifyAccessControlSubject.mockImplementation(async (principle: string) => ({ @@ -53,6 +56,7 @@ describe('AccessControlModal submit', () => { subjectValue: principle })) 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 })) await import('../../src/components/access-control-modal/AccessControlModal') }) @@ -181,6 +185,30 @@ describe('AccessControlModal submit', () => { expect(element.pendingAccessGrants).toEqual([]) }) + 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' + element.accessGrants = [] + element.authenticatedAccessRoleValue = 'Editor' + element.publicAccessRoleValue = 'Viewer' + + await element.onSaveClick() + + expect(planGrant).toHaveBeenNthCalledWith( + 1, + 'https://example.com/resource.ttl', + { type: 'agentClass', iri: 'http://www.w3.org/ns/auth/acl#AuthenticatedAgent' }, + ['Read', 'Write'] + ) + expect(planGrant).toHaveBeenNthCalledWith( + 2, + 'https://example.com/resource.ttl', + { type: 'agentClass', iri: 'http://xmlns.com/foaf/0.1/Agent' }, + ['Read'] + ) + expect(applyPlan).toHaveBeenCalledTimes(2) + }) + it('reads the selected role from the rendered combobox change event', async () => { const element = document.createElement('solid-ui-access-control-modal') as any document.body.appendChild(element) @@ -199,7 +227,8 @@ describe('AccessControlModal submit', () => { })) expect(element.addAccessRoleValue).toBe('Editor') - expect(element.sharedAccessRoleValue).toBe('No Access') + expect(element.authenticatedAccessRoleValue).toBe('No Access') + expect(element.publicAccessRoleValue).toBe('No Access') document.body.removeChild(element) }) @@ -209,14 +238,15 @@ describe('AccessControlModal submit', () => { element.subjectUri = 'https://example.com/resource.ttl' element.principalInputValue = 'https://alice.example.com/profile/card.ttl#me' element.addAccessRoleValue = 'Viewer' - element.sharedAccessRoleValue = 'No Access' + element.authenticatedAccessRoleValue = 'No Access' + element.publicAccessRoleValue = 'No Access' await element.onSubmit(new Event('submit')) document.body.appendChild(element) await element.updateComplete - const sharedRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--compact') + const sharedRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--authenticated') sharedRoleSelect.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, @@ -229,7 +259,8 @@ describe('AccessControlModal submit', () => { })) expect(element.addAccessRoleValue).toBe('Viewer') - expect(element.sharedAccessRoleValue).toBe('Editor') + expect(element.authenticatedAccessRoleValue).toBe('Editor') + expect(element.publicAccessRoleValue).toBe('No Access') expect(element.pendingAccessGrants).toEqual([ expect.objectContaining({ role: 'Viewer' @@ -274,20 +305,24 @@ describe('AccessControlModal submit', () => { expect(element.getDialogTitle()).toBe('Share this resource') }) - it('hides no access in the add selector and keeps no access in the general selector', async () => { + it('hides no access in the add selector, keeps it in the authenticated selector, and exposes dokieli public roles', async () => { const element = document.createElement('solid-ui-access-control-modal') as any document.body.appendChild(element) await element.updateComplete const addRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--top') - const generalRoleSelect = element.shadowRoot.querySelector('solid-ui-combobox.access-role-select--compact') + 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 addOptions = [...addRoleSelect.querySelectorAll('solid-ui-combobox-option')].map((option: Element) => option.textContent?.trim()) - const generalOptions = [...generalRoleSelect.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()) + const publicOptions = [...publicRoleSelect.querySelectorAll('solid-ui-combobox-option')].map((option: Element) => option.textContent?.trim()) expect(addOptions).not.toContain('No Access') expect(addOptions).toContain('Viewer') - expect(generalOptions).toContain('No Access') + expect(authenticatedOptions).toContain('No Access') + expect(authenticatedOptions).toContain('Editor') + expect(publicOptions).toEqual(['No Access', 'Viewer']) document.body.removeChild(element) }) @@ -313,6 +348,100 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + it('keeps authenticated and public grants out of the shared-with list', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.accessGrants = [ + { + agent: ['https://alice.example.com/profile/card.ttl#me'], + agentGroup: [], + agentClass: [], + mode: ['Read'] + }, + { + agent: [], + agentGroup: [], + agentClass: ['http://www.w3.org/ns/auth/acl#AuthenticatedAgent'], + mode: ['Read', 'Write'] + }, + { + agent: [], + agentGroup: [], + agentClass: ['http://xmlns.com/foaf/0.1/Agent'], + mode: ['Read'] + } + ] + + 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 searchOptions = [...element.shadowRoot.querySelectorAll('solid-ui-combobox.access-grants-search-input solid-ui-combobox-option')] + .map((option: Element) => option.textContent?.trim()) + expect(searchOptions).toEqual(['card.ttl']) + + document.body.removeChild(element) + }) + + it.each([ + '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 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' + const agentClass = 'https://example.com/team' + element.subjectUri = 'https://example.com/resource.ttl' + element.accessGrants = [{ + agent: [agent], + agentGroup: [group], + agentClass: [generalClass, agentClass], + mode: ['Read'] + }] + + document.body.appendChild(element) + await element.updateComplete + await element.refreshAccessGrantLabels() + await element.updateComplete + + const expectedLabel = element.getAuthorizationSubjectLabel({ + agent: [agent], + agentGroup: [group], + agentClass: [agentClass], + mode: ['Read'] + }) + const entries = element.getSharedAccessGrantEntries() + 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')] + .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) + + const roleSelect = element.shadowRoot.querySelector('.access-grants-list solid-ui-combobox') + roleSelect.dispatchEvent(new CustomEvent('change', { + bubbles: true, + composed: true, + detail: { option: { value: 'Editor', label: 'Editor' } } + })) + await element.updateComplete + await element.onSaveClick() + + expect(planGrant.mock.calls.map(([, subject]) => subject)).toEqual([ + { type: 'agent', iri: agent }, + { type: 'agentGroup', iri: group }, + { type: 'agentClass', iri: agentClass } + ]) + 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' @@ -344,6 +473,26 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + it('aborts saving when principal lookup fails', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + element.principalInputValue = 'not-a-principal' + element.publicAccessRoleValue = 'Viewer' + + document.body.appendChild(element) + await element.updateComplete + + await element.onSaveClick() + + expect(planGrant).not.toHaveBeenCalled() + expect(planRevoke).not.toHaveBeenCalled() + expect(applyPlan).not.toHaveBeenCalled() + expect(element.principalInputValue).toBe('not-a-principal') + expect(element.failed).toBe(true) + + document.body.removeChild(element) + }) + it('applies a shared row change to every subject represented by the authorization', async () => { const element = document.createElement('solid-ui-access-control-modal') as any element.subjectUri = 'https://example.com/resource.ttl'