From c9f88c0b7a5730fd3c082eb6108410976c1b9e34 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Fri, 16 Oct 2026 22:29:24 +1100 Subject: [PATCH 01/10] move file explorer provider, header and menu from solid-panes --- .../FileExplorerHeader.styles.css | 13 ++ .../FileExplorerHeader.ts | 99 +++++++++++ .../FileExplorerHeaderControls.styles.css | 69 ++++++++ .../FileExplorerHeaderControls.ts | 130 +++++++++++++++ .../FileExplorerHeaderSummary.styles.css | 148 +++++++++++++++++ .../FileExplorerHeaderSummary.ts | 155 ++++++++++++++++++ src/components/file-explorer-header/index.ts | 4 + src/components/file-explorer-header/types.ts | 10 ++ .../FileExplorerProvider.ts | 62 +++++++ .../file-explorer-provider/index.ts | 4 + src/components/index.ts | 1 + .../DeleteResourceDialog.ts | 36 ++++ .../ResourceActionsMenu.styles.css | 12 ++ .../ResourceActionsMenu.ts | 96 +++++++++++ src/index.ts | 1 + src/lib/context/noopContext.ts | 13 ++ src/lib/file-explorer/context.ts | 32 ++-- src/lib/file-explorer/events.ts | 11 ++ src/types/custom-elements.d.ts | 4 + src/utils/podUtils.ts | 90 ++++++++++ src/utils/webIdUtils.ts | 122 ++++++++++++++ test/unit/index.test.ts | 3 + 22 files changed, 1097 insertions(+), 18 deletions(-) create mode 100644 src/components/file-explorer-header/FileExplorerHeader.styles.css create mode 100644 src/components/file-explorer-header/FileExplorerHeader.ts create mode 100644 src/components/file-explorer-header/FileExplorerHeaderControls.styles.css create mode 100644 src/components/file-explorer-header/FileExplorerHeaderControls.ts create mode 100644 src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css create mode 100644 src/components/file-explorer-header/FileExplorerHeaderSummary.ts create mode 100644 src/components/file-explorer-header/index.ts create mode 100644 src/components/file-explorer-header/types.ts create mode 100644 src/components/file-explorer-provider/FileExplorerProvider.ts create mode 100644 src/components/file-explorer-provider/index.ts create mode 100644 src/components/resource-actions-menu/DeleteResourceDialog.ts create mode 100644 src/components/resource-actions-menu/ResourceActionsMenu.styles.css create mode 100644 src/components/resource-actions-menu/ResourceActionsMenu.ts create mode 100644 src/lib/context/noopContext.ts create mode 100644 src/lib/file-explorer/events.ts create mode 100644 src/utils/podUtils.ts create mode 100644 src/utils/webIdUtils.ts diff --git a/src/components/file-explorer-header/FileExplorerHeader.styles.css b/src/components/file-explorer-header/FileExplorerHeader.styles.css new file mode 100644 index 000000000..866bd8cbc --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeader.styles.css @@ -0,0 +1,13 @@ +:host { + header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + width: 100%; + text-align: right; + padding: 10px; + border-bottom: 1px solid var(--solid-ui-color-gray-200, #cbd5e1); + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeader.ts b/src/components/file-explorer-header/FileExplorerHeader.ts new file mode 100644 index 000000000..a926dd9b8 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeader.ts @@ -0,0 +1,99 @@ +import { sym } from 'rdflib' +import { solidLogicSingleton } from 'solid-logic' +import { WebComponent } from '@/lib/components' +import { DEFAULT_FILE_EXPLORER_CONTEXT, fileExplorerContext } from '@/lib/file-explorer/context' +import { FILE_EXPLORER_BACK_EVENT } from '@/lib/file-explorer/events' +import { customElement, property, state } from 'lit/decorators.js' +import { consume } from '@lit/context' +import { html } from 'lit' +import '~icons/lucide/share-2' +import '~icons/lucide/pencil' +import '~icons/lucide/ellipsis-vertical' +import styles from './FileExplorerHeader.styles.css' +import './FileExplorerHeaderSummary' +import './FileExplorerHeaderControls' +import { PaneIcon } from './types' +import { type FileExplorerResourceMetadata } from './types' + +@customElement('solid-ui-file-explorer-header') +export default class FileExplorerHeader extends WebComponent { + static styles = styles + + private _loadedMetadataForUri: string | undefined + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor fileExplorerContext = DEFAULT_FILE_EXPLORER_CONTEXT + + @property({ attribute: false }) + accessor menuItems: Array<{ label: string, action: (event: Event) => void }> = [] + + @property({ attribute: false }) + accessor paneIcon: PaneIcon = undefined as unknown as PaneIcon + + @state() + accessor responseMetadata: Pick = { + modified: undefined, + isPublic: false, + canEdit: false, + aclUri: undefined + } + + private handleBack = () => { + this.dispatchEvent(new CustomEvent(FILE_EXPLORER_BACK_EVENT, { + bubbles: true, + composed: true, + detail: { + subjectUri: this.fileExplorerContext.subjectUri + } + })) + } + + private getDefaultResponseMetadata (): Pick { + return { + modified: undefined, + isPublic: false, + canEdit: false, + aclUri: undefined + } + } + + protected updated () { + if (this.fileExplorerContext?.store && this.fileExplorerContext.subjectUri && this._loadedMetadataForUri !== this.fileExplorerContext.subjectUri) { + this._loadedMetadataForUri = this.fileExplorerContext.subjectUri + this.loadResponseMetadata() + } + } + + private async loadResponseMetadata () { + if (!this.fileExplorerContext?.store || !this.fileExplorerContext.subjectUri) return + + try { + const metadata = await solidLogicSingleton.resource.fetchMetadata(sym(this.fileExplorerContext.subjectUri)) + this.responseMetadata = { + modified: metadata.modified, + isPublic: metadata.access.isPublic, + canEdit: metadata.access.canEdit, + aclUri: metadata.aclUri + } + } catch (error) { + this.responseMetadata = this.getDefaultResponseMetadata() + console.warn('Failed to load response metadata', error) + } + } + + render () { + return html` +
+ + +
+ ` + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css b/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css new file mode 100644 index 000000000..dc411a3ca --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css @@ -0,0 +1,69 @@ +:host { + flex: 0 1 auto; + min-width: 0; + + div { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 6px; + flex: 0 0 auto; + text-align: right; + } + + /* generative ai */ + div > * { + flex: 0 1 auto; + } + + .file-explorer-header-action-button { + width: var(--solid-ui-clickable-area, 42px); + height: var(--solid-ui-clickable-area, 42px); + padding: 0; + } + + /* TODO: look into what colors to use this is just to show */ + .dirtyIndicator { + display: inline-flex; + align-items: center; + border: 1px solid #F59E0B; + color: #92400E; + background: #FFFBEB; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + line-height: 1; + padding: 4px 8px; + white-space: nowrap; + } + + icon-lucide-user-round-cog, + icon-lucide-pencil { + width: 16px; + height: 16px; + } + + @media (max-width: 900px) { + div { + padding-right: 3px; + } + } + + @media (max-width: 600px) { + div { + justify-content: flex-start; + gap: 5px; + } + + .file-explorer-header-action-button { + display: none; + } + + icon-lucide-ellipsis-vertical { + width: 15.36px; + height: 15.36px; + flex-shrink: 0; + aspect-ratio: 1 / 1; + } + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeaderControls.ts b/src/components/file-explorer-header/FileExplorerHeaderControls.ts new file mode 100644 index 000000000..2239824f0 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderControls.ts @@ -0,0 +1,130 @@ +import { WebComponent } from '@/lib/components' +import { customElement, property, state } from 'lit/decorators.js' +import { consume } from '@lit/context' +import { html, nothing } from 'lit' +import '@/components/button' +import '~icons/lucide/user-round-cog' +import '~icons/lucide/pencil' +import styles from './FileExplorerHeaderControls.styles.css' +import '@/components/resource-actions-menu/ResourceActionsMenu' +import { DEFAULT_FILE_EXPLORER_CONTEXT, fileExplorerContext } from '@/lib/file-explorer/context' +import { FILE_EXPLORER_MANAGE_ACCESS_EVENT } from '@/lib/file-explorer/events' +import { isContainerSubject } from '@/utils/podUtils' + +@customElement('solid-ui-file-explorer-header-controls') +export default class FileExplorerHeaderControls extends WebComponent { + static styles = styles + + private mobileMediaQuery: MediaQueryList | undefined + private readonly mobileQuery = '(max-width: 600px)' + private readonly handleMobileMediaChange = (event: MediaQueryListEvent) => { + this.isMobile = event.matches + } + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor fileExplorerContext = DEFAULT_FILE_EXPLORER_CONTEXT + + @property({ attribute: false }) + accessor menuItems: Array<{ label: string, action: (event: Event) => void, icon?: HTMLElement }> = [] + + @property({ type: Boolean }) + accessor canEdit: boolean = false + + @state() + accessor isMobile = typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(max-width: 600px)').matches + : false + + connectedCallback () { + super.connectedCallback() + + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return + } + + this.mobileMediaQuery = window.matchMedia(this.mobileQuery) + this.isMobile = this.mobileMediaQuery.matches + this.mobileMediaQuery.addEventListener('change', this.handleMobileMediaChange) + } + + disconnectedCallback () { + this.mobileMediaQuery?.removeEventListener('change', this.handleMobileMediaChange) + this.mobileMediaQuery = undefined + super.disconnectedCallback() + } + + // TODO: Add broken then use this function to set tooltip and disable edit button + /* private setEditable() { + const sourcePaneState = this.sourceContext?.sourcePaneState + const { canEdit } = this.sourceContext?.headerMetadata ?? { canEdit: false } + const subject = this.sourceContext?.subject + if (!sourcePaneState || !canEdit || sourcePaneState.broken || (subject && subject.endsWith('/'))) return + + this.sourceContext?.setEditing?.() + } */ + + private getEditTooltip () { + if (!this.canEdit) return 'No Access' + return 'Edit' + } + + private handleManageAccess = () => { + const subjectUri = this.fileExplorerContext.subjectUri + if (!subjectUri) return + + this.dispatchEvent(new CustomEvent(FILE_EXPLORER_MANAGE_ACCESS_EVENT, { + bubbles: true, + composed: true, + detail: { subjectUri } + })) + } + + private renderDirtyIndicator () { + if (!this.fileExplorerContext.isDirty) return nothing + + return html`Unsaved` + } + + render () { + const isContainerResource = isContainerSubject(this.fileExplorerContext.store, this.fileExplorerContext.subjectUri) + const supportsEditing = this.fileExplorerContext.canEdit + + return html` +
+ ${this.renderDirtyIndicator()} + ${!isContainerResource && !this.isMobile + ? html` + + + Manage access + + ${supportsEditing + ? html` + + + Edit + + ` + : nothing} + ` + : nothing} + +
+ ` + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css b/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css new file mode 100644 index 000000000..db9952b73 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css @@ -0,0 +1,148 @@ + :host { + .file-explorer-header-summary { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 6px; + flex-wrap: wrap; + flex: 1 1 auto; + min-width: 0; + text-align: left; + } + + h1 { + color: var(--solid-ui-color-gray-700, #364153); + font-size: var(--solid-ui-font-size-xl, 1.25rem); /* while in expand; will go to 2xl when new design is complete */ + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + } + + .resource-info { + display: flex; + flex-direction: column; + min-width: 0; + } + + .container-info { + display: flex; + flex-direction: row; + gap: 10px; + min-width: 0; + } + + .pane-icon { + display: flex; + width: 40px; + height: 40px; + padding: 7.5px 7px 6.5px 7px; + justify-content: center; + align-items: center; + aspect-ratio: 1/1; + /* border-radius: 100px; */ + /* background: var(--slate-600, #45556C); */ + } + + .pane-icon img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + + p { + display: flex; + align-items: center; + gap: 5px; + color: var(--solid-ui-gray-500, #6A7282); + font-size: var(--solid-ui-font-size-xs, .75rem); + font-weight: 500; + } + + .public { + display: flex; + padding: 2px 5px; + justify-content: center; + align-items: center; + gap: 3px; + color: var(--blue-400, #51A2FF); + border-radius: 5px; + background: var(--blue-50, #EFF6FF); + } + + icon-lucide-globe { + stroke-width: 1.021px; + stroke: var(--blue-400, #51A2FF); + } + + .private { + display: flex; + padding: 2px 5px; + justify-content: center; + align-items: center; + gap: 3px; + color: var(--yellow-600, #D08700); + border-radius: 5px; + background: var(--yellow-100, #FEF9C2); + } + + icon-lucide-lock-keyhole { + stroke-width: 1.021px; + stroke: var(--yellow-600, #D08700); + } + + icon-lucide-globe, + icon-lucide-lock-keyhole { + width: 0.875rem; + height: 0.875rem; + } + + .resource-date { + font-size: inherit; + } + + icon-lucide-arrow-left { + width: 1.125rem; + height: 1.125rem; + } + + @media (max-width: 600px) { + .file-explorer-header-summary { + gap: 7px; + } + + h1 { + font-size: 14px; + } + + .pane-icon { + width: 18.2px; + height: 18.2px; + flex-shrink: 0; + aspect-ratio: 1 / 1; + padding: 0; + } + + p { + font-size: 14px; + } + + .resource-date { + font-size: 10px; + } + + .public, + .private { + font-size: 9px; + } + + icon-lucide-arrow-left { + width: 15px; + height: 18px; + flex-shrink: 0; + aspect-ratio: 5 / 6; + } + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.ts b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts new file mode 100644 index 000000000..92ad3470b --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts @@ -0,0 +1,155 @@ +import { sym } from 'rdflib' +import { WebComponent } from '@/lib/components' +import * as utils from '@/utils' +import * as widgets from '@/widgets/index' +import { DEFAULT_FILE_EXPLORER_CONTEXT, fileExplorerContext } from '@/lib/file-explorer/context' +import '@/components/button' +import { customElement, property, query, state } from 'lit/decorators.js' +import { html } from 'lit' +import { consume } from '@lit/context' +import { PaneIcon } from './types' +import '~icons/lucide/globe' +import '~icons/lucide/lock-keyhole' +import '~icons/lucide/arrow-left' +import '~icons/lucide/folder' +import styles from './FileExplorerHeaderSummary.styles.css' +import { getContainerItemCount, isContainerSubject } from '@/utils/podUtils' +import type { FileExplorerResourceMetadata } from './types' + +@customElement('solid-ui-file-explorer-header-summary') +export default class FileExplorerHeaderSummary extends WebComponent { + static styles = styles + + private _draggableSubjectUri: string | undefined + private _resolvedPaneIconFor: PaneIcon | undefined + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor fileExplorerContext = DEFAULT_FILE_EXPLORER_CONTEXT + + @property({ attribute: false }) + accessor paneIcon: PaneIcon | undefined + + @property({ attribute: false }) + accessor onBackClick: (() => void) | undefined + + @property({ attribute: false }) + accessor responseMetadata: Pick = { + modified: undefined, + isPublic: false + } + + @state() + accessor resolvedPaneIcon: string | undefined = undefined + + @query('h1') + private accessor titleHeading: HTMLHeadingElement | null = null + + private formatModifiedDate (modified: string | undefined) { + if (!modified) return '' + + const date = new Date(modified) + if (Number.isNaN(date.getTime())) return modified + + const parts = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true + }).formatToParts(date) + + const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)?.value ?? '' + const day = getPart('day') + const month = getPart('month') + const year = getPart('year') + const hour = getPart('hour') + const minute = getPart('minute') + const dayPeriod = getPart('dayPeriod').toUpperCase() + + return `${day} ${month}, ${year} at ${hour}:${minute} ${dayPeriod}` + } + + protected updated () { + if (this.paneIcon !== undefined && this._resolvedPaneIconFor !== this.paneIcon) { + this._resolvedPaneIconFor = this.paneIcon + this.resolvePaneIcon() + } + + if (!this.titleHeading || !this.fileExplorerContext?.subjectUri || this._draggableSubjectUri === this.fileExplorerContext.subjectUri) return + + widgets.makeDraggable(this.titleHeading, sym(this.fileExplorerContext.subjectUri)) // doing it this way for now, just to keep the same functionality + this._draggableSubjectUri = this.fileExplorerContext.subjectUri + } + + // Needed because humanReadable pane can return a promise for an icon. + private async resolvePaneIcon () { + try { + if (this.paneIcon == null) { + this.resolvedPaneIcon = undefined + return + } + + const icon = await this.paneIcon + this.resolvedPaneIcon = icon ?? undefined + } catch (error) { + this.resolvedPaneIcon = undefined + console.warn('file-explorer-header-summary: failed to resolve pane icon', error) + } + } + + private renderContainerResourceHeader (label: string, isPublic: boolean) { + const itemCount = getContainerItemCount(this.fileExplorerContext?.store, this.fileExplorerContext?.subjectUri) ?? 0 + return html` +
+

+ ${label} +

+

+ ${itemCount} items + ${isPublic + ? html`` + : html``} +

+
+ ` + } + + private renderResourceHeader (label: string, isPublic: boolean) { + const modified = this.formatModifiedDate(this.responseMetadata.modified) + + return html` +
+

+ ${label} +

+

${modified} ${isPublic ? html` Public` : html` Private`}

+
+ ` + } + + render () { + const subject = this.fileExplorerContext?.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + const label = subject ? utils.label(subject) : '' + const isPublic = this.responseMetadata.isPublic + const isContainerResource = isContainerSubject(this.fileExplorerContext?.store, this.fileExplorerContext?.subjectUri) + + return html` +
+ + + Back + + + ${this.resolvedPaneIcon ? html`` : ''} + + ${isContainerResource ? this.renderContainerResourceHeader(label, isPublic) : this.renderResourceHeader(label, isPublic)} +
+ + ` + } +} diff --git a/src/components/file-explorer-header/index.ts b/src/components/file-explorer-header/index.ts new file mode 100644 index 000000000..ad71f8812 --- /dev/null +++ b/src/components/file-explorer-header/index.ts @@ -0,0 +1,4 @@ +import FileExplorerHeader from './FileExplorerHeader' + +export { FileExplorerHeader } +export default FileExplorerHeader diff --git a/src/components/file-explorer-header/types.ts b/src/components/file-explorer-header/types.ts new file mode 100644 index 000000000..826d13a8b --- /dev/null +++ b/src/components/file-explorer-header/types.ts @@ -0,0 +1,10 @@ +export type PaneIcon = string | Promise | null | undefined + +export type FileExplorerResourceMetadata = { + contentType: string | undefined + canEdit: boolean + isPublic: boolean + aclUri: string | undefined + eTag: string | undefined + modified: string | undefined +} diff --git a/src/components/file-explorer-provider/FileExplorerProvider.ts b/src/components/file-explorer-provider/FileExplorerProvider.ts new file mode 100644 index 000000000..f81af6377 --- /dev/null +++ b/src/components/file-explorer-provider/FileExplorerProvider.ts @@ -0,0 +1,62 @@ +import { html } from 'lit-html' +import { provide } from '@lit/context' +import { customElement, property, state } from 'lit/decorators.js' +import { DataBrowserContext, PaneDefinition } from 'pane-registry' +import type { NamedNode } from 'rdflib' +import { WebComponent } from '@/lib/components' +import { FileExplorerContext, FileExplorerEditor, fileExplorerContext } from '@/lib/file-explorer/context' + +@customElement('solid-ui-file-explorer-provider') +export default class FileExplorerProvider extends WebComponent { + @property({ attribute: false }) + accessor context: DataBrowserContext | undefined + + @property({ attribute: false }) + accessor subjectUri: string | undefined + + @property({ attribute: false }) + accessor pane: PaneDefinition | undefined + + @property({ attribute: false }) + accessor relevantPanes: PaneDefinition[] = [] + + @property({ attribute: false }) + accessor soloPane: boolean | undefined + + @property({ attribute: false }) + accessor onBack: (() => void) | undefined + + @property({ attribute: false }) + accessor openPane: ((s: NamedNode, name: string) => void) | undefined + + @property({ attribute: false }) + accessor handleAccessClick: (() => void) | undefined + + @state() private accessor editor: FileExplorerEditor | undefined + @state() private accessor isDirty = false + + @provide({ context: fileExplorerContext }) + accessor value!: FileExplorerContext + + private registerEditor = (editor: FileExplorerEditor) => { + this.editor = editor + return () => { if (this.editor === editor) this.editor = undefined } + } + + protected willUpdate () { + this.value = { + store: this.context?.session.store, + subjectUri: this.subjectUri, + pane: this.pane, + isDirty: this.isDirty, + canEdit: !!this.editor, + registerEditor: this.registerEditor, + beginEditing: () => { this.editor?.beginEditing() }, + setDirty: (dirty) => { this.isDirty = dirty } + } + } + + render () { + return html`` + } +} diff --git a/src/components/file-explorer-provider/index.ts b/src/components/file-explorer-provider/index.ts new file mode 100644 index 000000000..10e75175e --- /dev/null +++ b/src/components/file-explorer-provider/index.ts @@ -0,0 +1,4 @@ +import FileExplorerProvider from './FileExplorerProvider' + +export { FileExplorerProvider } +export default FileExplorerProvider diff --git a/src/components/index.ts b/src/components/index.ts index 916e49f9f..e47751a43 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ // Make sure that is registered first, so that contexts // are available to nested components when they mount. import './provider' +import './file-explorer-provider' import './all' diff --git a/src/components/resource-actions-menu/DeleteResourceDialog.ts b/src/components/resource-actions-menu/DeleteResourceDialog.ts new file mode 100644 index 000000000..1b5c427b1 --- /dev/null +++ b/src/components/resource-actions-menu/DeleteResourceDialog.ts @@ -0,0 +1,36 @@ +import { customElement, DialogComponent } from '@/lib/components' +import { html } from 'lit' +import { property } from 'lit/decorators.js' + +import '@/components/button' +import '@/components/dialog' +import '@/components/dialog-content' +import '@/components/dialog-footer' + +@customElement('resource-delete-dialog') +export default class DeleteResourceDialog extends DialogComponent { + @property({ type: String }) + accessor resourceName = 'this resource' + + private cancel = () => this.close(false) + + private confirm = () => this.close(true) + + protected render () { + return html` + + +

Are you sure you want to permanently delete ${this.resourceName}?

+
+ + + Go back + + + Yes + + +
+ ` + } +} diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.styles.css b/src/components/resource-actions-menu/ResourceActionsMenu.styles.css new file mode 100644 index 000000000..9b9e7b4d1 --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.styles.css @@ -0,0 +1,12 @@ +:host { + .file-explorer-header-more-button { + width: var(--solid-ui-clickable-area, 42px); + height: var(--solid-ui-clickable-area, 42px); + padding: 0; + } + + .ellipsisIcon { + width: 16px; + height: 16px; + } +} diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.ts b/src/components/resource-actions-menu/ResourceActionsMenu.ts new file mode 100644 index 000000000..265e3b716 --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.ts @@ -0,0 +1,96 @@ +import { WebComponent } from '@/lib/components' +import { customElement, property } from 'lit/decorators.js' +import { html, nothing } from 'lit' +import '@/components/button' +import '@/components/menu' +import '@/components/menu-item' +import '~icons/lucide/ellipsis-vertical' +import '~icons/lucide/user-round-cog' +import '~icons/lucide/pencil' +import { LiveStore } from 'rdflib' +import styles from './ResourceActionsMenu.styles.css' +import { isContainerSubject } from '@/utils/podUtils' + +type ResourceActionMenuItem = { + label: string + icon?: unknown + action?: (event: Event) => void +} + +type ActionableResourceActionMenuItem = ResourceActionMenuItem & { + action: (event: Event) => void +} + +@customElement('solid-ui-resource-actions-menu') +export default class ResourceActionsMenu extends WebComponent { + static styles = styles + + @property({ attribute: false }) + accessor store: LiveStore | undefined + + @property({ type: String }) + accessor subjectUri: string | undefined + + @property({ attribute: false }) + accessor handleAccessClick: (() => void) | undefined = undefined + + @property({ attribute: false }) + accessor handleEditingClick: (() => void) | undefined = undefined + + @property({ type: Boolean }) + accessor paneSupportsEditing = false + + @property({ type: Boolean }) + accessor canEdit = false + + @property({ type: Boolean }) + accessor isMobile = false + + @property({ attribute: false }) + accessor menuItems: ResourceActionMenuItem[] = [] + + render () { + const isContainerResource = isContainerSubject(this.store, this.subjectUri) + const canEdit = !isContainerResource && this.isMobile && this.paneSupportsEditing && this.canEdit && !!this.handleEditingClick + const canManageAccess = !!this.handleAccessClick + const actionableMenuItems = this.menuItems.filter((item): item is ActionableResourceActionMenuItem => typeof item.action === 'function') + return html` + + + + More options + + ${actionableMenuItems.map(item => html` + item.action(event)}> + ${item.icon} + ${item.label} + + `)} + ${canEdit + ? html` + + + Edit + + ` + : nothing} + ${!isContainerResource && this.isMobile && canManageAccess + ? html` + + + Manage Access + + ` + : nothing} + ${isContainerResource && canManageAccess + ? html` + + + Manage Access + + ` + : nothing} + + ` + } +} diff --git a/src/index.ts b/src/index.ts index 10b6aac46..8712e39e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,4 +67,5 @@ export * from './lib/auth' export * from './lib/components' export * from './lib/dialogs' export * from './lib/file-explorer/context' +export * from './lib/file-explorer/events' export * from './lib/code-editor' diff --git a/src/lib/context/noopContext.ts b/src/lib/context/noopContext.ts new file mode 100644 index 000000000..19083a4ba --- /dev/null +++ b/src/lib/context/noopContext.ts @@ -0,0 +1,13 @@ +export function noopContext (name: string): T { + const fail = () => { throw new Error(`Context '${name}' is missing. Is the component inside its provider?`) } + + return new Proxy({}, { + get: fail, + set: fail, + has: fail, + ownKeys: fail, + getOwnPropertyDescriptor: fail, + defineProperty: fail, + deleteProperty: fail + }) as T +} diff --git a/src/lib/file-explorer/context.ts b/src/lib/file-explorer/context.ts index 9d291174f..1e349fe37 100644 --- a/src/lib/file-explorer/context.ts +++ b/src/lib/file-explorer/context.ts @@ -1,26 +1,22 @@ -import { createContext } from '@lit/context' -import { LiveStore } from 'rdflib' -import type { NamedNode } from 'rdflib' +import type { LiveStore } from 'rdflib' import type { PaneDefinition } from 'pane-registry' +import { createContext } from '@lit/context' +import { noopContext } from '@/lib/context/noopContext' -export interface FileExplorerEdit { - onEdit?: () => void - isDirty?: boolean - updateDirtyState?: (dirty: boolean) => void +export interface FileExplorerEditor { + beginEditing (): void } export interface FileExplorerContext { - store: LiveStore | undefined - subjectUri: string | undefined - pane?: PaneDefinition - soloPane?: boolean - - onBack?: () => void - openPane?: (subject: NamedNode, paneName: string) => void - handleAccessClick?: () => void - - paneSupportsEditing?: boolean - edit?: FileExplorerEdit + readonly store: LiveStore | undefined + readonly subjectUri: string | undefined + readonly pane: PaneDefinition | undefined + readonly isDirty: boolean + readonly canEdit: boolean // true while an editor is registered + registerEditor (editor: FileExplorerEditor): () => void // returns unregister + beginEditing (): void + setDirty (dirty: boolean): void } export const fileExplorerContext = createContext(Symbol('file-explorer')) +export const DEFAULT_FILE_EXPLORER_CONTEXT = noopContext('file-explorer') diff --git a/src/lib/file-explorer/events.ts b/src/lib/file-explorer/events.ts new file mode 100644 index 000000000..e16910ba8 --- /dev/null +++ b/src/lib/file-explorer/events.ts @@ -0,0 +1,11 @@ +export const FILE_EXPLORER_BACK_EVENT = 'solid-ui-file-explorer-back' + +export interface FileExplorerBackDetail { + subjectUri?: string +} + +export const FILE_EXPLORER_MANAGE_ACCESS_EVENT = 'solid-ui-file-explorer-manage-access' + +export interface FileExplorerManageAccessDetail { + subjectUri: string +} diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts index fde924599..46645f9ac 100644 --- a/src/types/custom-elements.d.ts +++ b/src/types/custom-elements.d.ts @@ -11,6 +11,8 @@ import type DialogHeader from '../components/dialog-header/DialogHeader' import type DialogProvider from '../components/dialog-provider/DialogProvider' import type DialogsRoot from '../components/dialogs-root/DialogsRoot' import type ErrorState from '../components/error-state/ErrorState' +import type FileExplorerHeader from '../components/file-explorer-header/FileExplorerHeader' +import type FileExplorerProvider from '../components/file-explorer-provider/FileExplorerProvider' import type Guard from '../components/guard/Guard' import type Input from '../components/input/Input' import type LoginButton from '../components/login-button/LoginButton' @@ -43,6 +45,8 @@ declare global { 'solid-ui-dialog-provider': DialogProvider 'solid-ui-dialogs-root': DialogsRoot 'solid-ui-error-state': ErrorState + 'solid-ui-file-explorer-header': FileExplorerHeader + 'solid-ui-file-explorer-provider': FileExplorerProvider 'solid-ui-guard': Guard 'solid-ui-input': Input 'solid-ui-login-button': LoginButton diff --git a/src/utils/podUtils.ts b/src/utils/podUtils.ts new file mode 100644 index 000000000..db25cd393 --- /dev/null +++ b/src/utils/podUtils.ts @@ -0,0 +1,90 @@ +import { solidLogicSingleton, store } from 'solid-logic' +import ns from '@/lib/ns' +import { LiveStore, NamedNode, parse } from 'rdflib' +import { isWebIdUri } from './webIdUtils' + +export async function getPodStorages (url: NamedNode): Promise { + if (isWebIdUri(url)) { + try { + await store.fetcher.load(url.doc()) + } catch (err) { + console.warn('Unable to load profile document for WebID', url.doc().uri, err) + } + const podStorages = store.each(url, ns.space('storage')) + const results = await Promise.all(podStorages.map(async pod => await isPodStorage(pod as NamedNode) ? pod as NamedNode : null)) + return results.filter(pod => pod !== null) as NamedNode[] + } + try { + const storage = await findPodStorageFromUrl(url) + return storage ? [storage] : [] + } catch (err) { + console.error('cannot load container', err) + return [] + } +} + +async function findPodStorageFromUrl (url: NamedNode): Promise { + const podStorage = new URL(url.value || url.uri) + let pathStorage = podStorage.pathname + + while (pathStorage.length) { + pathStorage = pathStorage.substring(0, pathStorage.lastIndexOf('/')) + const candidate = store.sym(`${podStorage.origin}${pathStorage}/`) + if (await isPodStorage(candidate)) return candidate + } + + // TODO should url.origin be added to pods list when there are no pim:Storage ??? + return null +} + +async function isPodStorage (pod: NamedNode): Promise { + await loadContainerRepresentation(pod) + return store.holds(pod, ns.rdf('type'), ns.space('Storage'), pod.doc()) +} + +export async function loadContainerRepresentation (subject) { + // force reload for index.html with RDFa + if (!store.any(subject, ns.ldp('contains'), undefined, subject.doc())) { + try { + const response = await store.fetcher.webOperation('GET', subject.uri, store.fetcher.initFetchOptions(subject.uri, { headers: { accept: 'text/turtle' } })) + const containerTurtle = response.responseText + if (subject.uri && containerTurtle) { + parse(containerTurtle, store, subject.uri, 'text/turtle') + } + } catch (err) { + console.warn('Unable to load container representation for', subject.uri, err) + } + } +} + +export function isContainerSubject (store: LiveStore | undefined, subjectUri: string | undefined): boolean { + if (!store || !subjectUri) return false + + const subject = store.sym(subjectUri) + const typeUris = store.findTypeURIs(subject) + return Boolean( + typeUris[ns.ldp('Container').uri] || + typeUris[ns.ldp('BasicContainer').uri] || + subject.uri.endsWith('/') + ) +} + +// The shared rdflib store can hold duplicate ldp:contains statements for the +// same child resource when the container and companion metadata are both loaded. +// Count unique visible children here so the header summary matches the list UI. +export function getContainerItemCount (store: LiveStore | undefined, subjectUri: string | undefined): number { + if (!store || !subjectUri) return 0 + + const subject = store.sym(subjectUri) + const seen = new Set() + + for (const item of store.each(subject, ns.ldp('contains'))) { + if (item.termType === 'NamedNode') { + const resource = item as NamedNode + if (!solidLogicSingleton.resource.isHiddenFile(resource)) { + seen.add(resource.uri) + } + } + } + return seen.size +} diff --git a/src/utils/webIdUtils.ts b/src/utils/webIdUtils.ts new file mode 100644 index 000000000..5eb9cac32 --- /dev/null +++ b/src/utils/webIdUtils.ts @@ -0,0 +1,122 @@ +/* +* Utility functions to help load the profile +* especially when I am not logged in +*/ + +import { IndexedFormula, NamedNode, sym } from 'rdflib' +import ns from '@/lib/ns' +import { authn, store } from 'solid-logic' + +const DEFAULT_PROFILE_PATH = 'profile/card#me' + +export function isOwnWebIdProfile (subject: NamedNode): boolean { + const viewer = authn.currentUser() + return Boolean(viewer && (viewer.sameTerm(subject) || viewer.doc().sameTerm(subject))) +} + +export async function loadProfileFromURI ( + uri: NamedNode +): Promise { + try { + const pod = uri.site().uri + // TODO: This is a hack - we cannot assume that the profile is at this document, but we will live with it for now + const webId = sym(`${pod}${DEFAULT_PROFILE_PATH}`) + try { + await store.fetcher.load(webId) + return webId + } catch (err) { + // ignore any failure and continue fallback lookup + } + + // we try a prefixed pod structure + try { + const uriUrl = new URL(uri.uri) + const pathSegments = uriUrl.pathname.split('/').filter(Boolean) + if (pathSegments.length > 0) { + const derivedPod = `${uriUrl.origin}/${pathSegments[0]}/` + const derivedWebId = sym(`${derivedPod}${DEFAULT_PROFILE_PATH}`) + await store.fetcher.load(derivedWebId) + return derivedWebId + } + } catch (err) { + // ignore any failure and continue fallback lookup + } + + try { + await store.fetcher.load(uri) + } catch (err) { + return uri + } + + const primaryTopic = store.any(uri, ns.foaf('primaryTopic'), null, uri.doc()) + if (primaryTopic && primaryTopic.termType === 'NamedNode') { + try { + await store.fetcher.load(primaryTopic as NamedNode) + return primaryTopic as NamedNode + } catch (err) { + return uri + } + } + + return uri + } catch (err) { + return uri + } +} + +export function isWebIdUri (uri: NamedNode): boolean { + try { + const parsedURL = new URL(uri.uri) + return parsedURL.hash === '#me' + } catch (err) { + return false + } +} + +export async function getNameOfPodOwner ( + pod: NamedNode +): Promise { + // TODO: This is a hack - we cannot assume that the profile is at this document, but we will live with it for now + const webId = sym(`${pod.uri}${DEFAULT_PROFILE_PATH}`) + try { + await store.fetcher.load(webId) + return getName(store, webId) + } catch (err) { + if (!isFetchErrorStatus(err, 403)) { + console.error('getNameOfPodOwner failed on default profile:', err) + } + } + + // we try a prefixed pod structure + try { + const uriUrl = new URL(pod.uri) + const pathSegments = uriUrl.pathname.split('/').filter(Boolean) + if (pathSegments.length > 0) { + const derivedPod = `${uriUrl.origin}/${pathSegments[0]}/` + const derivedWebId = sym(`${derivedPod}${DEFAULT_PROFILE_PATH}`) + await store.fetcher.load(derivedWebId) + return getName(store, derivedWebId) + } + } catch (err) { + if (!isFetchErrorStatus(err, 403)) { + console.error('getNameOfPodOwner failed on derived profile:', err) + } + } + + return '' +} + +export function getName (store: IndexedFormula, ownersProfile: NamedNode): string { + return ( + store.anyValue(ownersProfile, ns.vcard('fn'), null, ownersProfile.doc()) || + store.anyValue(ownersProfile, ns.foaf('name'), null, ownersProfile.doc()) || + new URL(ownersProfile.uri).host.split('.')[0] + ) +} + +function isFetchErrorStatus (err: unknown, status: number): boolean { + if (!err || typeof err !== 'object') return false + const statusCode = (err as any)?.response?.status ?? (err as any)?.status + const parsedStatus = Number(statusCode) + return Number.isFinite(parsedStatus) && parsedStatus === status +} diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 89a6d9818..46c83196c 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -12,10 +12,13 @@ describe('Index', () => { 'CodeEditor', 'DEFAULT_AUTH_CONTEXT', 'DEFAULT_DIALOG_CONTEXT', + 'DEFAULT_FILE_EXPLORER_CONTEXT', 'DEFAULT_SIGNUP_URL', 'Dialog', 'DialogComponent', 'DialogTrait', + 'FILE_EXPLORER_BACK_EVENT', + 'FILE_EXPLORER_MANAGE_ACCESS_EVENT', 'FormControlComponent', 'FormControlTrait', 'NoopAuth', From 6272459932903534f6fe492bd1d570d401e30d49 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 17 Oct 2026 05:30:36 +1100 Subject: [PATCH 02/10] Fix stale header updates Prompt: Fix stale async header/menu updates using a generation counter. Co-authored-by: GPT-5.6 Luna --- .../FileExplorerHeader.ts | 34 +++++++++++-------- .../FileExplorerHeaderSummary.ts | 31 +++++++++-------- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/components/file-explorer-header/FileExplorerHeader.ts b/src/components/file-explorer-header/FileExplorerHeader.ts index a926dd9b8..8c9c7d8ff 100644 --- a/src/components/file-explorer-header/FileExplorerHeader.ts +++ b/src/components/file-explorer-header/FileExplorerHeader.ts @@ -20,6 +20,7 @@ export default class FileExplorerHeader extends WebComponent { static styles = styles private _loadedMetadataForUri: string | undefined + private _metadataGeneration = 0 @consume({ context: fileExplorerContext, subscribe: true }) accessor fileExplorerContext = DEFAULT_FILE_EXPLORER_CONTEXT @@ -58,26 +59,31 @@ export default class FileExplorerHeader extends WebComponent { } protected updated () { - if (this.fileExplorerContext?.store && this.fileExplorerContext.subjectUri && this._loadedMetadataForUri !== this.fileExplorerContext.subjectUri) { - this._loadedMetadataForUri = this.fileExplorerContext.subjectUri - this.loadResponseMetadata() + const subjectUri = this.fileExplorerContext?.subjectUri + if (this.fileExplorerContext?.store && subjectUri && this._loadedMetadataForUri !== subjectUri) { + this._loadedMetadataForUri = subjectUri + const generation = ++this._metadataGeneration + this.responseMetadata = this.getDefaultResponseMetadata() + this.loadResponseMetadata(subjectUri, generation) } } - private async loadResponseMetadata () { - if (!this.fileExplorerContext?.store || !this.fileExplorerContext.subjectUri) return - + private async loadResponseMetadata (subjectUri: string, generation: number) { try { - const metadata = await solidLogicSingleton.resource.fetchMetadata(sym(this.fileExplorerContext.subjectUri)) - this.responseMetadata = { - modified: metadata.modified, - isPublic: metadata.access.isPublic, - canEdit: metadata.access.canEdit, - aclUri: metadata.aclUri + const metadata = await solidLogicSingleton.resource.fetchMetadata(sym(subjectUri)) + if (generation === this._metadataGeneration && this.fileExplorerContext.subjectUri === subjectUri) { + this.responseMetadata = { + modified: metadata.modified, + isPublic: metadata.access.isPublic, + canEdit: metadata.access.canEdit, + aclUri: metadata.aclUri + } } } catch (error) { - this.responseMetadata = this.getDefaultResponseMetadata() - console.warn('Failed to load response metadata', error) + if (generation === this._metadataGeneration && this.fileExplorerContext.subjectUri === subjectUri) { + this.responseMetadata = this.getDefaultResponseMetadata() + console.warn('Failed to load response metadata', error) + } } } diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.ts b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts index 92ad3470b..4a44d8b28 100644 --- a/src/components/file-explorer-header/FileExplorerHeaderSummary.ts +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts @@ -5,6 +5,7 @@ import * as widgets from '@/widgets/index' import { DEFAULT_FILE_EXPLORER_CONTEXT, fileExplorerContext } from '@/lib/file-explorer/context' import '@/components/button' import { customElement, property, query, state } from 'lit/decorators.js' +import type { PropertyValues } from 'lit' import { html } from 'lit' import { consume } from '@lit/context' import { PaneIcon } from './types' @@ -21,7 +22,7 @@ export default class FileExplorerHeaderSummary extends WebComponent { static styles = styles private _draggableSubjectUri: string | undefined - private _resolvedPaneIconFor: PaneIcon | undefined + private _paneIconGeneration = 0 @consume({ context: fileExplorerContext, subscribe: true }) accessor fileExplorerContext = DEFAULT_FILE_EXPLORER_CONTEXT @@ -70,10 +71,11 @@ export default class FileExplorerHeaderSummary extends WebComponent { return `${day} ${month}, ${year} at ${hour}:${minute} ${dayPeriod}` } - protected updated () { - if (this.paneIcon !== undefined && this._resolvedPaneIconFor !== this.paneIcon) { - this._resolvedPaneIconFor = this.paneIcon - this.resolvePaneIcon() + protected updated (changedProperties: PropertyValues) { + if (changedProperties.has('paneIcon')) { + const generation = ++this._paneIconGeneration + this.resolvedPaneIcon = undefined + this.resolvePaneIcon(this.paneIcon, generation) } if (!this.titleHeading || !this.fileExplorerContext?.subjectUri || this._draggableSubjectUri === this.fileExplorerContext.subjectUri) return @@ -83,18 +85,19 @@ export default class FileExplorerHeaderSummary extends WebComponent { } // Needed because humanReadable pane can return a promise for an icon. - private async resolvePaneIcon () { + private async resolvePaneIcon (paneIcon: PaneIcon | undefined, generation: number) { try { - if (this.paneIcon == null) { - this.resolvedPaneIcon = undefined - return - } + if (paneIcon == null) return - const icon = await this.paneIcon - this.resolvedPaneIcon = icon ?? undefined + const icon = await paneIcon + if (generation === this._paneIconGeneration) { + this.resolvedPaneIcon = icon ?? undefined + } } catch (error) { - this.resolvedPaneIcon = undefined - console.warn('file-explorer-header-summary: failed to resolve pane icon', error) + if (generation === this._paneIconGeneration) { + this.resolvedPaneIcon = undefined + console.warn('file-explorer-header-summary: failed to resolve pane icon', error) + } } } From 690855af9cb603dcf003ec60a4b40af4b950db91 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 17 Oct 2026 05:48:00 +1100 Subject: [PATCH 03/10] add onback event plus cleanup --- .../file-explorer-provider/FileExplorerProvider.ts | 10 ---------- src/lib/file-explorer/events.ts | 7 +++++++ test/unit/index.test.ts | 1 + 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/file-explorer-provider/FileExplorerProvider.ts b/src/components/file-explorer-provider/FileExplorerProvider.ts index f81af6377..93e9d025b 100644 --- a/src/components/file-explorer-provider/FileExplorerProvider.ts +++ b/src/components/file-explorer-provider/FileExplorerProvider.ts @@ -2,7 +2,6 @@ import { html } from 'lit-html' import { provide } from '@lit/context' import { customElement, property, state } from 'lit/decorators.js' import { DataBrowserContext, PaneDefinition } from 'pane-registry' -import type { NamedNode } from 'rdflib' import { WebComponent } from '@/lib/components' import { FileExplorerContext, FileExplorerEditor, fileExplorerContext } from '@/lib/file-explorer/context' @@ -23,15 +22,6 @@ export default class FileExplorerProvider extends WebComponent { @property({ attribute: false }) accessor soloPane: boolean | undefined - @property({ attribute: false }) - accessor onBack: (() => void) | undefined - - @property({ attribute: false }) - accessor openPane: ((s: NamedNode, name: string) => void) | undefined - - @property({ attribute: false }) - accessor handleAccessClick: (() => void) | undefined - @state() private accessor editor: FileExplorerEditor | undefined @state() private accessor isDirty = false diff --git a/src/lib/file-explorer/events.ts b/src/lib/file-explorer/events.ts index e16910ba8..66b264897 100644 --- a/src/lib/file-explorer/events.ts +++ b/src/lib/file-explorer/events.ts @@ -9,3 +9,10 @@ export const FILE_EXPLORER_MANAGE_ACCESS_EVENT = 'solid-ui-file-explorer-manage- export interface FileExplorerManageAccessDetail { subjectUri: string } + +export const FILE_EXPLORER_OPEN_PANE_EVENT = 'solid-ui-file-explorer-open-pane' + +export interface FileExplorerOpenPaneDetail { + subjectUri: string + paneName: string +} diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 46c83196c..fd1e69e82 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -19,6 +19,7 @@ describe('Index', () => { 'DialogTrait', 'FILE_EXPLORER_BACK_EVENT', 'FILE_EXPLORER_MANAGE_ACCESS_EVENT', + 'FILE_EXPLORER_OPEN_PANE_EVENT', 'FormControlComponent', 'FormControlTrait', 'NoopAuth', From 4c0650a028d0bc24f716ab6b8560cb3cc3e21cd1 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 17 Oct 2026 09:03:35 +1100 Subject: [PATCH 04/10] fix a bug --- src/components/file-explorer-header/FileExplorerHeader.ts | 2 +- .../file-explorer-header/FileExplorerHeaderSummary.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/file-explorer-header/FileExplorerHeader.ts b/src/components/file-explorer-header/FileExplorerHeader.ts index 8c9c7d8ff..e3e8f70be 100644 --- a/src/components/file-explorer-header/FileExplorerHeader.ts +++ b/src/components/file-explorer-header/FileExplorerHeader.ts @@ -58,7 +58,7 @@ export default class FileExplorerHeader extends WebComponent { } } - protected updated () { + protected willUpdate () { const subjectUri = this.fileExplorerContext?.subjectUri if (this.fileExplorerContext?.store && subjectUri && this._loadedMetadataForUri !== subjectUri) { this._loadedMetadataForUri = subjectUri diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.ts b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts index 4a44d8b28..19e9e375a 100644 --- a/src/components/file-explorer-header/FileExplorerHeaderSummary.ts +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts @@ -71,13 +71,15 @@ export default class FileExplorerHeaderSummary extends WebComponent { return `${day} ${month}, ${year} at ${hour}:${minute} ${dayPeriod}` } - protected updated (changedProperties: PropertyValues) { + protected willUpdate (changedProperties: PropertyValues) { if (changedProperties.has('paneIcon')) { const generation = ++this._paneIconGeneration this.resolvedPaneIcon = undefined this.resolvePaneIcon(this.paneIcon, generation) } + } + protected updated () { if (!this.titleHeading || !this.fileExplorerContext?.subjectUri || this._draggableSubjectUri === this.fileExplorerContext.subjectUri) return widgets.makeDraggable(this.titleHeading, sym(this.fileExplorerContext.subjectUri)) // doing it this way for now, just to keep the same functionality From 3cb523bd98425aa041f308ffce8236c089d19351 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 17 Oct 2026 09:03:53 +1100 Subject: [PATCH 05/10] add neutral color from design --- src/styles/theme.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles/theme.css b/src/styles/theme.css index 7e52057cd..99ed3dae7 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -28,6 +28,7 @@ --solid-ui-color-gray-900: #101828; --solid-ui-color-slate-50: #f8fafc; + --solid-ui-color-neutral-50: #fafafa; --solid-ui-color-gray-50: #f9fafb; --solid-ui-color-slate-200: #e2e8f0; --solid-ui-color-slate-400: #90a1b9; From 9ca17f837b337cdce7a766d4eda06df123fb0b6f Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 17 Oct 2026 09:25:44 +1100 Subject: [PATCH 06/10] Move the relevant pane function to solid-ui so it can be used by both solid-panes and folder-pane Prompt: Move the getRelevantPane function to solid-ui from solid-panes Co-authored-by: GPT-5.6 Luna --- src/index.ts | 1 + src/lib/file-explorer/relevant-panes.ts | 37 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/lib/file-explorer/relevant-panes.ts diff --git a/src/index.ts b/src/index.ts index 8712e39e3..86c378587 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,4 +68,5 @@ export * from './lib/components' export * from './lib/dialogs' export * from './lib/file-explorer/context' export * from './lib/file-explorer/events' +export * from './lib/file-explorer/relevant-panes' export * from './lib/code-editor' diff --git a/src/lib/file-explorer/relevant-panes.ts b/src/lib/file-explorer/relevant-panes.ts new file mode 100644 index 000000000..5289d0e00 --- /dev/null +++ b/src/lib/file-explorer/relevant-panes.ts @@ -0,0 +1,37 @@ +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' +import type { NamedNode } from 'rdflib' +import { filterAvailablePanes } from '@/login/login' + +export type RelevantPaneOptions = { + excludedPaneNames?: readonly string[] + fallbackPaneName?: string +} + +/** + * Returns panes that can represent a subject and are available to the current user. + * + * Availability filtering is authoritative: a pane removed by the audience check is + * not reintroduced as a fallback. + */ +export async function getRelevantPanes ( + subject: NamedNode, + context: DataBrowserContext, + options: RelevantPaneOptions = {} +): Promise { + const excludedPaneNames = new Set(options.excludedPaneNames || []) + const paneRegistry = context.session.paneRegistry + const relevantPanes = paneRegistry.list.filter( + pane => !pane.global && + !excludedPaneNames.has(pane.name) && + pane.label(subject, context) + ) + + if (relevantPanes.length === 0) { + const fallbackPane = options.fallbackPaneName + ? paneRegistry.byName(options.fallbackPaneName) + : undefined + return fallbackPane ? [fallbackPane] : [] + } + + return filterAvailablePanes(relevantPanes) +} From f6da4996ec0d3e8fb48508f1aa5eaafb821d9da2 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sat, 17 Oct 2026 14:53:44 +1100 Subject: [PATCH 07/10] Modify getRelevant panes to work as legacy add a compose function that can be used in solid-panes and the folder-pane Prompt: Create a compose explorer function that can be used in solid-panes and folder-pane so that we can centralize the file explorer composition. Co-authored-by: GPT-5.6 Luna --- src/components/resource-actions-menu/index.ts | 1 + src/index.ts | 4 +- src/lib/file-explorer/compose-explorer.ts | 50 +++++++ src/lib/file-explorer/events.ts | 7 + src/lib/file-explorer/index.ts | 4 + src/lib/file-explorer/relevant-panes.ts | 16 ++- src/types/custom-elements.d.ts | 2 + test/components/compose-explorer.test.ts | 106 ++++++++++++++ .../unit/file-explorer/relevant-panes.test.ts | 130 ++++++++++++++++++ 9 files changed, 313 insertions(+), 7 deletions(-) create mode 100644 src/components/resource-actions-menu/index.ts create mode 100644 src/lib/file-explorer/compose-explorer.ts create mode 100644 src/lib/file-explorer/index.ts create mode 100644 test/components/compose-explorer.test.ts create mode 100644 test/unit/file-explorer/relevant-panes.test.ts diff --git a/src/components/resource-actions-menu/index.ts b/src/components/resource-actions-menu/index.ts new file mode 100644 index 000000000..a00c20ed1 --- /dev/null +++ b/src/components/resource-actions-menu/index.ts @@ -0,0 +1 @@ +export { default } from './ResourceActionsMenu' diff --git a/src/index.ts b/src/index.ts index 86c378587..fab8d4808 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,5 @@ export type { CreateContext, NewAppInstanceOptions } from './create/types' export * from './lib/auth' export * from './lib/components' export * from './lib/dialogs' -export * from './lib/file-explorer/context' -export * from './lib/file-explorer/events' -export * from './lib/file-explorer/relevant-panes' +export * from './lib/file-explorer' export * from './lib/code-editor' diff --git a/src/lib/file-explorer/compose-explorer.ts b/src/lib/file-explorer/compose-explorer.ts new file mode 100644 index 000000000..29efbb984 --- /dev/null +++ b/src/lib/file-explorer/compose-explorer.ts @@ -0,0 +1,50 @@ +import type FileExplorerProvider from '@/components/file-explorer-provider/FileExplorerProvider' +import type FileExplorerHeader from '@/components/file-explorer-header/FileExplorerHeader' +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' + +export type ExplorerComposition = { + context: DataBrowserContext + subjectUri: string + pane: PaneDefinition | undefined + relevantPanes: PaneDefinition[] + header?: Pick + paneElement?: HTMLElement +} + +/** + * Composes one explorer without touching nested explorers or owning navigation. + * Callers register the provider/header components and render the pane content. + */ +export function composeExplorer ( + provider: FileExplorerProvider, + composition: ExplorerComposition +): void { + provider.context = composition.context + provider.subjectUri = composition.subjectUri + provider.pane = composition.pane + provider.relevantPanes = composition.relevantPanes + + const currentHeader = provider.querySelector( + ':scope > solid-ui-file-explorer-header' + ) + if (composition.header) { + const header = currentHeader || + provider.ownerDocument.createElement('solid-ui-file-explorer-header') + header.paneIcon = composition.header.paneIcon + header.menuItems = composition.header.menuItems + if (provider.firstElementChild !== header) provider.prepend(header) + } else { + currentHeader?.remove() + } + + if (composition.paneElement) { + const paneElement = composition.paneElement + paneElement.classList.add('paneDiv') + const currentPane = provider.querySelector(':scope > .paneDiv') + if (currentPane) { + if (currentPane !== paneElement) currentPane.replaceWith(paneElement) + } else { + provider.appendChild(paneElement) + } + } +} diff --git a/src/lib/file-explorer/events.ts b/src/lib/file-explorer/events.ts index 66b264897..b8621925e 100644 --- a/src/lib/file-explorer/events.ts +++ b/src/lib/file-explorer/events.ts @@ -16,3 +16,10 @@ export interface FileExplorerOpenPaneDetail { subjectUri: string paneName: string } + +/** Whether an event belongs to this explorer rather than a nested one. */ +export function isExplorerEvent (event: Event, provider: HTMLElement): boolean { + return event.composedPath().find(target => + 'localName' in target && target.localName === 'solid-ui-file-explorer-provider' + ) === provider +} diff --git a/src/lib/file-explorer/index.ts b/src/lib/file-explorer/index.ts new file mode 100644 index 000000000..b495aab66 --- /dev/null +++ b/src/lib/file-explorer/index.ts @@ -0,0 +1,4 @@ +export * from './context' +export * from './events' +export * from './relevant-panes' +export * from './compose-explorer' diff --git a/src/lib/file-explorer/relevant-panes.ts b/src/lib/file-explorer/relevant-panes.ts index 5289d0e00..a8cf10af1 100644 --- a/src/lib/file-explorer/relevant-panes.ts +++ b/src/lib/file-explorer/relevant-panes.ts @@ -8,10 +8,11 @@ export type RelevantPaneOptions = { } /** - * Returns panes that can represent a subject and are available to the current user. + * Returns panes that can represent a subject, preserving legacy outliner ordering. * - * Availability filtering is authoritative: a pane removed by the audience check is - * not reintroduced as a fallback. + * Audience filtering narrows the list, but the first relevant pane is retained + * even when its audience does not match. The legacy outliner deliberately kept + * this most relevant view available, including when no panes match the audience. */ export async function getRelevantPanes ( subject: NamedNode, @@ -33,5 +34,12 @@ export async function getRelevantPanes ( return fallbackPane ? [fallbackPane] : [] } - return filterAvailablePanes(relevantPanes) + const filteredPanes = await filterAvailablePanes(relevantPanes) + if (filteredPanes.length === 0) return [relevantPanes[0]] + + const firstRelevantPaneIndex = paneRegistry.list.indexOf(relevantPanes[0]) + const firstFilteredPaneIndex = paneRegistry.list.indexOf(filteredPanes[0]) + return firstRelevantPaneIndex < firstFilteredPaneIndex + ? [relevantPanes[0]].concat(filteredPanes) + : filteredPanes } diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts index 46645f9ac..b5df7d19f 100644 --- a/src/types/custom-elements.d.ts +++ b/src/types/custom-elements.d.ts @@ -25,6 +25,7 @@ import type PhotoCaptureModal from '../components/photo-capture-modal/PhotoCaptu import type Provider from '../components/provider/Provider' import type RDFForm from '../components/rdf-form/RDFForm' import type RDFInput from '../components/rdf-input/RDFInput' +import type ResourceActionsMenu from '../components/resource-actions-menu/ResourceActionsMenu' import type Select from '../components/select/Select' import type SelectOption from '../components/select-option/SelectOption' import type SignupButton from '../components/signup-button/SignupButton' @@ -59,6 +60,7 @@ declare global { 'solid-ui-provider': Provider 'solid-ui-rdf-form': RDFForm 'solid-ui-rdf-input': RDFInput + 'solid-ui-resource-actions-menu': ResourceActionsMenu 'solid-ui-select': Select 'solid-ui-select-option': SelectOption 'solid-ui-signup-button': SignupButton diff --git a/test/components/compose-explorer.test.ts b/test/components/compose-explorer.test.ts new file mode 100644 index 000000000..9ff0f8741 --- /dev/null +++ b/test/components/compose-explorer.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import * as paneRegistry from 'pane-registry' +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' +import { solidLogicSingleton } from 'solid-logic' +import '../../src/components/file-explorer-provider' +import '../../src/components/file-explorer-header' +import { isExplorerEvent } from '../../src/lib/file-explorer/events' +import { composeExplorer } from '../../src/lib/file-explorer/compose-explorer' + +const context: DataBrowserContext = { + dom: document, + getOutliner: () => undefined, + session: { paneRegistry, store: solidLogicSingleton.store, logic: solidLogicSingleton } +} +const pane: PaneDefinition = { + name: 'test', + icon: '', + label: () => 'Test', + render: () => document.createElement('div') +} +const composition = { + context, + subjectUri: 'https://example.test/resource', + pane, + relevantPanes: [pane], + header: { paneIcon: '', menuItems: [] } +} + +describe('explorer composition', () => { + it('places the header first and pane content after it, with provider context', () => { + const provider = document.createElement('solid-ui-file-explorer-provider') + const paneElement = document.createElement('div') + composeExplorer(provider, { ...composition, paneElement }) + + expect(provider.context).toBe(context) + expect(provider.subjectUri).toBe(composition.subjectUri) + expect(provider.pane).toBe(pane) + expect(provider.relevantPanes).toBe(composition.relevantPanes) + expect(provider.firstElementChild?.localName).toBe('solid-ui-file-explorer-header') + expect(provider.lastElementChild).toBe(paneElement) + expect(paneElement.classList.contains('paneDiv')).toBe(true) + }) + + it('reuses the header and preserves content during a menu-only update', () => { + const provider = document.createElement('solid-ui-file-explorer-provider') + const paneElement = document.createElement('div') + composeExplorer(provider, { ...composition, paneElement }) + const header = provider.firstElementChild + const menuItems = [{ label: 'Other pane', action: () => {} }] + composeExplorer(provider, { ...composition, header: { paneIcon: '', menuItems } }) + + expect(provider.firstElementChild).toBe(header) + expect(provider.querySelector('solid-ui-file-explorer-header')?.menuItems).toBe(menuItems) + expect(provider.lastElementChild).toBe(paneElement) + expect(provider.children).toHaveLength(2) + }) + + it('hides and restores only the outer header without touching nested explorers', () => { + const outer = document.createElement('solid-ui-file-explorer-provider') + const inner = document.createElement('solid-ui-file-explorer-provider') + composeExplorer(inner, { ...composition, paneElement: document.createElement('div') }) + const innerHeader = inner.firstElementChild + const content = document.createElement('div') + content.appendChild(inner) + composeExplorer(outer, { ...composition, paneElement: content }) + + composeExplorer(outer, { ...composition, header: undefined }) + expect(outer.firstElementChild).toBe(content) + expect(inner.firstElementChild).toBe(innerHeader) + + composeExplorer(outer, composition) + expect(outer.firstElementChild?.localName).toBe('solid-ui-file-explorer-header') + expect(outer.lastElementChild).toBe(content) + expect(inner.firstElementChild).toBe(innerHeader) + }) + + it('replaces only direct pane content even when the current pane is a provider', () => { + const outer = document.createElement('solid-ui-file-explorer-provider') + const inner = document.createElement('solid-ui-file-explorer-provider') + const innerContent = document.createElement('div') + composeExplorer(inner, { ...composition, paneElement: innerContent }) + composeExplorer(outer, { ...composition, paneElement: inner }) + + const replacement = document.createElement('div') + composeExplorer(outer, { ...composition, paneElement: replacement }) + expect(outer.lastElementChild).toBe(replacement) + expect(inner.lastElementChild).toBe(innerContent) + expect(outer.contains(inner)).toBe(false) + }) + + it('identifies the nearest provider for bubbling events', () => { + const outer = document.createElement('solid-ui-file-explorer-provider') + const inner = document.createElement('solid-ui-file-explorer-provider') + const header = document.createElement('div') + inner.appendChild(header) + outer.appendChild(inner) + const owners: boolean[] = [] + inner.addEventListener('test-explorer', event => owners.push(isExplorerEvent(event, inner))) + outer.addEventListener('test-explorer', event => owners.push(isExplorerEvent(event, outer))) + + header.dispatchEvent(new CustomEvent('test-explorer', { bubbles: true, composed: true })) + expect(owners).toEqual([true, false]) + }) +}) diff --git a/test/unit/file-explorer/relevant-panes.test.ts b/test/unit/file-explorer/relevant-panes.test.ts new file mode 100644 index 000000000..5dbca9b73 --- /dev/null +++ b/test/unit/file-explorer/relevant-panes.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' +import { sym } from 'rdflib' +import { solidLogicSingleton } from 'solid-logic' +import { filterAvailablePanes } from '../../../src/login/login' +import { getRelevantPanes } from '../../../src/lib/file-explorer/relevant-panes' + +vi.mock('../../../src/login/login', () => ({ + filterAvailablePanes: vi.fn() +})) + +const subject = sym('https://example.test/resource') + +function createPane (name: string, properties: Partial = {}): PaneDefinition { + return { + name, + icon: '', + label: () => name, + render: () => document.createElement('div'), + ...properties + } +} + +function createContext (panes: PaneDefinition[]): DataBrowserContext { + return { + dom: document, + getOutliner: () => undefined, + session: { + logic: solidLogicSingleton, + store: solidLogicSingleton.store, + paneRegistry: { + list: panes, + paneForIcon: {}, + paneForPredicate: {}, + register: vi.fn(), + byName: name => panes.find(pane => pane.name === name) || null + } + } + } +} + +describe('getRelevantPanes', () => { + beforeEach(() => { + vi.mocked(filterAvailablePanes).mockReset() + vi.mocked(filterAvailablePanes).mockImplementation(async panes => panes) + }) + + it('preserves registry order when all relevant panes are available', async () => { + const panes = [createPane('first'), createPane('second')] + + expect(await getRelevantPanes(subject, createContext(panes))).toEqual(panes) + expect(filterAvailablePanes).toHaveBeenCalledWith(panes) + }) + + it('preserves the first relevant pane when audience filtering removes every pane', async () => { + const first = createPane('first') + const second = createPane('second') + const fallback = createPane('internal', { label: () => null }) + vi.mocked(filterAvailablePanes).mockResolvedValue([]) + + expect(await getRelevantPanes(subject, createContext([first, second, fallback]), { + fallbackPaneName: 'internal' + })).toEqual([first]) + }) + + it('prepends the first relevant pane when audience filtering removes it', async () => { + const first = createPane('first') + const second = createPane('second') + const third = createPane('third') + vi.mocked(filterAvailablePanes).mockResolvedValue([second, third]) + + expect(await getRelevantPanes(subject, createContext([first, second, third]))).toEqual([ + first, second, third + ]) + }) + + it('does not restore other audience-filtered panes or duplicate the first pane', async () => { + const first = createPane('first') + const second = createPane('second') + const third = createPane('third') + vi.mocked(filterAvailablePanes).mockResolvedValue([first, third]) + + expect(await getRelevantPanes(subject, createContext([first, second, third]))).toEqual([ + first, third + ]) + }) + + it('does not restore global, excluded, or nonmatching panes as the first relevant pane', async () => { + const global = createPane('global', { global: true }) + const excluded = createPane('sharing') + const nonmatching = createPane('nonmatching', { label: () => null }) + const first = createPane('first') + const second = createPane('second') + const context = createContext([global, excluded, nonmatching, first, second]) + vi.mocked(filterAvailablePanes).mockResolvedValue([]) + + expect(await getRelevantPanes(subject, context, { + excludedPaneNames: ['sharing'] + })).toEqual([first]) + expect(filterAvailablePanes).toHaveBeenCalledWith([first, second]) + }) + + it('uses the configured fallback without audience filtering when no pane is relevant', async () => { + const fallback = createPane('internal', { label: () => null }) + + expect(await getRelevantPanes(subject, createContext([fallback]), { + fallbackPaneName: 'internal' + })).toEqual([fallback]) + expect(filterAvailablePanes).not.toHaveBeenCalled() + }) + + it('returns an empty list when no pane is relevant and no fallback is configured', async () => { + expect(await getRelevantPanes(subject, createContext([]))).toEqual([]) + expect(filterAvailablePanes).not.toHaveBeenCalled() + }) + + it('returns an empty list when the configured fallback is not registered', async () => { + expect(await getRelevantPanes(subject, createContext([]), { + fallbackPaneName: 'missing' + })).toEqual([]) + expect(filterAvailablePanes).not.toHaveBeenCalled() + }) + + it('propagates audience-filtering errors instead of hiding them with a fallback', async () => { + const error = new Error('Unable to load audience roles') + vi.mocked(filterAvailablePanes).mockRejectedValue(error) + + await expect(getRelevantPanes(subject, createContext([createPane('first')]))).rejects.toBe(error) + }) +}) From 393905e7b1ba36f83acfd7497bd95187ce7cdb2b Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Mon, 19 Oct 2026 15:18:47 +1100 Subject: [PATCH 08/10] add failure function to drag and drop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/widgets/dragAndDrop.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/widgets/dragAndDrop.js b/src/widgets/dragAndDrop.js index 6957e63a4..85fe7534d 100644 --- a/src/widgets/dragAndDrop.js +++ b/src/widgets/dragAndDrop.js @@ -174,9 +174,10 @@ export function makeDraggable (tr, obj) { ** @param {String } imageBase URI of folder in which to put images ** @param successHandler function(file, uploadedURI) Called after EACH success upload ** With file object an final URI as params +** @param failureHandler function(file, message) Called for validation or upload failures */ -export function uploadFiles (fetcher, files, fileBase, imageBase, successHandler) { +export function uploadFiles (fetcher, files, fileBase, imageBase, successHandler, failureHandler) { const describeUploadFailure = function (destURI, error) { const status = error?.response?.status ?? error?.status const message = error?.message || String(error) @@ -220,6 +221,10 @@ export function uploadFiles (fetcher, files, fileBase, imageBase, successHandler 'Filename needs to have an extension which gives a type we know: ' + theFile.name debug.log(msg) + if (failureHandler) { + failureHandler(theFile, msg) + return + } alert(msg) throw new Error(msg) } @@ -254,6 +259,10 @@ export function uploadFiles (fetcher, files, fileBase, imageBase, successHandler error => { const msg = describeUploadFailure(destURI, error) debug.log(msg) + if (failureHandler) { + failureHandler(theFile, msg) + return + } alert(msg) throw new Error(msg) } From e308a1ef121a9715b67b9d43d1ba0f34150d09e8 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Mon, 19 Oct 2026 15:18:47 +1100 Subject: [PATCH 09/10] manage access 3 dots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ResourceActionsMenu.test.ts | 49 +++++++++++++++++++ .../ResourceActionsMenu.ts | 16 +++--- 2 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 src/components/resource-actions-menu/ResourceActionsMenu.test.ts diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.test.ts b/src/components/resource-actions-menu/ResourceActionsMenu.test.ts new file mode 100644 index 000000000..59a6ec482 --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import './ResourceActionsMenu.ts' + +describe('solid-ui-resource-actions-menu', () => { + it('does not duplicate the desktop file header access action in the menu', async () => { + const menu = document.createElement('solid-ui-resource-actions-menu') as HTMLElement & { + handleAccessClick?: () => void + subjectUri?: string + updateComplete: Promise + } + const handleAccessClick = vi.fn() + menu.subjectUri = 'https://example.test/resource.ttl' + menu.handleAccessClick = handleAccessClick + + document.body.appendChild(menu) + await menu.updateComplete + + const accessItem = Array.from(menu.shadowRoot?.querySelectorAll('solid-ui-menu-item') || []) + .find(item => item.textContent?.includes('Manage Access')) + expect(accessItem).toBeUndefined() + + menu.remove() + }) + + it('shows Manage Access when the containing view explicitly enables it', async () => { + const menu = document.createElement('solid-ui-resource-actions-menu') as HTMLElement & { + handleAccessClick?: () => void + subjectUri?: string + showAccessInMenu?: boolean + updateComplete: Promise + } + const handleAccessClick = vi.fn() + menu.subjectUri = 'https://example.test/resource.ttl' + menu.handleAccessClick = handleAccessClick + menu.showAccessInMenu = true + + document.body.appendChild(menu) + await menu.updateComplete + + const accessItem = Array.from(menu.shadowRoot?.querySelectorAll('solid-ui-menu-item') || []) + .find(item => item.textContent?.includes('Manage Access')) + expect(accessItem).toBeTruthy() + + accessItem?.dispatchEvent(new CustomEvent('solid-ui-select', { bubbles: true, composed: true })) + expect(handleAccessClick).toHaveBeenCalledOnce() + + menu.remove() + }) +}) diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.ts b/src/components/resource-actions-menu/ResourceActionsMenu.ts index 265e3b716..5e89be5ef 100644 --- a/src/components/resource-actions-menu/ResourceActionsMenu.ts +++ b/src/components/resource-actions-menu/ResourceActionsMenu.ts @@ -46,13 +46,17 @@ export default class ResourceActionsMenu extends WebComponent { @property({ type: Boolean }) accessor isMobile = false + @property({ type: Boolean, attribute: 'show-access-in-menu' }) + accessor showAccessInMenu = false + @property({ attribute: false }) accessor menuItems: ResourceActionMenuItem[] = [] render () { const isContainerResource = isContainerSubject(this.store, this.subjectUri) const canEdit = !isContainerResource && this.isMobile && this.paneSupportsEditing && this.canEdit && !!this.handleEditingClick - const canManageAccess = !!this.handleAccessClick + const canManageAccess = !!this.handleAccessClick && + (this.showAccessInMenu || this.isMobile || isContainerResource) const actionableMenuItems = this.menuItems.filter((item): item is ActionableResourceActionMenuItem => typeof item.action === 'function') return html` @@ -74,15 +78,7 @@ export default class ResourceActionsMenu extends WebComponent { ` : nothing} - ${!isContainerResource && this.isMobile && canManageAccess - ? html` - - - Manage Access - - ` - : nothing} - ${isContainerResource && canManageAccess + ${canManageAccess ? html` From d04735d6f84c2139f087916470abd1f8f364c734 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 18 Oct 2026 21:16:49 +1100 Subject: [PATCH 10/10] Tries to address the missing features from the current sharing pane. Prompt: multiple prompts to work to see what is possible to make sure not to lose functionality will review it after we have a look and improve code and design. Co-authored-by: GPT-5.6 Luna Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 4 +- package.json | 2 +- .../AccessControlAddAccess.styles.css | 2 +- .../AccessControlAddAccess.ts | 28 +- .../AccessControlErrors.ts | 17 +- .../AccessControlGeneralAccess.ts | 7 +- .../AccessControlModal.styles.css | 259 ++++++++ .../AccessControlModal.ts | 354 ++++++++++- .../AccessControlSharedAccess.ts | 19 +- .../access-control-modal/TrustedAppIcon.ts | 10 + .../access-control-modal/accessGrantDrafts.ts | 16 +- .../accessTargetResolver.ts | 79 ++- .../applyAccessChanges.ts | 34 +- src/components/access-control-modal/types.ts | 8 +- src/components/combobox/Combobox.ts | 1 + src/lib/context/noopContext.ts | 2 +- test/components/access-control-modal.test.ts | 599 +++++++++++++++++- .../accessGrantDrafts.test.ts | 50 +- .../accessTargetResolver.test.ts | 55 +- test/unit/index.test.ts | 3 + 20 files changed, 1479 insertions(+), 70 deletions(-) create mode 100644 src/components/access-control-modal/TrustedAppIcon.ts diff --git a/package-lock.json b/package-lock.json index d49a4b2d2..0166a1e4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-ui", - "version": "5.0.1", + "version": "5.0.2-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-ui", - "version": "5.0.1", + "version": "5.0.2-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5b0b8d37f..96f834af6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-ui", - "version": "5.0.1", + "version": "5.0.2-0", "description": "UI library for Solid applications", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", diff --git a/src/components/access-control-modal/AccessControlAddAccess.styles.css b/src/components/access-control-modal/AccessControlAddAccess.styles.css index 1331e3136..0257c9f2b 100644 --- a/src/components/access-control-modal/AccessControlAddAccess.styles.css +++ b/src/components/access-control-modal/AccessControlAddAccess.styles.css @@ -17,7 +17,7 @@ .access-grants-form-main { display: flex; - align-items: flex-start; + align-items: center; gap: 10px; align-self: stretch; diff --git a/src/components/access-control-modal/AccessControlAddAccess.ts b/src/components/access-control-modal/AccessControlAddAccess.ts index cda932ee9..8f4015a60 100644 --- a/src/components/access-control-modal/AccessControlAddAccess.ts +++ b/src/components/access-control-modal/AccessControlAddAccess.ts @@ -2,12 +2,13 @@ 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 type { AsyncComboboxOptionsProvider, ComboboxChangeEvent, ComboboxOptionData } from '@/components/combobox' import '~icons/lucide/book-user' import '~icons/lucide/user-round' import '~icons/lucide/users' import '~icons/lucide/circle-x' +import './TrustedAppIcon' import '@/components/button' import '@/components/combobox' import '@/components/combobox-option' @@ -56,6 +57,9 @@ export default class AccessControlAddAccess extends WebComponent { @property({ attribute: false }) accessor inputError = '' + @property({ type: Boolean }) + accessor disabled = false + @property({ attribute: false }) accessor asyncOptionsProvider: AsyncComboboxOptionsProvider | null = null @@ -69,7 +73,7 @@ export default class AccessControlAddAccess extends WebComponent { } private onPrincipalSelect (event: ComboboxChangeEvent) { - const option = event.detail.option + const option = event.detail.option as ComboboxOptionData & { targetKind?: 'origin' | 'trustedApp' } if (typeof option.value !== 'string') { return } @@ -77,7 +81,8 @@ export default class AccessControlAddAccess extends WebComponent { this.dispatchEvent(new CustomEvent('target-selected', { detail: { value: option.value, - label: option.label + label: option.label, + targetKind: option.targetKind }, bubbles: true, composed: true @@ -86,12 +91,15 @@ export default class AccessControlAddAccess extends WebComponent { private onRoleChange (event: ComboboxChangeEvent) { const role = event.detail.option.value - if (typeof role !== 'string' || !ACCESS_ROLES.includes(role as AccessRole)) { + const accessRole = typeof role === 'string' + ? ACCESS_ROLES.find(candidate => candidate === role) + : undefined + if (!accessRole) { return } this.dispatchEvent(new CustomEvent('role-changed', { - detail: { role: role as AccessRole }, + detail: { role: accessRole }, bubbles: true, composed: true })) @@ -118,6 +126,7 @@ export default class AccessControlAddAccess extends WebComponent {
@@ -156,11 +166,17 @@ export default class AccessControlAddAccess extends WebComponent {
${this.pendingAccessGrants.map((grant, index) => html`
- ${grant.label} + + ${grant.targetKind === 'trustedApp' + ? html`` + : nothing} + ${grant.label} + this.onPendingGrantRemove(index)} > Remove ${grant.label} diff --git a/src/components/access-control-modal/AccessControlErrors.ts b/src/components/access-control-modal/AccessControlErrors.ts index 4558b7419..fa8041153 100644 --- a/src/components/access-control-modal/AccessControlErrors.ts +++ b/src/components/access-control-modal/AccessControlErrors.ts @@ -25,6 +25,16 @@ export default class AccessControlErrors extends WebComponent { })) } + private getScopeLabel (scope: NonNullable['scope']>) { + return scope === 'folder+contents' + ? 'folder and contents' + : scope === 'container' + ? 'folder' + : scope === 'contents' + ? 'contents' + : 'resource' + } + protected render () { if (!this.errors.length) { return html`` @@ -44,11 +54,14 @@ export default class AccessControlErrors extends WebComponent { ${error.label} ${error.action === 'grant' - ? `Could not grant ${error.role ?? 'access'}` + ? `Could not grant ${error.role ?? 'access'} for ${error.scope ? this.getScopeLabel(error.scope) : 'resource'}` : error.action === 'revoke' - ? 'Could not remove access' + ? `Could not remove access for ${error.scope ? this.getScopeLabel(error.scope) : 'resource'}` + : error.action === 'reset' + ? 'Could not reset sharing' : 'Could not save access changes'} + ${error.message} `)} diff --git a/src/components/access-control-modal/AccessControlGeneralAccess.ts b/src/components/access-control-modal/AccessControlGeneralAccess.ts index ff969a5be..e634187ab 100644 --- a/src/components/access-control-modal/AccessControlGeneralAccess.ts +++ b/src/components/access-control-modal/AccessControlGeneralAccess.ts @@ -27,6 +27,9 @@ export default class AccessControlGeneralAccess extends WebComponent { @property({ attribute: false }) accessor publicAccessRoleValue: AccessRole = 'No Access' + @property({ type: Boolean }) + accessor disabled = false + private onAuthenticatedRoleChange (event: ComboboxChangeEvent) { this.dispatchRoleChange('authenticated-role-changed', event, ACCESS_ROLES) } @@ -83,6 +86,7 @@ export default class AccessControlGeneralAccess extends WebComponent { return html` @@ -95,6 +99,7 @@ export default class AccessControlGeneralAccess extends WebComponent { return html` @@ -149,7 +154,7 @@ export default class AccessControlGeneralAccess extends WebComponent {
${this.renderGeneralShareRow({ - title: 'Share with Anyone Signed In', + title: 'Only logged in users with the link', description: 'Users must sign in to SolidOS to access this shared item using the link.', variant: 'authenticated' })} diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index 32438b214..8c3109b2a 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -1,4 +1,263 @@ :host { + .access-control-mixed-policy-warning { + display: grid; + gap: 4px; + margin: 0 0 16px; + padding: 12px 14px; + border: 1px solid var(--solid-ui-color-red-300, #FCA5A5); + border-radius: 5px; + background: var(--solid-ui-color-red-50, #FEF2F2); + color: var(--solid-ui-color-red-800, #991B1B); + font-size: var(--solid-ui-font-size-sm, .875rem); + line-height: 1.45; + + strong { + font-weight: 600; + } + } + + .access-control-same-access-toggle { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 12px; + color: var(--solid-ui-color-gray-900, #101828); + line-height: 1.4; + border: 0; + background: transparent; + padding: 0; + text-align: left; + font: inherit; + cursor: pointer; + } + + .access-control-switch-track { + display: inline-flex; + flex: 0 0 auto; + width: 34px; + height: 20px; + margin-top: 1px; + border-radius: 999px; + background: var(--solid-ui-color-slate-300, #CBD5E1); + padding: 2px; + transition: background .15s ease; + } + + .access-control-same-access-toggle[aria-checked='true'] .access-control-switch-track { + background: var(--solid-ui-color-purple-600, #9333EA); + } + + .access-control-switch-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + box-shadow: 0 1px 2px rgb(0 0 0 / 25%); + transition: transform .15s ease; + } + + .access-control-same-access-toggle[aria-checked='true'] .access-control-switch-thumb { + transform: translateX(14px); + } + + .access-control-same-access-toggle > span:last-child { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + } + + .access-control-sharing-state, + .access-control-contents-prompt { + box-sizing: border-box; + min-width: 0; + max-width: 100%; + padding: 10px; + margin-bottom: 16px; + font-size: var(--solid-ui-font-size-sm, .875rem); + + h2 { + margin: 0 0 10px; + color: var(--solid-ui-color-gray-900, #101828); + font-size: var(--solid-ui-font-size-md, 1rem); + font-weight: 600; + } + + .access-control-advanced-sharing { + box-sizing: border-box; + min-width: 0; + width: 100%; + max-width: 100%; + overflow-wrap: anywhere; + margin-top: 10px; + padding: 10px 15px; + position: relative; + padding-right: 48px; + + h2 { + margin: 0 0 4px; + color: var(--solid-ui-color-gray-900, #101828); + font-size: var(--solid-ui-font-size-md, 1rem); + font-weight: 600; + } + + p { + margin: 0; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + color: var(--solid-ui-color-gray-600, #4A5565); + font-size: var(--solid-ui-font-size-xs, .75rem); + line-height: 1.5; + } + + .access-control-advanced-sharing-description-line { + display: block; + max-width: 100%; + } + } + + .access-control-advanced-sharing-options, + .access-control-advanced-sharing-actions { + display: flex; + flex-wrap: wrap; + min-width: 0; + gap: 8px; + margin-top: 10px; + + solid-ui-button { + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + min-width: 0; + max-width: 100%; + white-space: normal; + } + } + + .access-control-advanced-sharing-actions { + justify-content: flex-end; + position: absolute; + top: 10px; + right: 15px; + margin-top: 0; + + solid-ui-button { + color: var(--solid-ui-color-gray-600, #4A5565); + } + } + + p { + margin: 0; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + color: var(--solid-ui-color-gray-600, #4A5565); + line-height: 1.5; + } + } + + .access-control-sharing-state-panel, + .access-control-contents-prompt { + box-sizing: border-box; + min-width: 0; + max-width: 100%; + border: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); + border-radius: 5px; + background: var(--solid-ui-color-white, #FFF); + padding: 10px 15px; + } + + .access-control-sharing-state-panel { + display: grid; + gap: 10px; + + p { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + } + + .access-control-sharing-state-custom-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + + p { + flex: 1 1 auto; + } + + solid-ui-button { + flex: 0 0 auto; + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + } + } + } + + .access-control-sharing-guidance { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + gap: 12px; + + p { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + } + + solid-ui-button { + flex: 0 0 auto; + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + } + } + + .access-control-sharing-state-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 0; + justify-content: flex-end; + margin-top: 10px; + gap: 8px; + + solid-ui-button { + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + max-width: 100%; + } + } + + @media (max-width: 420px) { + .access-control-sharing-guidance { + align-items: flex-start; + flex-direction: column; + gap: 8px; + + solid-ui-button { + align-self: flex-end; + } + } + } + + .access-control-contents-prompt { + .access-control-contents-prompt-actions { + display: flex; + flex-wrap: wrap; + min-width: 0; + gap: 8px; + margin-top: 10px; + } + + solid-ui-button { + --solid-ui-font-size-md: var(--solid-ui-font-size-sm, .875rem); + max-width: 100%; + flex: 0 1 auto; + } + } + + .access-control-contents-prompt h2 { + margin-bottom: 4px; + } + .access-control-footer-actions { display: flex; justify-content: flex-end; diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index a4a634bcd..4675a0c57 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -4,10 +4,13 @@ import type { PropertyValues } from 'lit' import { property, state } from 'lit/decorators.js' import { label } from '@/utils/label' import * as debug from '@/lib/debug' -import { Authenticated, Public, solidLogicSingleton, type AccessRole, type Authorization } from 'solid-logic' +import { Authenticated, Public, analyzeContainerAccessScope, filterAuthorizationsByContainerScope, solidLogicSingleton, type ACLContext, type AccessRole, type Authorization, type ContainerAccessScope, type ContainerAccessScopeResult } from 'solid-logic' import { sym } from 'rdflib' import '~icons/lucide/circle-x' +import '~icons/lucide/folder' +import '~icons/lucide/folder-open' +import '~icons/lucide/x' import '@/components/dialog' import '@/components/dialog-content' import '@/components/dialog-footer' @@ -19,8 +22,7 @@ import './AccessControlGeneralAccess' import './AccessControlErrors' import styles from './AccessControlModal.styles.css' -import type { AccessChange, AccessControlError, AccessGrantDraft, AccessGrantRoleChangedEvent, AccessSaveError, InputAccessControlError, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' -import { dedupeByKey } from './helpers' +import type { AccessChange, AccessControlError, AccessGrantDraft, AccessGrantRoleChangedEvent, AccessSaveError, AccessSaveScope, InputAccessControlError, PendingAccessGrant, PendingGrantRemovedEvent, RoleChangedEvent, TargetInputEvent, TargetSelectedEvent } from './types' import { createAccessGrantDrafts, getAccessGrantIds, getAccessGrantViewModels, getAuthenticatedAccessRole, getAuthorizationSubjectLabel, getChangedAccessGrants, getPublicAccessRole, getSharedAuthorization } from './accessGrantDrafts' import { accessPrincipalOptionsProvider, resolveAccessTarget } from './accessTargetResolver' import { applyAccessChanges } from './applyAccessChanges' @@ -71,10 +73,27 @@ export default class AccessControlModal extends DialogComponent { @state() private accessor successfulChangesRefreshed = true + @state() + private accessor accessContext: ACLContext | undefined + + @state() + private accessor advancedSharingOpen = false + + @state() + private accessor containerScope: ContainerAccessScope = 'folder+contents' + + @state() + private accessor containerScopeAnalysis: ContainerAccessScopeResult | undefined + + @state() + private accessor protectedTarget = false + private accessGrantLabelRequestVersion = 0 + private accessContextRequestVersion = 0 disconnectedCallback (): void { this.accessGrantLabelRequestVersion++ + this.accessContextRequestVersion++ this.errors = [] super.disconnectedCallback() } @@ -102,26 +121,49 @@ export default class AccessControlModal extends DialogComponent { super.willUpdate(changedProperties) if (changedProperties.has('accessGrants')) { - const accessGrants = this.accessGrants ?? [] - this.accessGrantDrafts = createAccessGrantDrafts(accessGrants) - this.authenticatedAccessRoleValue = getAuthenticatedAccessRole(accessGrants) - this.publicAccessRoleValue = getPublicAccessRole(accessGrants) + const scopedAccessGrants = this.getScopedAccessGrants() + this.accessGrantDrafts = createAccessGrantDrafts(scopedAccessGrants) + this.authenticatedAccessRoleValue = getAuthenticatedAccessRole(scopedAccessGrants) + this.publicAccessRoleValue = getPublicAccessRole(scopedAccessGrants) } } protected updated (changedProperties: PropertyValues) { super.updated(changedProperties) + if (changedProperties.has('subjectUri')) { + this.loadAccessContext().catch(error => { + debug.error('Failed to load access control context', error) + }) + } + if (changedProperties.has('accessGrants')) { this.refreshAccessGrantLabels().catch((error: unknown) => { debug.error('Failed to refresh access grant labels', error) }) } + + if (changedProperties.has('accessContext' as keyof AccessControlModal) && !changedProperties.has('subjectUri')) { + this.refreshContainerScopeAnalysis() + } + } + + private refreshContainerScopeAnalysis () { + if (this.isContainer() && this.accessContext) { + this.containerScopeAnalysis = analyzeContainerAccessScope(this.accessContext) + this.containerScope = this.containerScopeAnalysis.primaryScope + this.refreshScopedAccessGrantDrafts() + } else { + this.containerScopeAnalysis = undefined + } } private async refreshAccessGrantLabels () { const requestVersion = ++this.accessGrantLabelRequestVersion - const grants = this.accessGrants ?? [] + const sourceAccessGrants = this.accessGrants + const subjectUri = this.subjectUri + const scope = this.getSelectedContainerScope() + const grants = this.getScopedAccessGrants(scope) if (!grants.length) { this.accessGrantDrafts = [] return @@ -139,7 +181,12 @@ export default class AccessControlModal extends DialogComponent { return getAuthorizationSubjectLabel(getSharedAuthorization(authorization)) })) - if (requestVersion === this.accessGrantLabelRequestVersion && this.accessGrants === grants) { + if ( + requestVersion === this.accessGrantLabelRequestVersion && + this.accessGrants === sourceAccessGrants && + this.subjectUri === subjectUri && + this.getSelectedContainerScope() === scope + ) { const labelsById = new Map(getAccessGrantIds(grants).map((id, index) => [id, labels[index]])) this.accessGrantDrafts = this.accessGrantDrafts.map(draft => ({ ...draft, @@ -153,10 +200,211 @@ export default class AccessControlModal extends DialogComponent { const subjectLabel = subject ? label(subject).trim() : '' if (!subjectLabel || subjectLabel === 'this resource') { - return 'Share this resource' + return 'Manage Access of "this resource"' } + return `Manage Access of "${subjectLabel}"` + } + + private isContainer (): boolean { + return Boolean(this.subjectUri && new URL(this.subjectUri).pathname.endsWith('/')) + } + + private async loadAccessContext (): Promise { + const requestVersion = ++this.accessContextRequestVersion + const subjectUri = this.subjectUri + if (!subjectUri) { + this.accessContext = undefined + return + } + + const accessContext = await solidLogicSingleton.acl.findEffectiveAcl(subjectUri) + if (requestVersion !== this.accessContextRequestVersion || this.subjectUri !== subjectUri) { + return + } + + this.accessContext = accessContext + if (this.accessContext?.inherited && !(this.accessGrants?.length)) { + // findAccessGrants only reports ACLs attached directly to the resource. + // Inherited permissions are the effective grants users need to review. + this.accessGrants = this.accessContext.authorizations + } + this.refreshContainerScopeAnalysis() + this.protectedTarget = solidLogicSingleton.acl.isProtectedResource(subjectUri) + if (this.protectedTarget) { + this.refreshScopedAccessGrantDrafts() + } + } + + private renderSharingInformation () { + if (!this.accessContext) { + return html`` + } + + const isCustomSharing = !this.accessContext.inherited + const inheritedContainer = this.accessContext?.effectiveContainer + const inheritedContainerLabel = inheritedContainer + ? label(sym(inheritedContainer)).trim() + : '' + const defaultContainer = inheritedContainerLabel && inheritedContainerLabel !== inheritedContainer + ? html`${inheritedContainerLabel} (${inheritedContainer})` + : html`${inheritedContainer ?? 'the parent container'}` + return html` + + ` + } + + private renderSharingState () { + if (!this.accessContext || !this.advancedSharingOpen) { + return html`` + } + + return html` + + ` + } + + private getSelectedContainerScope (): ContainerAccessScope { + return this.containerScope + } - return `Share "${subjectLabel}"` + private hasMixedContainerPolicy (): boolean { + return this.isContainer() && this.containerScopeAnalysis?.mixed === true + } + + private renderMixedContainerWarning () { + if (!this.hasMixedContainerPolicy()) { + return html`` + } + + return html` + + ` + } + + private getScopedAccessGrants (scope = this.getSelectedContainerScope()): Authorization[] { + const grants = this.accessGrants ?? [] + if (!this.isContainer() || !this.subjectUri || this.protectedTarget) { + return grants + } + return filterAuthorizationsByContainerScope(this.subjectUri, grants, scope, this.accessContext?.inherited ?? false) + } + + private refreshScopedAccessGrantDrafts () { + const scopedAccessGrants = this.getScopedAccessGrants() + this.accessGrantDrafts = createAccessGrantDrafts(scopedAccessGrants) + this.authenticatedAccessRoleValue = getAuthenticatedAccessRole(scopedAccessGrants) + this.publicAccessRoleValue = getPublicAccessRole(scopedAccessGrants) + } + + private selectContainerScope (scope: 'folder+contents' | 'contents' | 'folder') { + this.containerScope = scope + this.refreshScopedAccessGrantDrafts() + } + + private renderAdvancedSharing () { + if (!this.advancedSharingOpen) return html`` + + return html` +
+

Advanced sharing

+

+ Use this option when the folder and its contents have different access requirements. + Choose which access settings to edit. + Save Changes applies your current edits only to that scope. +

+ ${this.isContainer() + ? html` + +
+ { this.selectContainerScope('folder') }} + > + Folder Only + + { this.selectContainerScope('contents') }} + > + Contents Only + +
+ ` + : html``} +
+ + + Close + +
+
+ ` } protected render () { @@ -166,7 +414,9 @@ export default class AccessControlModal extends DialogComponent {
- { @pending-grant-removed=${this.onPendingGrantRemoved} > { .successfulChangesRefreshed=${this.successfulChangesRefreshed} @close=${this.onCancelClick} > + ${this.renderSharingState()} @@ -204,7 +457,7 @@ export default class AccessControlModal extends DialogComponent { Cancel 0} + ?disabled=${!this.hasUnsavedChanges() || this.submitting || this.saveErrors.length > 0 || this.hasMixedContainerPolicy()} ?loading=${this.submitting} type="button" @click=${this.onSaveClick} @@ -221,7 +474,7 @@ export default class AccessControlModal extends DialogComponent { private async onSubmit (e: Event) { e.preventDefault() - if (this.submitting) { + if (this.submitting || this.hasMixedContainerPolicy()) { return } @@ -229,7 +482,7 @@ export default class AccessControlModal extends DialogComponent { } private async onSaveClick () { - if (this.submitting) { + if (this.submitting || this.hasMixedContainerPolicy()) { return } @@ -241,15 +494,44 @@ export default class AccessControlModal extends DialogComponent { } } - await this.saveAccessChanges() + const scope = this.isContainer() && !this.protectedTarget + ? this.getSelectedContainerScope() + : 'file' + await this.saveAccessChanges(scope === 'folder' ? 'container' : scope) } private onCancelClick () { this.close() } - private async saveAccessChanges () { - if (this.submitting) { + private closeAdvancedSharing () { + this.advancedSharingOpen = false + } + + private async onResetToDefault () { + if (!this.subjectUri || !this.accessContext || this.accessContext.inherited || this.submitting || this.hasMixedContainerPolicy()) return + + this.submitting = true + this.errors = this.errors.filter(error => error.action === 'input') + try { + await solidLogicSingleton.acl.resetToDefault(this.subjectUri) + await this.loadAccessContext() + this.accessGrants = await solidLogicSingleton.acl.findAccessGrants(this.subjectUri) + } catch (error) { + this.errors = [...this.errors, { + action: 'reset', + targetUri: this.subjectUri, + label: 'Custom sharing', + message: error instanceof Error ? error.message : 'Unable to reset sharing.' + }] + debug.error('Failed to reset access control', error) + } finally { + this.submitting = false + } + } + + private async saveAccessChanges (scope: AccessSaveScope = 'file') { + if (this.submitting || this.hasMixedContainerPolicy()) { return } @@ -275,7 +557,9 @@ export default class AccessControlModal extends DialogComponent { changedAccessGrants }) - const saveErrors = await applyAccessChanges(this.subjectUri, accessChanges) + const saveErrors = await applyAccessChanges(this.subjectUri, accessChanges, { + containerScope: scope + }) if (saveErrors.length) { this.errors = [...this.errors, ...saveErrors] this.principalInputValue = '' @@ -324,12 +608,14 @@ export default class AccessControlModal extends DialogComponent { } private onPrincipalInput (event: TargetInputEvent) { + if (this.hasMixedContainerPolicy()) return this.principalInputValue = event.detail.value this.clearInputError() } private onPrincipalSelect (event: TargetSelectedEvent) { - this.queuePendingPrincipal(event.detail.value, this.addAccessRoleValue, event.detail.label) + if (this.hasMixedContainerPolicy()) return + this.queuePendingPrincipal(event.detail.value, this.addAccessRoleValue, event.detail.label, undefined, event.detail.targetKind) } private getGeneralAccessChanges (): AccessChange[] { @@ -337,7 +623,7 @@ export default class AccessControlModal extends DialogComponent { return [] } - const accessGrants = this.accessGrants ?? [] + const accessGrants = this.getScopedAccessGrants() const currentAuthenticatedRole = getAuthenticatedAccessRole(accessGrants) const currentPublicRole = getPublicAccessRole(accessGrants) @@ -361,6 +647,7 @@ export default class AccessControlModal extends DialogComponent { } private onAddAccessRoleInput (event: RoleChangedEvent) { + if (this.hasMixedContainerPolicy()) return this.addAccessRoleValue = event.detail.role this.pendingAccessGrants = this.pendingAccessGrants.map(grant => ({ ...grant, @@ -369,14 +656,17 @@ export default class AccessControlModal extends DialogComponent { } private onAuthenticatedAccessRoleInput (event: RoleChangedEvent) { + if (this.hasMixedContainerPolicy()) return this.authenticatedAccessRoleValue = event.detail.role } private onPublicAccessRoleInput (event: RoleChangedEvent) { + if (this.hasMixedContainerPolicy()) return this.publicAccessRoleValue = event.detail.role } private onAccessGrantRoleInput (event: AccessGrantRoleChangedEvent) { + if (this.hasMixedContainerPolicy()) return this.accessGrantDrafts = this.accessGrantDrafts.map(draft => draft.id === event.detail.id ? { ...draft, role: event.detail.role } @@ -385,6 +675,7 @@ export default class AccessControlModal extends DialogComponent { } private onPendingGrantRemoved (event: PendingGrantRemovedEvent) { + if (this.hasMixedContainerPolicy()) return this.pendingAccessGrants = this.pendingAccessGrants.filter((_, pendingIndex) => pendingIndex !== event.detail.index) } @@ -398,6 +689,10 @@ export default class AccessControlModal extends DialogComponent { } private async commitPrincipalFromInput (): Promise { + if (this.hasMixedContainerPolicy()) { + return false + } + const rawValue = this.principalInputValue.trim() if (!rawValue) { return false @@ -432,10 +727,11 @@ export default class AccessControlModal extends DialogComponent { principal: string, role: AccessRole = this.addAccessRoleValue, preferredLabel?: string, - inputSnapshot?: string + inputSnapshot?: string, + targetKind?: 'origin' | 'trustedApp' ): Promise { const snapshot = inputSnapshot ?? this.principalInputValue.trim() - const result = await this.createPendingAccessGrant(principal, role, preferredLabel) + const result = await this.createPendingAccessGrant(principal, role, preferredLabel, targetKind) if ('error' in result) { if (this.principalInputValue.trim() === snapshot) { @@ -459,7 +755,8 @@ export default class AccessControlModal extends DialogComponent { private async createPendingAccessGrant ( principal: string, role: AccessRole = this.addAccessRoleValue, - preferredLabel?: string + preferredLabel?: string, + targetKind?: 'origin' | 'trustedApp' ): Promise { try { const normalizedPrincipal = principal.trim() @@ -480,7 +777,8 @@ export default class AccessControlModal extends DialogComponent { subjectType: resolvedTarget.subjectType, subjectValue: resolvedTarget.subjectValue, role, - label: resolvedTarget.label + label: resolvedTarget.label, + targetKind: targetKind ?? resolvedTarget.targetKind } } } catch (error) { @@ -498,6 +796,10 @@ export default class AccessControlModal extends DialogComponent { } private dedupePendingAccessGrants (draftGrants: PendingAccessGrant[]): PendingAccessGrant[] { - return dedupeByKey(draftGrants, draftGrant => `${draftGrant.subjectType}:${draftGrant.subjectValue}`) + const grantsBySubject = new Map() + for (const draftGrant of draftGrants) { + grantsBySubject.set(`${draftGrant.subjectType}:${draftGrant.subjectValue}`, draftGrant) + } + return [...grantsBySubject.values()] } } diff --git a/src/components/access-control-modal/AccessControlSharedAccess.ts b/src/components/access-control-modal/AccessControlSharedAccess.ts index ce5f2828e..270e655b4 100644 --- a/src/components/access-control-modal/AccessControlSharedAccess.ts +++ b/src/components/access-control-modal/AccessControlSharedAccess.ts @@ -11,6 +11,7 @@ import '@/components/combobox-option' import styles from './AccessControlSharedAccess.styles.css' import type { AccessGrantRoleChangedDetail, AccessGrantViewModel } from './types' +import './TrustedAppIcon' @customElement('solid-ui-access-control-shared-access') export default class AccessControlSharedAccess extends WebComponent { @@ -19,6 +20,9 @@ export default class AccessControlSharedAccess extends WebComponent { @property({ attribute: false }) accessor accessGrantViewModels: AccessGrantViewModel[] = [] + @property({ type: Boolean }) + accessor disabled = false + @state() private accessor searchValue = '' @@ -38,13 +42,16 @@ export default class AccessControlSharedAccess extends WebComponent { private onAccessGrantRoleChange (id: string, event: ComboboxChangeEvent) { const value = event.detail.option.value - if (typeof value !== 'string' || !ACCESS_ROLES.includes(value as AccessRole)) { + const accessRole = typeof value === 'string' + ? ACCESS_ROLES.find(candidate => candidate === value) + : undefined + if (!accessRole) { return } const detail: AccessGrantRoleChangedDetail = { id, - role: value as AccessRole + role: accessRole } this.dispatchEvent(new CustomEvent('access-grant-role-changed', { @@ -83,6 +90,8 @@ export default class AccessControlSharedAccess extends WebComponent {
${badge.image ? html`` + : badge.kind === 'origin' + ? html`` : html``}
` @@ -123,7 +132,8 @@ export default class AccessControlSharedAccess extends WebComponent { if (authorization.origin[0]) { return { kind: 'origin' as const, - text: 'O' + text: 'O', + image: '' } } @@ -143,6 +153,7 @@ export default class AccessControlSharedAccess extends WebComponent { return html` this.onAccessGrantRoleChange(id, event)} > @@ -163,7 +174,7 @@ export default class AccessControlSharedAccess extends WebComponent { return html`
-

Share with

+

Shared with

`${subject.type}:${subject.iri}`) .sort() @@ -93,13 +97,17 @@ export function getChangedAccessGrants (drafts: AccessGrantDraft[]): ChangedAcce } export function getAuthenticatedAccessRole (accessGrants: AccessAuthorization[]): AccessRole { - const authorization = accessGrants.find(item => item.agentClass.includes(Authenticated.iri)) - return authorization ? getAuthorizationRole(authorization) : 'No Access' + const modes = accessGrants + .filter(item => item.agentClass.includes(Authenticated.iri)) + .flatMap(item => item.mode) + return modes.length ? solidLogicSingleton.acl.roleFromModes(modes) : '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' + const modes = accessGrants + .filter(item => item.agentClass.includes(Public.iri)) + .flatMap(item => item.mode) + return modes.length ? solidLogicSingleton.acl.publicRoleFromModes(modes) : 'No Access' } export function getAccessSubjectLabel (subject: AccessSubject): string { diff --git a/src/components/access-control-modal/accessTargetResolver.ts b/src/components/access-control-modal/accessTargetResolver.ts index 398431477..d17270f20 100644 --- a/src/components/access-control-modal/accessTargetResolver.ts +++ b/src/components/access-control-modal/accessTargetResolver.ts @@ -3,19 +3,33 @@ 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 { html } from 'lit' +import ns from '@/lib/ns' +import './TrustedAppIcon' import { dedupeByKey, dedupeComboboxOptions, isAbsoluteHttpUri, isOriginUrl, normalizeOriginInput, type StringComboboxOptionData } from './helpers' export type ResolvedAccessTarget = { subjectType: AccessControlSubjectKind subjectValue: string label: string + targetKind?: 'origin' | 'trustedApp' +} + +type AccessTargetOption = ComboboxOptionData & { + targetKind?: 'origin' | 'trustedApp' } async function classifyAccessTarget (principal: string) { const normalizedOrigin = normalizeOriginInput(principal) - return normalizedOrigin - ? { kind: 'origin' as const, subjectValue: normalizedOrigin } - : await solidLogicSingleton.acl.classifyAccessControlSubject(principal) + if (normalizedOrigin) { + return { kind: 'origin' as const, subjectValue: normalizedOrigin } + } + + if (isOriginUrl(principal)) { + return { kind: 'origin' as const, subjectValue: new URL(principal).origin } + } + + return await solidLogicSingleton.acl.classifyAccessControlSubject(principal) } async function resolveAccessTargetLabel ( @@ -30,7 +44,7 @@ async function resolveAccessTargetLabel ( await solidLogicSingleton.store.fetcher.load(subject.doc()) const resolvedLabel = preferredLabel?.trim() || label(subject).trim() if (!resolvedLabel || resolvedLabel === resolvedPrincipal.subjectValue) { - return undefined + throw new Error('The access target has no usable display label.') } return resolvedLabel @@ -53,7 +67,8 @@ export async function resolveAccessTarget ( return { subjectType: resolvedPrincipal.kind, subjectValue: resolvedPrincipal.subjectValue, - label: resolvedLabel + label: resolvedLabel, + targetKind: resolvedPrincipal.kind === 'origin' ? 'origin' : undefined } } @@ -72,12 +87,14 @@ function directoryEntryToOption (entry: DirectoryEntry, labelValue: string): Com } } -async function getPreferredAccessPrincipalOptions (query: string): Promise { +async function getPreferredAccessPrincipalOptions (query: string): Promise { const normalizedOrigin = normalizeOriginInput(query) if (normalizedOrigin) { return [{ label: `Use origin ${normalizedOrigin}`, - value: normalizedOrigin + value: normalizedOrigin, + targetKind: 'origin', + template: html`Use origin ${normalizedOrigin}` }] } @@ -88,7 +105,9 @@ async function getPreferredAccessPrincipalOptions (query: string): PromiseUse origin ${new URL(query).origin}` }] } @@ -96,6 +115,30 @@ async function getPreferredAccessPrincipalOptions (query: string): Promise { + try { + const me = solidLogicSingleton.authn.currentUser() + if (!me) return [] + await solidLogicSingleton.profile.loadProfile(me) + const store = solidLogicSingleton.store + const apps = store.each(me, ns.acl('trustedApp')) as Array> + return apps.flatMap(app => { + const origin = store.any(app, ns.acl('origin')) + if (!origin || !isOriginUrl(origin.value)) return [] + const normalizedOrigin = new URL(origin.value).origin + if (query && !`${normalizedOrigin} trusted app`.toLowerCase().includes(query.toLowerCase())) return [] + return [{ + label: `Trusted app: ${normalizedOrigin}`, + value: normalizedOrigin, + targetKind: 'trustedApp' as const, + template: html`Trusted app: ${normalizedOrigin}` + }] + }) + } catch { + return [] + } +} + function getShortQueryOptions (preferredOptions: ComboboxOptionData[]): ComboboxOptionData[] { if (preferredOptions.length) { return preferredOptions @@ -112,9 +155,18 @@ export const accessPrincipalOptionsProvider: AsyncComboboxOptionsProvider = defineAsyncComboboxOptionsProvider(async (filter: string) => { const query = filter.trim() const preferredOptions = await getPreferredAccessPrincipalOptions(query) + const trustedAppOptions = await getTrustedAppOptions(query) if (query.length < 2) { - return getShortQueryOptions(preferredOptions) + return dedupeByKey( + [...preferredOptions, ...trustedAppOptions, ...getShortQueryOptions(preferredOptions)], + option => { + const targetOption = option as AccessTargetOption + return typeof targetOption.value === 'string' + ? `${targetOption.value}:${targetOption.targetKind ?? 'default'}` + : undefined + } + ) } let entries: DirectoryEntry[] = [] @@ -142,7 +194,12 @@ export const accessPrincipalOptionsProvider: AsyncComboboxOptionsProvider = .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 + [...preferredOptions, ...trustedAppOptions, ...options], + option => { + const targetOption = option as AccessTargetOption + return typeof targetOption.value === 'string' + ? `${targetOption.value}:${targetOption.targetKind ?? 'default'}` + : undefined + } ) }) diff --git a/src/components/access-control-modal/applyAccessChanges.ts b/src/components/access-control-modal/applyAccessChanges.ts index 084514195..685303ba2 100644 --- a/src/components/access-control-modal/applyAccessChanges.ts +++ b/src/components/access-control-modal/applyAccessChanges.ts @@ -1,19 +1,28 @@ import { PUBLIC_ACCESS_ROLES, Public, solidLogicSingleton } from 'solid-logic' -import type { AccessChange, AccessSaveError } from './types' +import type { AccessChange, AccessSaveError, AccessSaveScope } from './types' export async function applyAccessChanges ( subjectUri: string, - accessChanges: AccessChange[] + accessChanges: AccessChange[], + options: { containerScope?: AccessSaveScope } = {} ): Promise { const errors: AccessSaveError[] = [] + const scope = options.containerScope ?? 'file' for (const { subject, label, role } of accessChanges) { try { + const grantPlanner = scope === 'folder+contents' + ? solidLogicSingleton.acl.planGrantContainerAndContents + : scope === 'container' + ? solidLogicSingleton.acl.planGrantContainer + : scope === 'contents' + ? solidLogicSingleton.acl.planGrantContainerContents + : solidLogicSingleton.acl.planGrant 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 (scope === 'contents' + ? solidLogicSingleton.acl.planRevokeContainerContents(subjectUri, subject) + : solidLogicSingleton.acl.planRevoke(subjectUri, subject)) + : await grantPlanner(subjectUri, subject, getModes(subject, role)) await solidLogicSingleton.acl.applyPlan(plan) } catch (error) { @@ -22,6 +31,7 @@ export async function applyAccessChanges ( targetUri: subject.iri, label, role, + scope, message: error instanceof Error ? error.message : 'Unable to apply access change.' @@ -31,3 +41,15 @@ export async function applyAccessChanges ( return errors } + +function getModes (subject: AccessChange['subject'], role: AccessChange['role']) { + if (subject.type === 'agentClass' && subject.iri === Public.iri) { + const publicRole = PUBLIC_ACCESS_ROLES.find(candidate => candidate === role) + if (!publicRole) { + throw new Error(`Public access does not support the ${role} role.`) + } + return solidLogicSingleton.acl.modesFromPublicRole(publicRole) + } + + return solidLogicSingleton.acl.modesFromRole(role) +} diff --git a/src/components/access-control-modal/types.ts b/src/components/access-control-modal/types.ts index 43b376834..def7c1835 100644 --- a/src/components/access-control-modal/types.ts +++ b/src/components/access-control-modal/types.ts @@ -4,6 +4,7 @@ export type AccessAuthorization = Omit< Pick, 'mode' > & { + id?: string mode: readonly AccessMode[] } @@ -14,6 +15,7 @@ export type TargetInputDetail = { export type TargetSelectedDetail = { value: string label: string + targetKind?: 'origin' | 'trustedApp' } export type RoleChangedDetail = { @@ -41,6 +43,7 @@ export type PendingAccessGrant = { subjectValue: string role: AccessRole label: string + targetKind?: 'origin' | 'trustedApp' } export type AccessGrantEntry = { @@ -74,6 +77,8 @@ export type AccessChange = { label: string } +export type AccessSaveScope = 'folder+contents' | 'container' | 'contents' | 'file' + export type InputAccessControlError = { action: 'input' targetUri: string @@ -86,9 +91,10 @@ export type AccessSaveError = { targetUri: string label: string role: AccessRole + scope: AccessSaveScope message: string } | { - action: 'save' + action: 'save' | 'reset' targetUri: string label: string message: string diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index 864e79468..3793e4bdc 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -154,6 +154,7 @@ export default class Combobox extends FormControlComponent { aria-required=${this.required ? 'true' : nothing} autocomplete="off" spellcheck="false" + ?disabled=${this.disabled} ?required=${this.required} .value=${this.displayValue} @keydown=${this.onInputKeyDown} diff --git a/src/lib/context/noopContext.ts b/src/lib/context/noopContext.ts index 19083a4ba..dd9e04eba 100644 --- a/src/lib/context/noopContext.ts +++ b/src/lib/context/noopContext.ts @@ -1,6 +1,6 @@ export function noopContext (name: string): T { const fail = () => { throw new Error(`Context '${name}' is missing. Is the component inside its provider?`) } - + return new Proxy({}, { get: fail, set: fail, diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 3e365ef8d..a02339162 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -6,11 +6,17 @@ import type { AccessMode } from 'solid-logic' import { isAbsoluteHttpUri, isOriginUrl } from '../../src/components/access-control-modal/helpers' const planGrant = vi.fn() +const planGrantContainer = vi.fn() +const planGrantContainerContents = vi.fn() +const planGrantContainerAndContents = vi.fn() const planRevoke = vi.fn() const planPublicRead = vi.fn() const applyPlan = vi.fn() const classifyAccessControlSubject = vi.fn() const findAccessGrants = vi.fn() +const findEffectiveAcl = vi.fn() +const resetToDefault = vi.fn() +const isProtectedResource = vi.fn() const load = vi.fn() vi.mock('@/components/combobox', () => ({ @@ -30,10 +36,16 @@ vi.mock('solid-logic', async (importOriginal) => { ...actual.solidLogicSingleton.acl, classifyAccessControlSubject, planGrant, + planGrantContainer, + planGrantContainerContents, + planGrantContainerAndContents, planPublicRead, planRevoke, applyPlan, - findAccessGrants + findAccessGrants, + findEffectiveAcl, + resetToDefault, + isProtectedResource }, store: { ...actual.solidLogicSingleton.store, @@ -53,20 +65,32 @@ vi.mock('solid-logic', async (importOriginal) => { describe('AccessControlModal submit', () => { beforeEach(async () => { planGrant.mockReset() + planGrantContainer.mockReset() + planGrantContainerContents.mockReset() + planGrantContainerAndContents.mockReset() planRevoke.mockReset() planPublicRead.mockReset() applyPlan.mockReset() classifyAccessControlSubject.mockReset() findAccessGrants.mockReset() + findEffectiveAcl.mockReset() + resetToDefault.mockReset() + isProtectedResource.mockReset() load.mockReset() classifyAccessControlSubject.mockImplementation(async (principal: string) => ({ kind: 'agent' as const, subjectValue: principal })) planGrant.mockResolvedValue({ target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }) + planGrantContainer.mockResolvedValue({ target: 'https://example.com/container/.acl', deletes: [], inserts: [] }) + planGrantContainerContents.mockResolvedValue({ target: 'https://example.com/container/.acl', deletes: [], inserts: [] }) + planGrantContainerAndContents.mockResolvedValue({ target: 'https://example.com/container/.acl', deletes: [], inserts: [] }) planPublicRead.mockResolvedValue({ target: 'https://example.com/resource.ttl.acl', deletes: [], inserts: [] }) applyPlan.mockResolvedValue(new Response('ok', { status: 200 })) findAccessGrants.mockResolvedValue([]) + findEffectiveAcl.mockResolvedValue({ inherited: true, authorizations: [] }) + resetToDefault.mockResolvedValue(new Response(null, { status: 204 })) + isProtectedResource.mockReturnValue(false) load.mockResolvedValue(undefined) await import('../../src/components/access-control-modal/AccessControlModal') await import('../../src/components/access-control-modal/AccessControlErrors') @@ -787,7 +811,7 @@ describe('AccessControlModal submit', () => { it('uses the default share title when no subject uri is present', async () => { const element = document.createElement('solid-ui-access-control-modal') as any - expect(element.getDialogTitle()).toBe('Share this resource') + expect(element.getDialogTitle()).toBe('Manage Access of "this resource"') }) it('hides no access in the add selector, keeps it in the authenticated selector, and exposes dokieli public roles', async () => { @@ -969,6 +993,78 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + it('refreshes labels for a filtered container grant', async () => { + const folder = 'https://example.com/folder/' + const authorization = { + id: `${folder}.acl#group`, + agent: [], + agentGroup: ['https://example.com/groups/team'], + agentClass: [], + origin: [], + mode: ['Read'], + accessTo: [folder], + default: [folder] + } + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = folder + element.accessContext = { resource: folder, inherited: false, authorizations: [authorization] } + element.accessGrants = [authorization] + document.body.appendChild(element) + await element.updateComplete + element.accessGrantDrafts = [{ + id: authorization.id, + authorization, + role: 'Viewer', + initialRole: 'Viewer', + subjectLabel: 'stale label', + subjects: [{ + type: 'agentGroup', + iri: authorization.agentGroup[0] + }] + }] + + await element.refreshAccessGrantLabels() + + expect(element.accessGrantDrafts[0].subjectLabel).not.toBe('stale label') + document.body.removeChild(element) + }) + + it('ignores an access context that resolves after the resource changes', async () => { + let resolveFirst!: (context: any) => void + let resolveSecond!: (context: any) => void + findEffectiveAcl + .mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve })) + .mockImplementationOnce(() => new Promise(resolve => { resolveSecond = resolve })) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/first.ttl' + const firstLoad = element.loadAccessContext() + element.subjectUri = 'https://example.com/second.ttl' + const secondLoad = element.loadAccessContext() + + resolveSecond({ resource: 'https://example.com/second.ttl', inherited: true, authorizations: [] }) + await secondLoad + resolveFirst({ resource: 'https://example.com/first.ttl', inherited: false, authorizations: [] }) + await firstLoad + + expect(element.accessContext.resource).toBe('https://example.com/second.ttl') + }) + + it('replaces a duplicate pending principal with its latest role', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/resource.ttl' + + await element.queuePendingPrincipal('https://alice.example.com/profile/card.ttl#me', 'Viewer') + await element.queuePendingPrincipal('https://alice.example.com/profile/card.ttl#me', 'Editor') + + expect(element.pendingAccessGrants).toEqual([ + expect.objectContaining({ + subjectValue: 'https://alice.example.com/profile/card.ttl#me', + role: 'Editor' + }) + ]) + }) + 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' @@ -1066,4 +1162,503 @@ describe('AccessControlModal submit', () => { document.body.removeChild(element) }) + + it('saves a file directly without an apply-to-contents prompt', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + + await element.onSaveClick() + + expect(planGrant).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + }) + + it('uses the common container-and-contents scope by default', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + + await element.onSaveClick() + + expect(planGrant).not.toHaveBeenCalled() + expect(planGrantContainer).not.toHaveBeenCalled() + expect(planGrantContainerContents).not.toHaveBeenCalled() + expect(planGrantContainerAndContents).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + }) + + it('uses the advanced folder-access scope when selected', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + + element.advancedSharingOpen = true + element.containerScope = 'folder' + await element.onSaveClick() + + expect(planGrantContainer).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + expect(planGrantContainerContents).not.toHaveBeenCalled() + }) + + it('uses the advanced contents-defaults scope when selected', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + + element.advancedSharingOpen = true + element.containerScope = 'contents' + await element.onSaveClick() + + expect(planGrantContainerContents).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + expect(planGrantContainer).not.toHaveBeenCalled() + }) + + it('offers advanced folder-versus-contents sharing for containers', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.accessContext = { inherited: true, effectiveContainer: 'https://example.com/' } + document.body.appendChild(element) + await element.updateComplete + + element.advancedSharingOpen = true + await element.updateComplete + expect(element.shadowRoot.textContent).toContain('different access requirements') + expect(element.shadowRoot.textContent).toContain('Folder Only') + expect(element.shadowRoot.textContent).toContain('Contents Only') + + document.body.removeChild(element) + }) + + it('defaults containers to folder-and-contents without a scope summary', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + document.body.appendChild(element) + await element.updateComplete + + expect(element.containerScope).toBe('folder+contents') + expect(element.shadowRoot.querySelector('.access-control-scope-summary')).toBeNull() + + document.body.removeChild(element) + }) + + it.each([ + ['folder+contents', ['https://example.com/both#me']], + ['folder', ['https://example.com/folder#me']], + ['contents', ['https://example.com/contents#me']] + ] as const)('shows only %s authorizations in Shared with', async (scope, expectedSubjects) => { + const folder = 'https://example.com/folder/' + const grants = [ + { agent: ['https://example.com/folder#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [folder], default: [] }, + { agent: ['https://example.com/contents#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [], default: [folder] }, + { agent: ['https://example.com/both#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [folder], default: [folder] } + ] + findEffectiveAcl.mockResolvedValue({ + resource: folder, + inherited: false, + effectiveContainer: folder, + authorizations: grants + }) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = folder + element.accessGrants = grants + document.body.appendChild(element) + await element.updateComplete + await Promise.resolve() + await element.updateComplete + element.selectContainerScope(scope) + await element.updateComplete + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') as any + await sharedAccess.updateComplete + + expect(sharedAccess.accessGrantViewModels.map((entry: any) => entry.authorization.agent[0])) + .toEqual(expectedSubjects) + + document.body.removeChild(element) + }) + + it('recomputes Shared with entries immediately when Advanced changes scope', async () => { + const folder = 'https://example.com/folder/' + const grants = [ + { agent: ['https://example.com/folder#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [folder], default: [] }, + { agent: ['https://example.com/contents#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [], default: [folder] }, + { agent: ['https://example.com/both#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] as AccessMode[], accessTo: [folder], default: [folder] } + ] + findEffectiveAcl.mockResolvedValue({ resource: folder, inherited: false, effectiveContainer: folder, authorizations: grants }) + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = folder + element.accessGrants = grants + element.advancedSharingOpen = true + document.body.appendChild(element) + await element.updateComplete + const sharedAccess = () => element.shadowRoot.querySelector('solid-ui-access-control-shared-access') as any + await sharedAccess().updateComplete + expect(sharedAccess().accessGrantViewModels).toHaveLength(1) + + const contentsOnly = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: HTMLElement) => button.textContent?.includes('Contents Only')) as HTMLElement + contentsOnly.click() + await element.updateComplete + await sharedAccess().updateComplete + expect(sharedAccess().accessGrantViewModels).toHaveLength(1) + expect(sharedAccess().accessGrantViewModels[0].authorization.agent).toEqual(['https://example.com/contents#me']) + + document.body.removeChild(element) + }) + + it('updates the summary and save planner when a scope card is selected', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + element.pendingAccessGrants = [{ + subjectType: 'agent', + subjectValue: 'https://example.com/alice#me', + role: 'Viewer', + label: 'Alice' + }] + document.body.appendChild(element) + await element.updateComplete + + element.advancedSharingOpen = true + await element.updateComplete + const folderOnly = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: HTMLElement) => button.textContent?.includes('Folder Only')) as HTMLElement + folderOnly.click() + await element.updateComplete + + await element.onSaveClick() + expect(planGrantContainer).toHaveBeenCalledWith( + element.subjectUri, + { type: 'agent', iri: 'https://example.com/alice#me' }, + ['Read'] + ) + + document.body.removeChild(element) + }) + + it('supports the same-access toggle in advanced mode', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + document.body.appendChild(element) + await element.updateComplete + + element.advancedSharingOpen = true + await element.updateComplete + const toggle = element.shadowRoot.querySelector('.access-control-same-access-toggle') as HTMLButtonElement + expect(toggle.getAttribute('aria-checked')).toBe('true') + toggle.click() + await element.updateComplete + + expect(element.containerScope).toBe('folder') + document.body.removeChild(element) + }) + + it('closes advanced sharing with an accessible ghost X button', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + document.body.appendChild(element) + await element.updateComplete + + element.advancedSharingOpen = true + await element.updateComplete + const closeButton = element.shadowRoot.querySelector('.access-control-advanced-sharing-actions solid-ui-button') as HTMLElement + expect(closeButton?.getAttribute('variant')).toBe('ghost') + expect(closeButton?.getAttribute('aria-label')).toBe('Close advanced sharing') + expect(closeButton?.getAttribute('title')).toBe('Close advanced sharing') + expect(closeButton?.querySelector('icon-lucide-x')).not.toBeNull() + expect(closeButton?.querySelector('.sr-only')?.textContent).toBe('Close') + + closeButton.click() + await element.updateComplete + expect(element.shadowRoot.querySelector('.access-control-advanced-sharing')).toBeNull() + + document.body.removeChild(element) + }) + + it('places sharing details inside Advanced', async () => { + const inherited = document.createElement('solid-ui-access-control-modal') as any + inherited.subjectUri = 'https://example.com/inherited-folder/' + inherited.accessContext = { + inherited: true, + effectiveContainer: 'https://example.com/' + } + document.body.appendChild(inherited) + await inherited.updateComplete + + inherited.advancedSharingOpen = true + await inherited.updateComplete + expect(inherited.shadowRoot.querySelector('.access-control-advanced-sharing')).not.toBeNull() + document.body.removeChild(inherited) + + const custom = document.createElement('solid-ui-access-control-modal') as any + custom.subjectUri = 'https://example.com/custom-folder/' + custom.accessContext = { + inherited: false, + effectiveContainer: 'https://example.com/', + authorizations: [] + } + document.body.appendChild(custom) + await custom.updateComplete + + custom.advancedSharingOpen = true + await custom.updateComplete + const advancedActions = custom.shadowRoot.querySelector('.access-control-advanced-sharing-actions') + expect(advancedActions).not.toBeNull() + document.body.removeChild(custom) + }) + + it('hides Advanced for files and protected containers without offering reset controls', async () => { + const file = document.createElement('solid-ui-access-control-modal') as any + file.subjectUri = 'https://example.com/file.ttl' + file.accessContext = { inherited: true, effectiveContainer: 'https://example.com/' } + document.body.appendChild(file) + await file.updateComplete + expect(file.shadowRoot.querySelector('.access-control-sharing-state')).toBeNull() + expect(file.shadowRoot.querySelector('solid-ui-button')?.textContent ?? '').not.toContain('Advanced') + document.body.removeChild(file) + + const protectedContainer = document.createElement('solid-ui-access-control-modal') as any + protectedContainer.subjectUri = 'https://example.com/protected/' + protectedContainer.accessContext = { inherited: true, effectiveContainer: 'https://example.com/' } + document.body.appendChild(protectedContainer) + await protectedContainer.updateComplete + protectedContainer.protectedTarget = true + await protectedContainer.updateComplete + expect(protectedContainer.shadowRoot.querySelector('.access-control-sharing-state')).toBeNull() + expect(protectedContainer.shadowRoot.querySelector('solid-ui-button')?.textContent ?? '').not.toContain('Advanced') + expect(protectedContainer.shadowRoot.querySelector('[aria-label="Reset custom sharing to default"]')).toBeNull() + document.body.removeChild(protectedContainer) + }) + + it('keeps resource sharing details inside Advanced', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/file.ttl' + element.accessContext = { + inherited: false, + effectiveContainer: 'https://example.com/folder/', + authorizations: [] + } + document.body.appendChild(element) + await element.updateComplete + element.advancedSharingOpen = true + await element.updateComplete + + const generalAccess = element.shadowRoot.querySelector('solid-ui-access-control-general-access') + const customSharing = element.shadowRoot.querySelector('.access-control-sharing-state') + expect(generalAccess).not.toBeNull() + expect(customSharing).not.toBeNull() + expect(generalAccess.compareDocumentPosition(customSharing) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(element.shadowRoot.querySelector('.access-control-advanced-sharing')).not.toBeNull() + const panel = element.shadowRoot.querySelector('.access-control-sharing-state-panel') + expect(panel).not.toBeNull() + expect(panel?.querySelector('p')).not.toBeNull() + document.body.removeChild(element) + }) + + it('shows inherited sharing details inside Advanced', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { + inherited: true, + effectiveContainer: 'https://example.com/folder/', + authorizations: [] + } + document.body.appendChild(element) + await element.updateComplete + element.advancedSharingOpen = true + await element.updateComplete + + const sharingState = element.shadowRoot.querySelector('.access-control-sharing-state') + expect(sharingState).not.toBeNull() + expect(sharingState?.querySelector('.access-control-advanced-sharing')).not.toBeNull() + expect(sharingState?.textContent).not.toContain('Reset to default') + document.body.removeChild(element) + }) + + it('keeps the sharing state inside Advanced when no inherited container is known', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { + inherited: false, + authorizations: [] + } + document.body.appendChild(element) + await element.updateComplete + element.advancedSharingOpen = true + await element.updateComplete + + const sharingState = element.shadowRoot.querySelector('.access-control-sharing-state') + expect(sharingState?.querySelector('.access-control-advanced-sharing')).not.toBeNull() + document.body.removeChild(element) + }) + + it('explains when an inherited resource has no default container', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { + inherited: true, + authorizations: [] + } + document.body.appendChild(element) + await element.updateComplete + element.advancedSharingOpen = true + await element.updateComplete + + const sharingState = element.shadowRoot.querySelector('.access-control-sharing-state') + expect(sharingState?.textContent).toContain('no inherited default is available') + expect(sharingState?.textContent).not.toContain('sharing from') + expect(sharingState?.textContent).not.toContain('Reset to default') + document.body.removeChild(element) + }) + + it('blocks edits and explains mixed container policies until Advanced is released', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/folder/' + findEffectiveAcl.mockResolvedValueOnce({ + resource: element.subjectUri, + inherited: false, + authorizations: [ + { + agent: ['https://example.com/folder-agent#me'], + agentGroup: [], + agentClass: [], + origin: [], + accessTo: [element.subjectUri], + default: [], + mode: ['Read'] + }, + { + agent: ['https://example.com/contents-agent#me'], + agentGroup: [], + agentClass: [], + origin: [], + accessTo: [], + default: [element.subjectUri], + mode: ['Read'] + } + ] + }) + document.body.appendChild(element) + await element.updateComplete + await Promise.resolve() + await element.updateComplete + + const warning = element.shadowRoot.querySelector('.access-control-mixed-policy-warning') + expect(warning?.getAttribute('role')).toBe('alert') + expect(warning?.textContent).toContain('different access settings for the folder and its contents') + expect(warning?.textContent).toContain('available in the next release') + expect(element.shadowRoot.querySelector('solid-ui-access-control-add-access')?.disabled).toBe(true) + expect(element.shadowRoot.querySelector('solid-ui-access-control-shared-access')?.disabled).toBe(true) + expect(element.shadowRoot.querySelector('solid-ui-access-control-general-access')?.disabled).toBe(true) + const saveButton = [...element.shadowRoot.querySelectorAll('solid-ui-button')] + .find((button: Element) => button.textContent?.includes('Save Changes')) as HTMLElement + expect(saveButton.hasAttribute('disabled')).toBe(true) + + await element.onSaveClick() + expect(planGrant).not.toHaveBeenCalled() + document.body.removeChild(element) + }) + + it('resets custom sharing and refreshes context and grants', async () => { + findEffectiveAcl + .mockResolvedValueOnce({ inherited: true, authorizations: [] }) + .mockResolvedValueOnce({ inherited: true, authorizations: [{ agent: ['https://example.com/owner#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] }] }) + findAccessGrants.mockResolvedValueOnce([{ agent: ['https://example.com/owner#me'], agentGroup: [], agentClass: [], origin: [], mode: ['Read'] }]) + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { inherited: false, effectiveContainer: 'https://example.com/folder/', authorizations: [] } + + await element.onResetToDefault() + + expect(resetToDefault).toHaveBeenCalledWith(element.subjectUri) + expect(findEffectiveAcl).toHaveBeenCalled() + expect(findAccessGrants).toHaveBeenCalledWith(element.subjectUri) + expect(element.accessContext.inherited).toBe(true) + expect(element.accessGrants[0].agent).toEqual(['https://example.com/owner#me']) + }) + + it('shows inherited effective authorizations when the resource has no direct ACL', async () => { + const inheritedAuthorization = { + agent: ['https://example.com/owner#me'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'] + } + findEffectiveAcl.mockResolvedValueOnce({ + inherited: true, + effectiveContainer: 'https://example.com/folder/', + authorizations: [inheritedAuthorization] + }) + + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + document.body.appendChild(element) + await element.updateComplete + await Promise.resolve() + await element.updateComplete + + expect(element.accessGrants).toEqual([inheritedAuthorization]) + const sharedAccess = element.shadowRoot.querySelector('solid-ui-access-control-shared-access') + await sharedAccess.updateComplete + expect(sharedAccess.shadowRoot.querySelectorAll('.access-grants-list h3')).toHaveLength(1) + document.body.removeChild(element) + }) + + it('reports reset errors without changing the custom state', async () => { + resetToDefault.mockRejectedValueOnce(new Error('reset failed')) + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { inherited: false, authorizations: [] } + + await element.onResetToDefault() + + expect(element.accessContext.inherited).toBe(false) + expect(element.saveErrors[0]).toEqual(expect.objectContaining({ action: 'reset', message: 'reset failed' })) + }) + + it('does not offer reset for an inherited resource', async () => { + const element = document.createElement('solid-ui-access-control-modal') as any + element.subjectUri = 'https://example.com/file.ttl' + element.accessContext = { inherited: true, effectiveContainer: 'https://example.com/folder/', authorizations: [] } + document.body.appendChild(element) + await element.updateComplete + + expect(element.shadowRoot.textContent).not.toContain('Reset to default') + document.body.removeChild(element) + }) }) diff --git a/test/components/access-control-modal/accessGrantDrafts.test.ts b/test/components/access-control-modal/accessGrantDrafts.test.ts index 11e839c62..a1d5a3d8f 100644 --- a/test/components/access-control-modal/accessGrantDrafts.test.ts +++ b/test/components/access-control-modal/accessGrantDrafts.test.ts @@ -5,7 +5,9 @@ import { getAccessGrantId, getAccessGrantIds, getChangedAccessGrants, - getSharedAuthorization + getSharedAuthorization, + getAuthenticatedAccessRole, + getPublicAccessRole } from '../../../src/components/access-control-modal/accessGrantDrafts' describe('access-control grant drafts', () => { @@ -58,6 +60,52 @@ describe('access-control grant drafts', () => { ]) }) + it('uses authorization ids instead of subject order for draft identity', () => { + const first: AccessAuthorization = { + id: 'https://example.com/.acl#authorization-1', + agent: ['https://alice.example/profile#me'], + agentGroup: [], + agentClass: [], + origin: [], + mode: ['Read'] as AccessMode[] + } + const second = { ...first, id: 'https://example.com/.acl#authorization-2', mode: ['Write'] as AccessMode[] } + + expect(getAccessGrantIds([second, first])).toEqual([second.id, first.id]) + }) + + it('aggregates modes from all matching general-access authorizations', () => { + const authorizations: AccessAuthorization[] = [ + { + id: 'auth-read', + agent: [], + agentGroup: [], + agentClass: [Authenticated.iri], + origin: [], + mode: ['Read'] as AccessMode[] + }, + { + id: 'auth-write', + agent: [], + agentGroup: [], + agentClass: [Authenticated.iri], + origin: [], + mode: ['Write'] as AccessMode[] + }, + { + id: 'public-read', + agent: [], + agentGroup: [], + agentClass: [Public.iri], + origin: [], + mode: ['Read'] as AccessMode[] + } + ] + + expect(getAuthenticatedAccessRole(authorizations)).toBe('Editor') + expect(getPublicAccessRole(authorizations)).toBe('Viewer') + }) + it('returns only changed drafts with real shared subjects', () => { const changed = { id: 'agent:https://alice.example/profile#me', diff --git a/test/components/access-control-modal/accessTargetResolver.test.ts b/test/components/access-control-modal/accessTargetResolver.test.ts index 12eb048b9..670b5fc9a 100644 --- a/test/components/access-control-modal/accessTargetResolver.test.ts +++ b/test/components/access-control-modal/accessTargetResolver.test.ts @@ -4,13 +4,25 @@ import { resolveAccessTarget } from '../../../src/components/access-control-modal/accessTargetResolver' import { solidLogicSingleton } from 'solid-logic' +import { sym } from 'rdflib' +import ns from '../../../src/lib/ns' 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' + label: 'https://example.test', + targetKind: 'origin' + }) + }) + + it('resolves a selected absolute origin without classifying it as an agent', async () => { + await expect(resolveAccessTarget('https://example.test/')).resolves.toEqual({ + subjectType: 'origin', + subjectValue: 'https://example.test', + label: 'https://example.test', + targetKind: 'origin' }) }) @@ -22,6 +34,47 @@ describe('access-control target resolver', () => { }]) }) + it('suggests only the current user’s trusted-app origins from the profile RDF', async () => { + const store = solidLogicSingleton.store + const me = sym('https://trusted-app-options.example/profile/card#me') + const profile = me.doc() + const trustedApp = sym('https://trusted-app-options.example/apps/trusted') + const invalidApp = sym('https://trusted-app-options.example/apps/invalid') + const otherUser = sym('https://other-user.example/profile/card#me') + const otherProfile = otherUser.doc() + const unrelatedApp = sym('https://other-user.example/apps/unrelated') + + store.add(me, ns.acl('trustedApp'), trustedApp, profile) + store.add(trustedApp, ns.acl('origin'), sym('https://trusted.example.test/'), profile) + store.add(me, ns.acl('trustedApp'), invalidApp, profile) + store.add(invalidApp, ns.acl('origin'), sym('https://invalid.example.test/app'), profile) + store.add(otherUser, ns.acl('trustedApp'), unrelatedApp, otherProfile) + store.add(unrelatedApp, ns.acl('origin'), sym('https://unrelated.example.test/'), otherProfile) + + const currentUserSpy = vi.spyOn(solidLogicSingleton.authn, 'currentUser').mockReturnValue(me) + const loadProfileSpy = vi.spyOn(solidLogicSingleton.profile, 'loadProfile').mockResolvedValue(profile) + const directorySearchSpy = vi.spyOn(solidLogicSingleton.directory, 'search').mockResolvedValue([]) + + try { + const options = await accessPrincipalOptionsProvider('trusted') + + expect(loadProfileSpy).toHaveBeenCalledWith(me) + expect(directorySearchSpy).toHaveBeenCalled() + expect(options).toHaveLength(1) + expect(options[0]).toEqual(expect.objectContaining({ + label: 'Trusted app: https://trusted.example.test', + value: 'https://trusted.example.test', + targetKind: 'trustedApp' + })) + } finally { + currentUserSpy.mockRestore() + loadProfileSpy.mockRestore() + directorySearchSpy.mockRestore() + store.removeMatches(null, null, null, profile) + store.removeMatches(null, null, null, otherProfile) + } + }) + it('preserves related document lookup failures for direct resolution', async () => { const classifySpy = vi.spyOn(solidLogicSingleton.acl, 'classifyAccessControlSubject') .mockResolvedValueOnce({ diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index fd1e69e82..33bf40ebb 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -29,6 +29,7 @@ describe('Index', () => { 'acl', 'aclControl', 'authContext', + 'composeExplorer', 'create', 'createTypes', 'customElement', @@ -36,10 +37,12 @@ describe('Index', () => { 'dom', 'fileExplorerContext', 'generateId', + 'getRelevantPanes', 'icons', 'infiniteMessageArea', 'initFooter', 'initHeader', + 'isExplorerEvent', 'language', 'log', 'login',