diff --git a/tools/apps/da-permissions/api.js b/tools/apps/da-permissions/api.js
new file mode 100644
index 0000000..b43c2c0
--- /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, status: response.status, 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..b202ad6
--- /dev/null
+++ b/tools/apps/da-permissions/da-permissions.css
@@ -0,0 +1,902 @@
+: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-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);
+ 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);
+}
+
+.message.warning {
+ background: var(--s2-orange-100);
+ color: var(--s2-orange-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-none {
+ background: var(--s2-red-100);
+ color: var(--s2-red-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..603919f
--- /dev/null
+++ b/tools/apps/da-permissions/da-permissions.js
@@ -0,0 +1,902 @@
+/* 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);
+}
+
+// DA actions values: 'write' | 'read' | '' (empty = deny/none)
+// UI level names: 'write' | 'read' | 'none'
+const levelToActions = (level) => (level === 'none' ? '' : level);
+const actionsToLevel = (actions) => (actions === 'write' || actions === 'read' ? actions : 'none');
+
+// ---- 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 },
+ _readOnly: { 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._readOnly = false;
+
+ 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: [],
+ none: [],
+ inheritedRead: [],
+ inheritedWrite: [],
+ inheritedNone: [],
+ });
+ });
+
+ if (this._pendingFolderPath) {
+ const pending = `/${site}/${this._pendingFolderPath}/ + **`;
+ if (!slotMap.has(pending)) {
+ slotMap.set(pending, {
+ path: pending,
+ read: [],
+ write: [],
+ none: [],
+ inheritedRead: [],
+ inheritedWrite: [],
+ inheritedNone: [],
+ });
+ }
+ }
+
+ 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: [],
+ none: [],
+ inheritedRead: [],
+ inheritedWrite: [],
+ inheritedNone: [],
+ });
+ }
+ const slot = slotMap.get(key);
+ const groups = splitGroups(rule.groups);
+ slot[actionsToLevel(rule.actions)].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);
+ const lvl = actionsToLevel(rule.actions);
+ const inheritedKey = `inherited${lvl.charAt(0).toUpperCase()}${lvl.slice(1)}`;
+ groups.filter((g) => !siteSlot[lvl].includes(g))
+ .forEach((g) => siteSlot[inheritedKey].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 = '';
+ this._readOnly = false;
+
+ 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 actions = levelToActions(level);
+ const existing = config.permissions.data.find(
+ (r) => normPath(r.path) === normPath(path) && r.actions === actions,
+ );
+ 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 });
+ 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 if (result.status === 403 || result.status === 401) {
+ this._readOnly = true;
+ } 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 === levelToActions(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 if (result.status === 403 || result.status === 401) {
+ this._readOnly = true;
+ } 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`
+
+ `;
+ }
+
+ // ---- Render: loading ----
+
+ renderLoading() {
+ return html`
+
+ `;
+ }
+
+ // ---- 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 = slot[level];
+ const inherited = slot[`inherited${level.charAt(0).toUpperCase()}${level.slice(1)}`] ?? [];
+ const isAdding = this._addingToPath === slot.path && this._addingToLevel === level;
+ const isEmpty = members.length === 0 && inherited.length === 0 && !isAdding;
+ const labels = { write: 'Write', read: 'Read', none: 'None' };
+
+ return html`
+
+
${labels[level]}
+
+ ${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}
+
+
+ ${this.renderLevelRow(slot, 'read')}
+ ${this.renderLevelRow(slot, 'write')}
+ ${slot.none.length > 0 ? this.renderLevelRow(slot, 'none') : nothing}
+
+ `;
+ }
+
+ // ---- 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`
+
+ ` : nothing}
+
+
+ Add this folder
+ Cancel
+
+
+ `;
+ }
+
+ // ---- Render: admin view ----
+
+ renderAdmin() {
+ const orgSectionTitles = { CONFIG: 'Config Permissions', '/ + **': 'Content Permissions' };
+ return html`
+ ${this._readOnly ? html`
+ You have read-only access to this configuration.
+ ` : nothing}
+ ${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`
+
+
+
+
+
Content
+
+ - Read
- View content.
+ - Write
- Create, edit & delete — includes Read.
+ - None
- Explicitly denies access.
+
+
+
+
Config
+
+ - 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..14fb126
--- /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, vb = '0 0 20 20') => 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 @@
+
diff --git a/tools/apps/da-permissions/img/Smock_ChevronDown_18_N.svg b/tools/apps/da-permissions/img/Smock_ChevronDown_18_N.svg
new file mode 100644
index 0000000..12593fb
--- /dev/null
+++ b/tools/apps/da-permissions/img/Smock_ChevronDown_18_N.svg
@@ -0,0 +1,5 @@
+