From bf9087396840d601dbe0055d90ea05275b35a15b Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 4 Oct 2026 19:28:45 +1100 Subject: [PATCH 01/14] add a color to theme css --- src/styles/theme.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/styles/theme.css b/src/styles/theme.css index 933f25f89..037b18856 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -15,6 +15,7 @@ --solid-ui-color-gray-100: #f3f4f6; --solid-ui-color-gray-200: #cbd5e1; + --solid-ui-color-gray-300: #d1d5dc; --solid-ui-color-gray-400: #99a1af; --solid-ui-color-gray-500: #6a7282; --solid-ui-color-gray-600: #4a5565; @@ -23,6 +24,7 @@ --solid-ui-color-gray-900: #101828; --solid-ui-color-slate-50: #f8fafc; + --solid-ui-color-gray-50: #f9fafb; --solid-ui-color-slate-200: #e2e8f0; --solid-ui-color-slate-400: #90a1b9; --solid-ui-color-slate-800: #1d293d; From 77aa7fcf362df125a76ce1608cbdab8a02062384 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 4 Oct 2026 19:31:51 +1100 Subject: [PATCH 02/14] srOnly uses . not ? --- src/components/combobox/Combobox.stories.ts | 6 +++--- src/components/input/Input.stories.ts | 2 +- src/components/select/Select.stories.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/combobox/Combobox.stories.ts b/src/components/combobox/Combobox.stories.ts index 6dac7daf1..f7049a73e 100644 --- a/src/components/combobox/Combobox.stories.ts +++ b/src/components/combobox/Combobox.stories.ts @@ -48,14 +48,14 @@ const meta = { }, render ({ label, options, asyncJSOptions, asyncHtmlOptions, srOnlyLabel }) { if (asyncJSOptions) { - return html`` + return html`` } if (asyncHtmlOptions) { return html` option.trim()) return html` - + ${parsedOptions.map((option, index) => { const indent = index === 0 ? '' : ' ' diff --git a/src/components/input/Input.stories.ts b/src/components/input/Input.stories.ts index 4ae45e176..1ec4149a2 100644 --- a/src/components/input/Input.stories.ts +++ b/src/components/input/Input.stories.ts @@ -27,7 +27,7 @@ const meta = { return html` option.trim()) return html` - + ${parsedOptions.map((option, index) => { const indent = index === 0 ? '' : ' ' From 43ee42da5e04dcfe02f053150d6746010bffdbad Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Sun, 4 Oct 2026 19:35:53 +1100 Subject: [PATCH 03/14] input and roles --- .../AccessControlModal.styles.css | 112 +++- .../AccessControlModal.ts | 558 ++++++++++++++---- src/components/access-control-modal/types.ts | 12 +- test/components/access-control-modal.test.ts | 191 +++++- 4 files changed, 751 insertions(+), 122 deletions(-) diff --git a/src/components/access-control-modal/AccessControlModal.styles.css b/src/components/access-control-modal/AccessControlModal.styles.css index c904a35ca..670d0571c 100644 --- a/src/components/access-control-modal/AccessControlModal.styles.css +++ b/src/components/access-control-modal/AccessControlModal.styles.css @@ -7,19 +7,93 @@ .access-grants-form { display: flex; + flex-direction: column; padding: 10px 10px; - flex-direction: row; align-items: flex-start; gap: 10px; align-self: stretch; + font-size: var(--solid-ui-font-size-sm, .875rem); --access-role-select-width: 129px; - solid-ui-input { - flex: 1 1 auto; + p { + color: var(--solid-ui-color-gray-600, #4A5565); + line-height: 1.5; + } + + .access-grants-form-main { + display: flex; + align-items: flex-start; + gap: 10px; + align-self: stretch; + + .access-grants-input { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + padding: 10px 15px; + gap: 12px; + align-items: flex-start; + border-radius: 5px; + border: 1px solid var(--solid-ui-color-slate-200, #E2E8F0); + background: var(--solid-ui-color-white, #FFF); + + solid-ui-combobox { + width: 100%; + /* color: var(--solid-ui-color-gray-300, #D1D5DC); it is so light you can't read*/ + + --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); + } + } + } + + .access-grants-pending { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 42px; + align-items: center; + } + + .access-grants-pending-item { + display: flex; + padding: 5px 10px; + align-items: center; + gap: 6px; + max-width: 100%; + border-radius: 5px; + border: 1px solid var(--solid-ui-color-gray-300, #D1D5DC); + background: var(--solid-ui-color-gray-50, #F9FAFB); + color: var(--solid-ui-color-gray-700, #374151); + } + + .access-grants-pending-item-label { min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--solid-ui-color-gray-500, #6A7282); + font-size: var(--solid-ui-font-size-xs, .75rem); + line-height: 1; + } + + .access-grants-pending-item-remove { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--solid-ui-color-gray-300, #D1D5DC); + cursor: pointer; - --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); + } + + .access-grants-pending-item-remove icon-lucide-circle-x { + width: 14px; + height: 14px; } } @@ -57,6 +131,10 @@ align-items: flex-start; gap: 15px; flex: 1 0 0; + min-height: 0; + box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; border-radius: 10px; border: 1px solid var(--solid-ui-color-gray-200, #E5E7EB); } @@ -92,6 +170,11 @@ } span { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; line-height: 1; } } @@ -139,7 +222,6 @@ } .access-grants-role--editable { - width: fit-content; max-width: 100%; } } @@ -235,6 +317,26 @@ margin-top: 25px; flex: 0 0 auto; width: var(--access-role-select-width); + --solid-ui-input-border-color: var(--solid-ui-color-slate-200, #E2E8F0); + } + + .access-role-select--top { + margin-top: 0; + } + + .access-role-select--compact { + width: 110px; + min-width: 110px; + flex: 0 0 110px; + max-width: 100%; + --solid-ui-input-border-color: transparent; + margin-top: 0; + } + + .access-grants-list li .access-role-select--compact { + width: 110px; + min-width: 110px; + flex: 0 0 110px; } .access-control-footer-actions { diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index c9c7d718c..15df10067 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -5,34 +5,26 @@ import { property, query, state } from 'lit/decorators.js' import { label } from '@/utils/label' import { findImage } from '@/widgets' import type Dialog from '@/components/dialog' -import { solidLogicSingleton } from 'solid-logic' -import type { AccessMode, Authorization, SubjectType } from 'solid-logic' -import type { AccessControlBadgeKind, AccessRole, DraftGrant } from './types' +import { ACCESS_ROLES, DEFAULT_DIRECTORY_SOURCES, solidLogicSingleton, type AccessMode, type AccessRole, type Authorization, type DirectoryEntry } from 'solid-logic' +import { defineAsyncComboboxOptionsProvider, type ComboboxChangeEvent, type ComboboxOptionData } from '@/components/combobox' import { sym } from 'rdflib' import '~icons/lucide/chevron-down' import '~icons/lucide/link' -import '~icons/lucide/search' import '~icons/lucide/globe' +import '~icons/lucide/book-user' +import '~icons/lucide/user-round' +import '~icons/lucide/users' +import '~icons/lucide/circle-x' import '@/components/dialog' import '@/components/dialog-content' import '@/components/dialog-footer' import '@/components/button' -import '@/components/input' import '@/components/combobox' import '@/components/combobox-option' - import styles from './AccessControlModal.styles.css' - - -const ACCESS_ROLE_RULES = [ - { modes: ['Control'], label: 'Owner' }, - { modes: ['Write'], label: 'Editor' }, - { modes: ['Append', 'Read'], label: 'Poster' }, - { modes: ['Append'], label: 'Submitter' }, - { modes: ['Read'], label: 'Viewer' } -] as const +import type { AccessControlBadgeKind, PendingAccessGrant } from './types' @customElement('solid-ui-access-control-modal') export default class AccessControlModal extends WebComponent { @@ -45,10 +37,13 @@ export default class AccessControlModal extends WebComponent { accessor accessGrants: Authorization[] | undefined = undefined @state() - private accessor principleInputValue: string = '' + private accessor principalInputValue: string = '' @state() - private accessor roleValue: string = 'Viewer' + private accessor addAccessRoleValue: AccessRole = 'Viewer' + + @state() + private accessor sharedAccessRoleValue: AccessRole = 'No Access' @state() private accessor searchValue: string = '' @@ -59,29 +54,85 @@ export default class AccessControlModal extends WebComponent { @state() private accessor submitting: boolean = false + @state() + private accessor pendingAccessGrants: PendingAccessGrant[] = [] + @state() private accessor accessGrantRoles: AccessRole[] = [] + @state() + private accessor accessGrantLabels: string[] = [] + @query('solid-ui-dialog') private accessor dialog: Dialog | null = null - connectedCallback () { - super.connectedCallback() - } - protected willUpdate (changedProperties: PropertyValues) { super.willUpdate(changedProperties) if (changedProperties.has('accessGrants')) { this.accessGrantRoles = this.accessGrants?.map(item => this.getAuthorizationRole(item)) ?? [] + void this.refreshAccessGrantLabels() } } + private async refreshAccessGrantLabels () { + const grants = this.accessGrants ?? [] + if (!grants.length) { + this.accessGrantLabels = [] + return + } + + const labels = await Promise.all(grants.map(async authorization => { + const groupUri = authorization.agentGroup[0] + if (groupUri) { + try { + await solidLogicSingleton.store.fetcher.load(sym(groupUri).doc()) + } catch { + // Keep the fallback label if the group document cannot be loaded. + } + } + + return this.getAuthorizationSubjectLabel(authorization) + })) + + if (this.accessGrants === grants) { + this.accessGrantLabels = labels + } + } + + private getAccessGrantEntries () { + return (this.accessGrants ?? []) + .map((authorization, index) => ({ + authorization, + index, + role: this.accessGrantRoles[index] ?? this.getAuthorizationRole(authorization), + subjectLabel: this.accessGrantLabels[index] ?? this.getAuthorizationSubjectLabel(authorization) + })) + .sort((left, right) => { + const leftIsOwner = left.role === 'Owner' + const rightIsOwner = right.role === 'Owner' + + if (leftIsOwner && !rightIsOwner) return -1 + if (!leftIsOwner && rightIsOwner) return 1 + return left.index - right.index + }) + } + private renderAccessGrants() { + const query = this.searchValue.trim().toLowerCase() + const accessGrants = this.getAccessGrantEntries().filter(({ subjectLabel }) => { + if (!query) { + return true + } + + return subjectLabel.toLowerCase().includes(query) + }) + return html`
    - ${!this.accessGrants || this.accessGrants.length === 0 ? html`
  • No access grants
  • ` : nothing} - ${this.accessGrants?.map((item, index) => this.renderAccessGrant(item, index))} + ${accessGrants.length > 0 + ? accessGrants.map(({ authorization, index }) => this.renderAccessGrant(authorization, index)) + : html`
  • No access grants
  • `}
` } @@ -89,11 +140,12 @@ export default class AccessControlModal extends WebComponent { private renderAccessGrant (authorization: Authorization, index: number) { const badge = this.getAuthorizationBadge(authorization) const role = this.accessGrantRoles[index] ?? this.getAuthorizationRole(authorization) + const subjectLabel = this.accessGrantLabels[index] ?? this.getAuthorizationSubjectLabel(authorization) return html`
  • ${this.renderAuthorizationBadge(badge)} -

    ${this.renderAuthorizationSubjects(authorization)}

    +

    ${subjectLabel}

    ${this.renderAuthorizationRole(role, index)}
  • ` @@ -106,7 +158,7 @@ export default class AccessControlModal extends WebComponent { return { kind: 'agent' as const, image, - text: image ? '' : this.getInitials(this.renderAuthorizationSubjects(authorization), 2) + text: image ? '' : this.getInitials(this.getAuthorizationSubjectLabel(authorization), 2) } } @@ -114,7 +166,7 @@ export default class AccessControlModal extends WebComponent { if (agentGroup) { return { kind: 'group' as const, - text: this.getInitials(this.renderAuthorizationSubjects(authorization), 1) + text: this.getInitials(this.getAuthorizationSubjectLabel(authorization), 1) } } @@ -124,7 +176,7 @@ export default class AccessControlModal extends WebComponent { return { kind: 'agentClass' as const, image, - text: image ? '' : this.getInitials(this.renderAuthorizationSubjects(authorization), 2) + text: image ? '' : this.getInitials(this.getAuthorizationSubjectLabel(authorization), 2) } } @@ -156,22 +208,35 @@ export default class AccessControlModal extends WebComponent { return (initials || label.slice(0, maxWords)).toUpperCase() } - private renderAuthorizationSubjects (authorization: Authorization): string { - const subjects = [ + private getAuthorizationSubjects (authorization: Authorization) { + return [ ...authorization.agent, ...authorization.agentGroup, ...authorization.agentClass, ...authorization.origin ] + } + private getAuthorizationSubjectLabel (authorization: Authorization): string { + const subjects = this.getAuthorizationSubjects(authorization) return subjects.length ? subjects.map(subject => label(sym(subject))).join(', ') : 'Unknown access holder' } private getAuthorizationRole (authorization: Authorization): AccessRole { - const modes = new Set(authorization.mode) - const matchingRule = ACCESS_ROLE_RULES.find(rule => rule.modes.every(mode => modes.has(mode))) + return solidLogicSingleton.acl.roleFromModes(authorization.mode) + } - return matchingRule?.label ?? 'Viewer' + private getRoleValueFromEvent (event: Event, fallback: AccessRole = 'Viewer'): AccessRole { + const customEvent = event as ComboboxChangeEvent + const selectedValue = customEvent.detail?.option?.value + + if (typeof selectedValue === 'string') { + return selectedValue as AccessRole + } + + const target = event.currentTarget as { value?: string | null } | null + + return typeof target?.value === 'string' ? target.value as AccessRole : fallback } private renderAuthorizationRole (role: AccessRole, index: number) { @@ -181,66 +246,122 @@ export default class AccessControlModal extends WebComponent { return html` this.onAccessGrantRoleInput(index, event)} + @change=${(event: Event) => this.onAccessGrantRoleInput(index, event)} > - Editor - Viewer - Poster - Submitter - Remove + ${this.renderGrantRoleOptions()} ` } - private renderModeSelector () { + private renderModeSelector (variant: 'add' | 'general' = 'add') { + const className = variant === 'add' + ? 'access-role-select access-role-select--top' + : 'access-role-select access-role-select--compact' + const value = variant === 'add' ? this.addAccessRoleValue : this.sharedAccessRoleValue return html` - Owner - Editor - Viewer - Poster - Submitter - Remove + ${variant === 'add' ? this.renderAddRoleOptions() : this.renderGeneralRoleOptions()} ` } + private renderAddRoleOptions () { + return ACCESS_ROLES + .filter(role => role !== 'No Access') + .map(role => html` + ${role} + `) + } + + private renderGeneralRoleOptions () { + return ACCESS_ROLES.map(role => html` + ${role} + `) + } + + private renderGrantRoleOptions () { + return ACCESS_ROLES.map(role => html` + ${role === 'No Access' ? 'Remove' : role} + `) + } + private renderAddAccessForm () { return html`
    - - ${this.renderModeSelector()} +

    Add person, group or software agent URL.

    +
    +
    + + ${this.renderPendingAccessGrants()} +
    + ${this.renderModeSelector('add')} +
    +
    + ` + } + + private renderPendingAccessGrants () { + if (!this.pendingAccessGrants.length) { + return nothing + } + + return html` +
    + ${this.pendingAccessGrants.map((grant, index) => html` +
    + ${grant.label} + this.removePendingAccessGrant(index)} + > + Remove ${grant.label} + + +
    + `)}
    ` } private renderAccessGrantsSection () { + const accessGrantOptions = this.getAccessGrantSearchOptions() + return html`

    Share with

    - - - + ${accessGrantOptions.map(option => html` + + ${option.label} + + `)} +
    ${this.renderAccessGrants()} @@ -270,7 +391,7 @@ export default class AccessControlModal extends WebComponent {
    - ${this.renderModeSelector()} + ${this.renderModeSelector('general')} ` @@ -287,32 +408,18 @@ export default class AccessControlModal extends WebComponent { } private getRoleModes (role: AccessRole): AccessMode[] { - return [...(ACCESS_ROLE_RULES.find(rule => rule.label === role)?.modes ?? [])] - } - - private parsePrincipleInput (): DraftGrant[] | undefined { - const value = this.principleInputValue.trim() - if (!value) return undefined - - const principleList = value.split(',').map(item => item.trim()).filter(item => item) - if (!principleList.length) return undefined - - return principleList.map((principle): DraftGrant => { - const subjectType: SubjectType = solidLogicSingleton.resource.isWebId(principle) ? 'agent' : 'agentGroup' - - return { - subjectType, - subjectValue: principle, - role: this.roleValue as AccessRole - } - }) + return solidLogicSingleton.acl.modesFromRole(role) } private getDialogTitle (): string { const subject = this.subjectUri ? sym(this.subjectUri) : undefined - const subjectLabel = subject ? label(subject) : '' + const subjectLabel = subject ? label(subject).trim() : '' - return `Share ${subjectLabel || 'this resource'}` + if (!subjectLabel || subjectLabel === 'this resource') { + return 'Share this resource' + } + + return `Share "${subjectLabel}"` } protected render () { @@ -331,14 +438,15 @@ export default class AccessControlModal extends WebComponent {