From c489bdea616606c59d893f920a2a0bf574e680c9 Mon Sep 17 00:00:00 2001 From: Usman Khalid Date: Fri, 5 Jun 2026 13:03:45 -0400 Subject: [PATCH 1/3] feat: add DA permissions management app --- tools/apps/da-permissions/api.js | 57 ++ tools/apps/da-permissions/da-permissions.css | 876 ++++++++++++++++++ tools/apps/da-permissions/da-permissions.html | 19 + tools/apps/da-permissions/da-permissions.js | 872 +++++++++++++++++ tools/apps/da-permissions/icons.js | 10 + .../img/S2_Icon_Checkmark_20_N.svg | 5 + .../da-permissions/img/S2_Icon_Close_20_N.svg | 5 + 7 files changed, 1844 insertions(+) create mode 100644 tools/apps/da-permissions/api.js create mode 100644 tools/apps/da-permissions/da-permissions.css create mode 100644 tools/apps/da-permissions/da-permissions.html create mode 100644 tools/apps/da-permissions/da-permissions.js create mode 100644 tools/apps/da-permissions/icons.js create mode 100644 tools/apps/da-permissions/img/S2_Icon_Checkmark_20_N.svg create mode 100644 tools/apps/da-permissions/img/S2_Icon_Close_20_N.svg diff --git a/tools/apps/da-permissions/api.js b/tools/apps/da-permissions/api.js new file mode 100644 index 0000000..54d6f29 --- /dev/null +++ b/tools/apps/da-permissions/api.js @@ -0,0 +1,57 @@ +/* eslint-disable import/no-unresolved, no-console */ + +const { getDaAdmin } = await import('https://da.live/nx/public/utils/constants.js'); +const DA_ADMIN = getDaAdmin(); + +// daFetch ensures a fresh IMS token is used on every request (handles token expiry) +const { daFetch } = await import('https://da.live/nx/utils/daFetch.js'); + +export async function fetchOrgConfig(org) { + try { + const response = await daFetch(`${DA_ADMIN}/config/${org}/`); + const unauthorized = response.status === 403 || response.status === 401; + if (unauthorized) return { canAccess: false, config: null }; + if (!response.ok) return { canAccess: false, config: null }; + const config = await response.json(); + return { canAccess: true, config }; + } catch { + return { canAccess: false, config: null }; + } +} + +export async function updateOrgConfig(org, config) { + try { + const formData = new FormData(); + formData.append('config', JSON.stringify(config)); + const response = await daFetch(`${DA_ADMIN}/config/${org}/`, { + method: 'POST', + body: formData, + }); + return { success: response.ok, error: response.ok ? null : `HTTP ${response.status}` }; + } catch (error) { + return { success: false, error: error.message }; + } +} + +export async function listFolders(org, site, path = '/') { + try { + const response = await daFetch(`${DA_ADMIN}/list/${org}/${site}${path}`); + if (!response.ok) return []; + const items = await response.json(); + return Array.isArray(items) ? items.filter((item) => !item.ext) : []; + } catch { + return []; + } +} + +export async function fetchSiteList(org) { + try { + const response = await daFetch(`${DA_ADMIN}/list/${org}/`); + if (!response.ok) return []; + const items = await response.json(); + if (!Array.isArray(items)) return []; + return items.filter((item) => !item.ext).map((item) => item.name); + } catch { + return []; + } +} diff --git a/tools/apps/da-permissions/da-permissions.css b/tools/apps/da-permissions/da-permissions.css new file mode 100644 index 0000000..ac7ed2e --- /dev/null +++ b/tools/apps/da-permissions/da-permissions.css @@ -0,0 +1,876 @@ +:host { + --pw-accent: var(--s2-blue-900); + --pw-accent-hover: var(--s2-blue-1000); + + display: block; + max-width: var(--grid-container-width); + margin: var(--spacing-800) auto; + -webkit-font-smoothing: antialiased; + font-family: var(--body-font-family); + color: var(--s2-gray-800); +} + +/* + * sl-button hosts must NOT use nexter class names (.accent, .negative, .action): + * da.live/nx/styles/buttons.css targets those classes and paints the sl-button + * host while ::part(base) styles the inner button — causing a nested "double pill". + */ +sl-button.pw-fill-accent, +sl-button.pw-quiet-secondary, +sl-button.pw-quiet-danger { + background: transparent; + border: none; + padding: 0; + vertical-align: middle; +} + +sl-button.pw-fill-accent::part(base) { + background: var(--pw-accent); + border-color: var(--pw-accent); + color: var(--s2-gray-75); +} + +sl-button.pw-fill-accent::part(base):disabled { + opacity: 0.4; + cursor: not-allowed; +} + +sl-button.pw-fill-accent::part(base):hover:not(:disabled) { + background: var(--pw-accent-hover); + border-color: var(--pw-accent-hover); +} + +sl-button.pw-quiet-secondary::part(base) { + background: transparent; + border: 1px solid var(--s2-gray-300); + color: var(--s2-gray-800); +} + +sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { + background: var(--s2-gray-75); + border-color: var(--s2-gray-400); +} + +sl-button.pw-quiet-danger::part(base) { + background: var(--s2-red-800); + border-color: var(--s2-red-800); + color: #fff; +} + +sl-button.pw-quiet-danger::part(base):disabled { + opacity: 0.4; + cursor: not-allowed; +} + +sl-button.pw-quiet-danger::part(base):hover:not(:disabled) { + background: var(--s2-red-900); + border-color: var(--s2-red-900); +} + +.pw-action-sm::part(base) { + padding: var(--spacing-75) var(--spacing-200); + font-size: var(--s2-body-s-size); +} + +/* ---- Loading ---- */ + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-200); + min-height: 240px; + color: var(--s2-gray-700); +} + +.spectrum-loading-indicator { + width: 32px; + height: 32px; + border-radius: 50%; + border: 3px solid var(--s2-gray-200); + border-top-color: var(--pw-accent); + animation: da-perm-spin 0.85s linear infinite; +} + +.loading-label { + margin: 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); +} + +@keyframes da-perm-spin { + to { transform: rotate(360deg); } +} + +/* ---- Toolbar ---- */ + +.da-toolbar { + margin-bottom: var(--spacing-400); +} + +.da-toolbar-header { + margin: 0 0 var(--spacing-400) 0; +} + +.da-title { + margin: 0; + font-size: var(--s2-heading-xl-size, var(--s2-heading-size-700)); + line-height: 1.15; + font-weight: 700; + color: var(--s2-gray-900); + letter-spacing: -0.02em; +} + +.da-org-form { + display: flex; + align-items: flex-end; + gap: 12px; +} + +.da-org-field { + flex: 1; +} + +.da-org-field sl-input { + display: block; + width: 100%; +} + +.da-site-field { + position: relative; + flex: 1; + min-width: 180px; +} + +.da-site-field sl-input { + display: block; + width: 100%; +} + +.site-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + min-width: 200px; + background: #fff; + border: 1px solid #c0c0c0; + border-radius: 8px; + box-shadow: 0 6px 20px rgb(0 0 0 / 14%); + max-height: 260px; + overflow-y: auto; + z-index: 200; + padding: 4px 0; +} + +.site-dropdown::-webkit-scrollbar { + width: 5px; +} + +.site-dropdown::-webkit-scrollbar-track { + background: transparent; +} + +.site-dropdown::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 3px; +} + +.site-dropdown-item { + display: block; + width: 100%; + padding: 8px 14px; + background: none; + border: none; + border-radius: 0; + text-align: left; + font-size: 14px; + font-family: var(--body-font-family, sans-serif); + line-height: 1.4; + color: #222; + cursor: pointer; + box-sizing: border-box; +} + +.site-dropdown-item:hover, +.site-dropdown-item.is-active { + background: #f0f4ff; + color: var(--pw-accent, #0265dc); +} + +.site-dropdown-item.is-selected { + color: var(--pw-accent, #0265dc); + font-weight: 600; + background: #e8f0ff; +} + +.site-dropdown-empty { + padding: 8px 14px; + color: #888; + font-size: 13px; + font-style: italic; + margin: 0; +} + +/* ---- Messages ---- */ + +.message { + padding: var(--spacing-200) var(--spacing-300); + margin-bottom: var(--spacing-200); + border-radius: var(--s2-radius-100); + font-size: var(--s2-body-s-size); + line-height: 1.5; +} + +.message.success { + background: var(--s2-green-100); + color: var(--s2-green-900); +} + +.message.error { + background: var(--s2-red-100); + color: var(--s2-red-900); +} + +/* ---- Content area ---- */ + +.da-content { + padding-top: var(--spacing-200); +} + +/* ---- Context back bar (site view) ---- */ + +.context-back-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--s2-gray-200); +} + +.context-back-btn { + background: none; + border: none; + padding: 0; + color: var(--pw-accent); + font-size: var(--s2-body-s-size, 14px); + font-family: var(--body-font-family, sans-serif); + font-weight: 600; + cursor: pointer; +} + +.context-back-btn:hover { + text-decoration: underline; +} + +.context-sep { + color: var(--s2-gray-400); + font-size: var(--s2-body-s-size, 14px); +} + +.context-site-label { + font-size: var(--s2-body-s-size, 14px); + font-weight: 700; + color: var(--s2-gray-800); +} + +/* ---- Permission slots list ---- */ + +.slots-list { + display: flex; + flex-direction: column; + gap: var(--spacing-300); +} + +/* ---- Permission slot (one per path) ---- */ + +.perm-slot { + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-radius-200); + overflow: hidden; +} + +.perm-slot-header { + padding: var(--spacing-200) var(--spacing-300); + background: var(--s2-gray-75, #f8f8f8); + border-bottom: 1px solid var(--s2-gray-200); +} + +.slot-section-title { + margin: var(--spacing-500) 0 var(--spacing-150) 0; + font-size: var(--s2-body-l-size, 16px); + font-weight: 700; + color: var(--s2-gray-800); + letter-spacing: -0.01em; +} + +.slot-section-title:first-child { + margin-top: 0; +} + +.perm-slot-plain-label { + font-size: var(--s2-body-m-size, 14px); + font-weight: 600; + color: var(--s2-gray-600); +} + +.perm-slot-label { + display: inline-block; + padding: var(--spacing-75) var(--spacing-200); + border-radius: var(--s2-radius-75); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.scope-config { + background: var(--s2-orange-100); + color: var(--s2-orange-900); +} + +.scope-org { + background: var(--s2-blue-100); + color: var(--s2-blue-900); +} + +.scope-site { + background: var(--s2-blue-100); + color: var(--s2-blue-900); +} + +.scope-folder { + background: var(--s2-gray-100); + color: var(--s2-gray-700); +} + +/* ---- Level row (Read / Write within a slot) ---- */ + +.level-row { + display: flex; + align-items: flex-start; + gap: var(--spacing-300); + padding: var(--spacing-250, 10px) var(--spacing-300); + border-bottom: 1px solid var(--s2-gray-100); +} + +.level-row:last-child { + border-bottom: none; +} + +.level-row-label { + flex-shrink: 0; + width: 44px; + padding: var(--spacing-75) var(--spacing-150); + border-radius: var(--s2-radius-75); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + text-align: center; + margin-top: 3px; +} + +.level-read { + background: var(--s2-blue-100); + color: var(--s2-blue-900); +} + +.level-write { + background: var(--s2-green-100); + color: var(--s2-green-900); +} + +.level-row-members { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-100); + align-items: center; + flex: 1; + min-width: 0; +} + +.level-empty { + font-size: var(--s2-body-s-size); + color: var(--s2-gray-400); + font-style: italic; +} + +/* ---- Group pills ---- */ + +.group-pill-inherited { + opacity: 0.6; + border-style: dashed; +} + +.group-pill { + display: inline-flex; + align-items: center; + background: var(--s2-gray-100); + border: 1px solid var(--s2-gray-200); + border-radius: 20px; + font-size: 13px; + color: var(--s2-gray-800); + overflow: hidden; +} + +.group-pill-text { + padding: 5px 10px; + word-break: break-all; +} + +.group-pill-remove { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 26px; + height: 26px; + background: none; + border: none; + border-left: 1px solid var(--s2-gray-200); + color: var(--s2-gray-600); + line-height: 1; + cursor: pointer; + padding: 0; +} + +.group-pill-remove svg { + width: 14px; + height: 14px; + background: var(--s2-gray-300); + border-radius: 50%; + padding: 2px; + box-sizing: border-box; +} + +.group-pill-remove:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.group-pill-remove:hover:not(:disabled) { + color: var(--s2-gray-800); +} + +.group-pill-remove:hover:not(:disabled) svg { + background: var(--s2-gray-400); +} + +.group-pill-confirming { + background: var(--s2-red-100); + border-color: var(--s2-red-200); +} + +.group-pill-confirm-label { + font-size: 11px; + font-weight: 600; + color: var(--s2-red-800); + padding: 0 6px; + white-space: nowrap; +} + +.group-pill-confirm-yes, +.group-pill-confirm-no { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 26px; + height: 26px; + background: none; + border: none; + border-left: 1px solid var(--s2-red-200); + line-height: 1; + cursor: pointer; + padding: 0; +} + +.group-pill-confirm-yes { + color: var(--s2-red-800); +} + +.group-pill-confirm-yes:hover { + background: var(--s2-red-200); +} + +.group-pill-confirm-no { + color: var(--s2-gray-600); +} + +.group-pill-confirm-no:hover { + color: var(--s2-gray-800); +} + +/* ---- Inline add member ---- */ + +.add-member-btn { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 var(--spacing-200); + background: none; + border: 1px dashed var(--s2-gray-300); + border-radius: 20px; + color: var(--s2-gray-500); + font-size: 12px; + font-family: var(--body-font-family); + cursor: pointer; + transition: border-color 0.1s, color 0.1s, background 0.1s; + white-space: nowrap; +} + +.add-member-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.add-member-btn:hover:not(:disabled) { + border-color: var(--pw-accent); + color: var(--pw-accent); + background: var(--s2-blue-75, #f0f6ff); +} + +.add-input-wrap { + position: relative; + display: inline-flex; + align-items: center; +} + +.add-input-hint { + position: absolute; + right: 10px; + color: var(--s2-gray-400, #999); + font-size: 13px; + pointer-events: none; + user-select: none; + line-height: 1; +} + +.add-member-input { + height: 26px; + min-width: 220px; + max-width: 320px; + padding: 0 28px 0 var(--spacing-200); + border: 1px solid var(--pw-accent); + border-radius: 20px; + background: var(--s2-gray-75, #fff); + color: var(--s2-gray-900); + font-size: 12px; + font-family: var(--body-font-family); + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--pw-accent) 20%, transparent); +} + +/* ---- Add folder scope button ---- */ + +.add-scope-btn { + display: flex; + align-items: center; + width: 100%; + padding: var(--spacing-300) var(--spacing-300); + background: none; + border: 1px dashed var(--s2-gray-300); + border-radius: var(--s2-radius-200); + color: var(--s2-gray-500); + font-size: var(--s2-body-s-size); + font-family: var(--body-font-family); + cursor: pointer; + transition: border-color 0.1s, color 0.1s, background 0.1s; +} + +.add-scope-btn:hover { + border-color: var(--pw-accent); + color: var(--pw-accent); + background: var(--s2-blue-75, #f0f6ff); +} + +/* ---- Folder browser ---- */ + +.folder-browser { + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-radius-200); + overflow: hidden; + margin-top: var(--spacing-200); +} + +.folder-browser-path { + display: flex; + align-items: center; + gap: var(--spacing-100); + padding: var(--spacing-100) var(--spacing-200); + background: var(--s2-gray-75, #f8f8f8); + border-bottom: 1px solid var(--s2-gray-200); + min-height: 32px; + font-size: var(--s2-body-s-size); +} + +.folder-path-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); +} + +.folder-path-value { + color: var(--pw-accent); + font-weight: 600; +} + +.folder-path-hint { + color: var(--s2-gray-400); + font-style: italic; +} + +.folder-columns-wrap { + display: flex; + overflow-x: auto; + min-height: 200px; + max-height: 280px; +} + +.folder-columns-wrap::-webkit-scrollbar { + height: 5px; +} + +.folder-columns-wrap::-webkit-scrollbar-track { + background: transparent; +} + +.folder-columns-wrap::-webkit-scrollbar-thumb { + background: var(--s2-gray-300); + border-radius: 3px; +} + +.folder-column { + flex: 0 0 200px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--s2-gray-200); +} + +.folder-column:last-child { + border-right: none; +} + +.folder-column-header { + padding: 6px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + background: var(--s2-gray-75, #f8f8f8); + border-bottom: 1px solid var(--s2-gray-200); + min-height: 32px; + display: flex; + align-items: center; + user-select: none; +} + +.folder-column-items { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.folder-column-items::-webkit-scrollbar { + width: 5px; +} + +.folder-column-items::-webkit-scrollbar-track { + background: transparent; +} + +.folder-column-items::-webkit-scrollbar-thumb { + background: var(--s2-gray-300); + border-radius: 3px; +} + +.folder-item { + display: flex; + align-items: center; + gap: var(--spacing-100); + width: 100%; + padding: 6px 12px; + background: none; + border: none; + cursor: pointer; + font-size: var(--s2-body-s-size); + font-family: var(--body-font-family); + color: var(--s2-gray-800); + text-align: left; + min-height: 32px; +} + +.folder-item:hover { + background: var(--s2-gray-100); +} + +.folder-item.folder-item-selected { + background: var(--s2-blue-100, #e0ecff); + color: var(--pw-accent); + font-weight: 600; +} + +.folder-item.folder-item-selected:hover { + background: var(--s2-blue-200, #cce0ff); +} + +.folder-item-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-item-chevron { + color: var(--s2-gray-400); + font-size: 14px; + flex-shrink: 0; +} + +.folder-item-selected .folder-item-chevron { + color: var(--pw-accent); +} + +.folder-empty, +.folder-loading-col { + padding: var(--spacing-200) var(--spacing-300); + color: var(--s2-gray-500); + font-size: var(--s2-body-s-size); + font-style: italic; +} + +.folder-browser-actions { + display: flex; + align-items: center; + gap: var(--spacing-200); + padding: var(--spacing-200) var(--spacing-300); + border-top: 1px solid var(--s2-gray-200); + background: var(--s2-gray-75, #f8f8f8); +} + +/* ---- Info panel ---- */ + +.info-panel { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px 14px; + margin-bottom: 16px; + background: #edf5ff; + border: 1px solid #b0d0f8; + border-left: 3px solid #1473e6; + border-radius: 8px; + font-size: 13px; + line-height: 1.5; + color: #222; +} + + +.info-panel-body { + flex: 1; + min-width: 0; +} + +.info-panel-sections { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 28px; + margin-bottom: 6px; +} + +@media (width <= 900px) { + .info-panel-sections { + grid-template-columns: 1fr; + gap: 12px 0; + } +} + +.info-panel-section-label { + display: block; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #555; + margin-bottom: 3px; +} + +.info-panel-terms { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0; +} + +.info-panel-term { + display: flex; + gap: 4px; + margin: 0; +} + +.info-panel-term dt { + font-weight: 600; + color: #1473e6; + white-space: nowrap; +} + +.info-panel-term dt::after { + content: ':'; + color: #666; + font-weight: 400; +} + +.info-panel-term dd { + margin: 0; + color: #444; +} + +.info-panel-note { + margin: 0 0 4px; + color: #666; + font-size: 12px; +} + +.info-panel .pw-fill-accent { + margin-top: 8px; +} + + +.info-panel-body sl-button { + display: block; + width: fit-content; + margin-top: 8px; +} + +.info-panel-close { + background: none; + border: none; + cursor: pointer; + color: #888; + font-size: 14px; + padding: 2px 5px; + border-radius: 4px; + flex-shrink: 0; + line-height: 1; + margin-top: -1px; +} + +.info-panel-close:hover { + background: rgb(0 0 0 / 8%); + color: #222; +} + +/* ---- Misc ---- */ + +.empty-note { + color: var(--s2-gray-500); + font-size: var(--s2-body-s-size); + font-style: italic; + margin: 0; +} diff --git a/tools/apps/da-permissions/da-permissions.html b/tools/apps/da-permissions/da-permissions.html new file mode 100644 index 0000000..5404563 --- /dev/null +++ b/tools/apps/da-permissions/da-permissions.html @@ -0,0 +1,19 @@ + + + + DA Permissions + + + + + + + + + + + + + diff --git a/tools/apps/da-permissions/da-permissions.js b/tools/apps/da-permissions/da-permissions.js new file mode 100644 index 0000000..ba7e379 --- /dev/null +++ b/tools/apps/da-permissions/da-permissions.js @@ -0,0 +1,872 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { LitElement, html, nothing } from 'da-lit'; +import { + fetchOrgConfig, + updateOrgConfig, + fetchSiteList, + listFolders, +} from './api.js'; +import { icon } from './icons.js'; + +const NX = 'https://da.live/nx'; +let nexter = null; +let sl = null; +let styles = null; +let buttons = null; +try { + const [{ default: getStyle }, { loadStyle }] = await Promise.all([ + import(`${NX}/utils/styles.js`), + import(`${NX}/scripts/nexter.js`), + ]); + await Promise.all([ + loadStyle(`${NX}/styles/nexter.css`), + loadStyle(`${NX}/public/sl/styles.css`), + ]); + await import(`${NX}/public/sl/components.js`); + [nexter, sl, styles, buttons] = await Promise.all([ + getStyle(`${NX}/styles/nexter.css`), + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + getStyle(`${NX}/styles/buttons.css`), + ]); +} catch (e) { + console.warn('Failed to load styles:', e); +} + +// ---- Display helpers ---- + +function pathLabel(path, site) { + if (!site) { + if (path === 'CONFIG') return 'DA Configurations'; + if (path === '/ + **' || path === '/+**') return 'All sites'; + return path; + } + if (path === `/${site}/ + **` || path === `/${site}/+**`) return 'Entire site'; + const folderMatch = path.match(new RegExp(`^\\/${site}\\/(.+)\\/ ?\\+ ?\\*\\*$`)); + if (folderMatch) return `${folderMatch[1]}/`; + return path; +} + +function pathClass(path, site) { + if (!site) return path === 'CONFIG' ? 'scope-config' : 'scope-org'; + if (path === `/${site}/ + **` || path === `/${site}/+**`) return 'scope-site'; + return 'scope-folder'; +} + +// Normalize path variants like /+** and / + ** to a single canonical form +function normPath(path) { + if (!path) return path; + return path.replace(/\/ ?\+ ?\*\*/g, '/ + **'); +} + +function splitGroups(groups) { + return (groups || '').split(',').map((s) => s.trim()).filter(Boolean); +} + +// ---- Component ---- + +class DaPermissionsApp extends LitElement { + static properties = { + context: { attribute: false }, + token: { attribute: false }, + // 'idle' | 'loading' | 'admin' | 'user' + _state: { state: true }, + _org: { state: true }, + _orgValue: { state: true }, + // null = org context, string = site context + _site: { state: true }, + _userEmail: { state: true }, + _message: { state: true }, + _rules: { state: true }, + _siteList: { state: true }, + _isProcessing: { state: true }, + // site combobox + _siteDropdownOpen: { state: true }, + _siteSearchQuery: { state: true }, + _siteActiveIndex: { state: true }, + // path+level currently showing the inline add input + _addingToPath: { state: true }, + _addingToLevel: { state: true }, + // folder browser for adding a new folder scope + _newScopeOpen: { state: true }, + // folder path staged from browser, waiting for first member + _pendingFolderPath: { state: true }, + _folderColumns: { state: true }, + _folderLoading: { state: true }, + _selectedFolderPath: { state: true }, + _infoDismissed: { state: true }, + _confirmRemove: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [nexter, sl, buttons, styles].filter(Boolean); + + this._state = 'idle'; + this._org = ''; + this._orgValue = ''; + this._site = null; + this._userEmail = ''; + this._message = null; + this._rules = []; + this._siteList = []; + this._isProcessing = false; + this._siteDropdownOpen = false; + this._siteSearchQuery = ''; + this._siteActiveIndex = -1; + this._addingToPath = null; + this._addingToLevel = null; + this._newScopeOpen = false; + this._pendingFolderPath = null; + this._folderColumns = []; + this._folderLoading = false; + this._selectedFolderPath = ''; + this._infoDismissed = localStorage.getItem('da-permissions-info-dismissed') === 'true'; + this._confirmRemove = null; + + this._handleDocMousedown = (e) => { + if (!this._siteDropdownOpen) return; + const field = this.shadowRoot?.querySelector('.da-site-field'); + if (field && !e.composedPath().includes(field)) { + this._siteDropdownOpen = false; + this._siteSearchQuery = ''; + } + }; + document.addEventListener('mousedown', this._handleDocMousedown); + + if (this.token) { + try { + const payload = JSON.parse(atob(this.token.split('.')[1])); + this._userEmail = payload.email || payload.user_id || ''; + } catch { /* noop */ } + } + + const urlParams = new URLSearchParams(window.location.search); + const org = (urlParams.get('org') || '').trim(); + const site = (urlParams.get('site') || '').trim() || null; + if (org) { + this._orgValue = org; + this._state = 'loading'; + this.loadOrg(org, site); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('mousedown', this._handleDocMousedown); + } + + updateUrl(org, site) { + try { + const url = new URL(window.location.href); + url.searchParams.set('org', org); + if (site) { + url.searchParams.set('site', site); + } else { + url.searchParams.delete('site'); + } + window.history.replaceState(null, '', url); + } catch (e) { + console.warn('[da-permissions] updateUrl failed:', e); + } + } + + // Default paths that always appear for the current context (even if empty) + get defaultPaths() { + if (!this._site) return ['CONFIG', '/ + **']; + return [`/${this._site}/ + **`]; + } + + // Group rules by path into Read/Write member lists + get permissionSlots() { + const { _site: site } = this; + const slotMap = new Map(); + + this.defaultPaths.forEach((path) => { + slotMap.set(path, { + path, read: [], write: [], inheritedRead: [], inheritedWrite: [], + }); + }); + + if (this._pendingFolderPath) { + const pending = `/${site}/${this._pendingFolderPath}/ + **`; + if (!slotMap.has(pending)) { + slotMap.set(pending, { + path: pending, read: [], write: [], inheritedRead: [], inheritedWrite: [], + }); + } + } + + const isInContext = (path) => { + const np = normPath(path); + return !site + ? (np === 'CONFIG' || np === '/ + **') + : (np === `/${site}/ + **` || path.startsWith(`/${site}/`)); + }; + + this._rules.filter((r) => r.path && isInContext(r.path)).forEach((rule) => { + const key = normPath(rule.path); + if (!slotMap.has(key)) { + slotMap.set(key, { + path: key, read: [], write: [], inheritedRead: [], inheritedWrite: [], + }); + } + const slot = slotMap.get(key); + const groups = splitGroups(rule.groups); + if (rule.actions === 'write') { + slot.write.push(...groups); + } else { + slot.read.push(...groups); + } + }); + + // In site context, populate inherited members on the top-level site slot + // from org-level "All sites" (/ + **) rules, excluding anyone already listed. + if (site) { + const siteSlotKey = `/${site}/ + **`; + const siteSlot = slotMap.get(siteSlotKey); + if (siteSlot) { + this._rules.filter((r) => normPath(r.path) === '/ + **').forEach((rule) => { + const groups = splitGroups(rule.groups); + if (rule.actions === 'write') { + groups.filter((g) => !siteSlot.write.includes(g)) + .forEach((g) => siteSlot.inheritedWrite.push(g)); + } else { + groups.filter((g) => !siteSlot.read.includes(g)) + .forEach((g) => siteSlot.inheritedRead.push(g)); + } + }); + } + } + + return Array.from(slotMap.values()); + } + + get allSites() { + const fromRules = this._rules + .map((r) => r.path?.match(/^\/([^/+\s]+)\//)?.[1]) + .filter(Boolean); + return [...new Set([...this._siteList, ...fromRules])].sort(); + } + + get filteredSites() { + const q = this._siteSearchQuery.toLowerCase(); + return q ? this.allSites.filter((s) => s.toLowerCase().includes(q)) : this.allSites; + } + + // ---- Org loading ---- + + async handleOrgLoad() { + this._message = null; + const raw = (this.shadowRoot.querySelector('#org-input')?.value ?? '') + .trim().replace(/^\/+/, '').replace(/\/+$/, ''); + + if (!raw) { + this._message = { type: 'error', text: 'Enter an organization name.' }; + return; + } + + this._state = 'loading'; + await this.loadOrg(raw); + } + + async loadOrg(org, site = null) { + this._org = org; + this._orgValue = org; + this._site = site; + this._rules = []; + this._siteList = []; + this._message = null; + this._addingToPath = null; + this._addingToLevel = null; + this._newScopeOpen = false; + this._pendingFolderPath = null; + this._folderColumns = []; + this._folderLoading = false; + this._selectedFolderPath = ''; + + const [orgResult, siteList] = await Promise.all([ + fetchOrgConfig(org), + fetchSiteList(org), + ]); + + this._siteList = siteList; + this.updateUrl(org, site); + + if (orgResult.canAccess) { + this._rules = orgResult.config?.permissions?.data || []; + this._state = 'admin'; + } else { + this._state = 'user'; + } + } + + // ---- Context navigation ---- + + navigateToSite(site) { + this._site = site; + this._addingToPath = null; + this._addingToLevel = null; + this._newScopeOpen = false; + this._pendingFolderPath = null; + this._folderColumns = []; + this._selectedFolderPath = ''; + this._message = null; + this.updateUrl(this._org, site); + } + + navigateToOrg() { + this._site = null; + this._addingToPath = null; + this._addingToLevel = null; + this._newScopeOpen = false; + this._pendingFolderPath = null; + this._folderColumns = []; + this._selectedFolderPath = ''; + this._message = null; + this.updateUrl(this._org, null); + } + + // ---- Add member ---- + + handleOpenAddMember(path, level) { + this._addingToPath = path; + this._addingToLevel = level; + this._message = null; + this.updateComplete.then(() => { + this.shadowRoot.querySelector('.add-member-input')?.focus(); + }); + } + + handleAddMemberBlur() { + this._addingToPath = null; + this._addingToLevel = null; + } + + async handleAddMemberKey(e) { + if (e.key === 'Escape') { + this._addingToPath = null; + this._addingToLevel = null; + return; + } + if (e.key !== 'Enter') return; + + const who = e.target.value.trim(); + if (!who || this._isProcessing) return; + + this._isProcessing = true; + this._message = null; + + const path = this._addingToPath; + const level = this._addingToLevel; + + const { canAccess, config } = await fetchOrgConfig(this._org); + if (!canAccess || !config?.permissions) { + this._message = { type: 'error', text: 'Config access denied.' }; + this._isProcessing = false; + return; + } + + const existing = config.permissions.data.find( + (r) => normPath(r.path) === normPath(path) && r.actions === level, + ); + if (existing) { + const current = splitGroups(existing.groups); + if (!current.includes(who)) { + existing.groups = [...current, who].join(', '); + } + } else { + config.permissions.data.push({ path, groups: who, actions: level }); + config.permissions.total = config.permissions.data.length; + config.permissions.limit = config.permissions.data.length; + } + + const result = await updateOrgConfig(this._org, config); + if (result.success) { + this._rules = config.permissions.data; + if (this._pendingFolderPath) { + const pendingPath = `/${this._site}/${this._pendingFolderPath}/ + **`; + if (path === pendingPath) this._pendingFolderPath = null; + } + this._addingToPath = null; + this._addingToLevel = null; + } else { + this._message = { type: 'error', text: `Failed to save: ${result.error}` }; + } + + this._isProcessing = false; + } + + // ---- Remove member ---- + + handleRemoveMember(path, level, who) { + this._confirmRemove = { path, level, who }; + } + + async confirmRemove() { + const { path, level, who } = this._confirmRemove; + this._confirmRemove = null; + if (this._isProcessing) return; + + this._isProcessing = true; + this._message = null; + + const { canAccess, config } = await fetchOrgConfig(this._org); + if (!canAccess || !config?.permissions?.data) { + this._message = { type: 'error', text: 'Config access denied.' }; + this._isProcessing = false; + return; + } + + const ruleIdx = config.permissions.data.findIndex( + (r) => normPath(r.path) === normPath(path) && r.actions === level, + ); + if (ruleIdx === -1) { + this._isProcessing = false; + return; + } + + const updated = splitGroups(config.permissions.data[ruleIdx].groups).filter((g) => g !== who); + if (updated.length === 0) { + config.permissions.data.splice(ruleIdx, 1); + } else { + config.permissions.data[ruleIdx].groups = updated.join(', '); + } + config.permissions.total = config.permissions.data.length; + config.permissions.limit = config.permissions.data.length; + + const result = await updateOrgConfig(this._org, config); + if (result.success) { + this._rules = config.permissions.data; + } else { + this._message = { type: 'error', text: `Failed to remove: ${result.error}` }; + } + + this._isProcessing = false; + } + + // ---- New folder scope ---- + + async handleOpenNewScope() { + this._newScopeOpen = true; + this._selectedFolderPath = ''; + this._folderColumns = []; + this._folderLoading = true; + const items = await listFolders(this._org, this._site, '/'); + this._folderColumns = [{ path: '/', items, selectedName: null }]; + this._folderLoading = false; + } + + async handleFolderClick(columnIndex, folderName) { + const updated = this._folderColumns + .slice(0, columnIndex + 1) + .map((col, i) => (i === columnIndex ? { ...col, selectedName: folderName } : col)); + + const pathParts = updated.map((col) => col.selectedName).filter(Boolean); + this._selectedFolderPath = pathParts.join('/'); + this._folderColumns = updated; + this._folderLoading = true; + + const subPath = `/${pathParts.join('/')}/`; + const subItems = await listFolders(this._org, this._site, subPath); + if (subItems.length > 0) { + this._folderColumns = [...updated, { path: subPath, items: subItems, selectedName: null }]; + } + this._folderLoading = false; + } + + handleConfirmNewScope() { + if (!this._selectedFolderPath) return; + this._pendingFolderPath = this._selectedFolderPath; + this._newScopeOpen = false; + this._folderColumns = []; + this._selectedFolderPath = ''; + } + + handleCancelNewScope() { + this._newScopeOpen = false; + this._folderColumns = []; + this._selectedFolderPath = ''; + } + + // ---- Render: toolbar ---- + + handleSiteInputFocus() { + this._siteDropdownOpen = true; + this._siteSearchQuery = ''; + this._siteActiveIndex = -1; + } + + handleSiteInputTyped(e) { + this._siteSearchQuery = e.target.value; + this._siteDropdownOpen = true; + this._siteActiveIndex = -1; + } + + handleSiteInputKey(e) { + const { filteredSites } = this; + if (e.key === 'Escape') { + this._siteDropdownOpen = false; + this._siteSearchQuery = ''; + this._siteActiveIndex = -1; + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + this._siteDropdownOpen = true; + this._siteActiveIndex = Math.min(this._siteActiveIndex + 1, filteredSites.length - 1); + this.scrollActiveSiteItem(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this._siteActiveIndex = Math.max(this._siteActiveIndex - 1, -1); + this.scrollActiveSiteItem(); + } else if (e.key === 'Enter' && this._siteDropdownOpen && this._siteActiveIndex >= 0) { + e.preventDefault(); + this.handleSiteItemSelect(filteredSites[this._siteActiveIndex]); + } + } + + scrollActiveSiteItem() { + requestAnimationFrame(() => { + this.shadowRoot?.querySelector('.site-dropdown-item.is-active') + ?.scrollIntoView({ block: 'nearest' }); + }); + } + + handleSiteInputBlur() { + // rAF lets any @click on dropdown items fire before the dropdown closes + requestAnimationFrame(() => { + this._siteDropdownOpen = false; + this._siteSearchQuery = ''; + this._siteActiveIndex = -1; + }); + } + + handleSiteItemSelect(site) { + this._siteDropdownOpen = false; + this._siteSearchQuery = ''; + this._siteActiveIndex = -1; + if (site) { + this.navigateToSite(site); + } else { + this.navigateToOrg(); + } + } + + renderToolbar() { + const orgLoaded = this._state === 'admin' || this._state === 'user'; + const inputVal = this._siteDropdownOpen ? this._siteSearchQuery : (this._site || ''); + return html` +
+
+

DA Permissions

+
+
+
+ { if (e.key === 'Enter') this.handleOrgLoad(); }} + > +
+ ${orgLoaded ? html` +
+ + ${this._siteDropdownOpen ? html` + + ` : nothing} +
+ ` : nothing} + this.handleOrgLoad()} + ?disabled=${this._state === 'loading'} + > + ${this._state === 'loading' ? 'Loading…' : 'Load'} + +
+
+ `; + } + + // ---- Render: loading ---- + + renderLoading() { + return html` +
+ +

Loading…

+
+ `; + } + + // ---- Render: message ---- + + renderMessage() { + if (!this._message) return nothing; + return html`
${this._message.text}
`; + } + + // ---- Render: level row within a slot ---- + + renderPill(slot, level, member) { + const isPending = this._confirmRemove?.path === slot.path + && this._confirmRemove?.level === level + && this._confirmRemove?.who === member; + return html` + + ${member} + ${isPending ? html` + Remove? + + + ` : html` + + `} + + `; + } + + renderLevelRow(slot, level) { + const members = level === 'write' ? slot.write : slot.read; + const inherited = level === 'write' ? slot.inheritedWrite : slot.inheritedRead; + const isAdding = this._addingToPath === slot.path && this._addingToLevel === level; + const isEmpty = members.length === 0 && inherited.length === 0 && !isAdding; + + return html` +
+ ${level === 'write' ? 'Write' : 'Read'} +
+ ${members.map((member) => this.renderPill(slot, level, member))} + ${inherited.map((member) => html` + + ${member} + + `)} + ${isEmpty ? html` + No one + ` : nothing} + ${isAdding ? html` + + + + + ` : html` + + `} +
+
+ `; + } + + // ---- Render: permission slot ---- + + renderSlot(slot, sectionTitle = null) { + const label = pathLabel(slot.path, this._site); + const cls = pathClass(slot.path, this._site); + const labelEl = cls === 'scope-folder' + ? html`${label}` + : html`${label}`; + return html` + ${sectionTitle ? html`

${sectionTitle}

` : nothing} +
+
${labelEl}
+ ${this.renderLevelRow(slot, 'read')} + ${this.renderLevelRow(slot, 'write')} +
+ `; + } + + // ---- Render: folder browser for new scope ---- + + renderFolderBrowser() { + return html` +
+
+ ${this._selectedFolderPath ? html` + Selected: + ${this._selectedFolderPath} + ` : html` + Click a folder to select it + `} +
+
+ ${this._folderColumns.map((col, colIndex) => html` +
+
+ ${col.path === '/' ? this._site : col.path.replace(/^\/|\/$/g, '')} +
+
+ ${col.items.length === 0 ? html` +
No subfolders
+ ` : col.items.map((item) => html` + + `)} +
+
+ `)} + ${this._folderLoading ? html` +
+
 
+
Loading…
+
+ ` : nothing} +
+
+ Add this folder + Cancel +
+
+ `; + } + + // ---- Render: admin view ---- + + renderAdmin() { + const orgSectionTitles = { CONFIG: 'Config Permissions', '/ + **': 'Content Permissions' }; + return html` + ${this._site ? html` +
+ + + ${this._site} +
+ ` : nothing} +
+ ${this.permissionSlots.map((slot) => this.renderSlot(slot, orgSectionTitles[slot.path] ?? null))} + ${this._site && !this._newScopeOpen ? html` + + ` : nothing} + ${this._site && this._newScopeOpen ? this.renderFolderBrowser() : nothing} +
+ `; + } + + // ---- Render: top-level ---- + + renderContent() { + switch (this._state) { + case 'loading': return this.renderLoading(); + case 'admin': return this.renderAdmin(); + case 'user': return html`

You don't have admin access to this org.

`; + default: return nothing; + } + } + + dismissInfo() { + this._infoDismissed = true; + localStorage.setItem('da-permissions-info-dismissed', 'true'); + } + + renderInfoPanel() { + if (this._infoDismissed || this._state !== 'admin') return nothing; + return html` +
+
+
+
+ +
+
Read
View content.
+
Write
Create, edit & delete — includes Read.
+
None
Explicitly denies access.
+
+
+
+ +
+
Read
Read DA configurations — recommended for content authors as using the block library or plugins in your DA project require it.
+
Write
Modify DA configurations — recommended for power users who need to manage org and site level configurations in DA.
+
+
+
+

Identity via Adobe IMS — use email addresses or IMS group IDs.

+ window.open('https://docs.da.live/administrators/guides/permissions', '_blank', 'noopener')} + >DA permissions docs +
+ +
+ `; + } + + render() { + return html` + ${this.renderToolbar()} + ${this.renderInfoPanel()} + ${this.renderMessage()} +
+ ${this.renderContent()} +
+ `; + } +} + +customElements.define('da-permissions-app', DaPermissionsApp); + +(async function init() { + const { context, token } = await DA_SDK; + const cmp = document.createElement('da-permissions-app'); + cmp.context = context; + cmp.token = token; + document.body.append(cmp); +}()); diff --git a/tools/apps/da-permissions/icons.js b/tools/apps/da-permissions/icons.js new file mode 100644 index 0000000..22e5130 --- /dev/null +++ b/tools/apps/da-permissions/icons.js @@ -0,0 +1,10 @@ +/* eslint-disable import/no-unresolved */ +import { html } from 'da-lit'; + +const ICON_DIR = new URL('./img/', import.meta.url).href; + +// eslint-disable-next-line import/prefer-default-export +export const icon = (name, w = 16, h = 16) => html` + `; diff --git a/tools/apps/da-permissions/img/S2_Icon_Checkmark_20_N.svg b/tools/apps/da-permissions/img/S2_Icon_Checkmark_20_N.svg new file mode 100644 index 0000000..187b807 --- /dev/null +++ b/tools/apps/da-permissions/img/S2_Icon_Checkmark_20_N.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tools/apps/da-permissions/img/S2_Icon_Close_20_N.svg b/tools/apps/da-permissions/img/S2_Icon_Close_20_N.svg new file mode 100644 index 0000000..e4a0384 --- /dev/null +++ b/tools/apps/da-permissions/img/S2_Icon_Close_20_N.svg @@ -0,0 +1,5 @@ + + + + + From f86aec85e426d3ff25dc7ff0638195ec1c91be06 Mon Sep 17 00:00:00 2001 From: Usman Khalid Date: Mon, 8 Jun 2026 09:21:40 -0400 Subject: [PATCH 2/3] chore: fix chevron --- tools/apps/da-permissions/da-permissions.css | 16 ++++++++++++++++ tools/apps/da-permissions/da-permissions.js | 3 ++- tools/apps/da-permissions/icons.js | 4 ++-- .../img/Smock_ChevronDown_18_N.svg | 5 +++++ 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 tools/apps/da-permissions/img/Smock_ChevronDown_18_N.svg diff --git a/tools/apps/da-permissions/da-permissions.css b/tools/apps/da-permissions/da-permissions.css index ac7ed2e..b06de04 100644 --- a/tools/apps/da-permissions/da-permissions.css +++ b/tools/apps/da-permissions/da-permissions.css @@ -148,6 +148,22 @@ sl-button.pw-quiet-danger::part(base):hover:not(:disabled) { width: 100%; } +.site-chevron { + position: absolute; + right: 0.6rem; + top: 50%; + transform: translateY(-50%); + color: var(--s2-gray-600); + pointer-events: none; + transition: transform 0.15s; + display: flex; + align-items: center; +} + +.site-chevron.is-open { + transform: translateY(-50%) rotate(180deg); +} + .site-dropdown { position: absolute; top: calc(100% + 4px); diff --git a/tools/apps/da-permissions/da-permissions.js b/tools/apps/da-permissions/da-permissions.js index ba7e379..135335e 100644 --- a/tools/apps/da-permissions/da-permissions.js +++ b/tools/apps/da-permissions/da-permissions.js @@ -577,7 +577,7 @@ class DaPermissionsApp extends LitElement { + ${this._siteDropdownOpen ? html`