diff --git a/tools/apps/publish-requests-inbox/api.js b/tools/apps/publish-requests-inbox/api.js index 6cb8f5b..e150eb7 100644 --- a/tools/apps/publish-requests-inbox/api.js +++ b/tools/apps/publish-requests-inbox/api.js @@ -151,41 +151,56 @@ function resolveApproversWithGroups(approversList, groupsData) { } /** - * Fetch the workflow config via the DA Config API. - * Tries site-level first: GET /config/{org}/{site}/publish-workflow-config - * Falls back to org-level: GET /config/{org}/publish-workflow-config - * See: https://docs.da.live/developers/api/config#get-config + * Fetch site-level and org-level DA configs in parallel. This is the single + * source of truth for hitting `admin.da.live/config/...` from this module — + * higher-level helpers (`checkDaConfiguration`, `extractAccentSettings`, + * `fetchWorkflowConfig`) all derive from the configs returned here so the + * inbox app pays for at most two GETs at boot regardless of how many + * downstream questions it asks of the config. + * + * Network failures and non-200 responses become `null` for that level so + * callers can continue with the level that did return. + * * @param {string} org - Organization * @param {string} site - Site * @param {string} token - Authorization token - * @returns {Promise} Config data or null on failure + * @returns {Promise<{siteConfig: Object|null, orgConfig: Object|null}>} */ -async function fetchWorkflowConfig(org, site, token) { +async function fetchSiteAndOrgConfig(org, site, token) { const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json', }; - - // 1. Try site-level root config - const siteUrl = `${DA_ADMIN}/config/${org}/${site}/`; - const siteResp = await fetch(siteUrl, { headers }); - if (siteResp.ok) { - const config = await siteResp.json(); - if (config['publish-workflow-config']) { - return config; - } - } - - // 2. Fallback to org-level root config - const orgUrl = `${DA_ADMIN}/config/${org}/`; - const orgResp = await fetch(orgUrl, { headers }); - if (orgResp.ok) { - const config = await orgResp.json(); - if (config['publish-workflow-config']) { - return config; + const fetchOne = async (url) => { + try { + const resp = await fetch(url, { headers }); + if (!resp.ok) return null; + return await resp.json(); + } catch { + return null; } - } + }; + const [siteConfig, orgConfig] = await Promise.all([ + fetchOne(`${DA_ADMIN}/config/${org}/${site}/`), + fetchOne(`${DA_ADMIN}/config/${org}/`), + ]); + return { siteConfig, orgConfig }; +} +/** + * Fetch the workflow config via the DA Config API. + * Tries site-level first: GET /config/{org}/{site}/publish-workflow-config + * Falls back to org-level: GET /config/{org}/publish-workflow-config + * See: https://docs.da.live/developers/api/config#get-config + * @param {string} org - Organization + * @param {string} site - Site + * @param {string} token - Authorization token + * @returns {Promise} Config data or null on failure + */ +async function fetchWorkflowConfig(org, site, token) { + const { siteConfig, orgConfig } = await fetchSiteAndOrgConfig(org, site, token); + if (siteConfig?.['publish-workflow-config']) return siteConfig; + if (orgConfig?.['publish-workflow-config']) return orgConfig; return null; } @@ -244,6 +259,30 @@ export async function fetchAccentSettings(org, site, token) { }; } +/** + * Synchronous accent-color extractor for callers that already have the DA + * configs in hand (e.g. the inbox app's `_daConfig` cache). Avoids a second + * round trip to `admin.da.live` on app boot when the config has already + * been fetched for the Step 2 setup check. + * + * Mirrors the precedence in `fetchWorkflowConfig`: prefer settings from + * whichever config has the `publish-workflow-settings` tab, site first. + * + * @param {Object|null} siteConfig - Site-level DA config (or null) + * @param {Object|null} orgConfig - Org-level DA config (or null) + * @returns {{accentColor: string|null, accentColorHover: string|null}} + */ +export function extractAccentSettings(siteConfig, orgConfig) { + let config = null; + if (siteConfig?.['publish-workflow-settings']) config = siteConfig; + else if (orgConfig?.['publish-workflow-settings']) config = orgConfig; + if (!config) return { accentColor: null, accentColorHover: null }; + return { + accentColor: extractSetting(config, 'theme.accent-color'), + accentColorHover: extractSetting(config, 'theme.accent-color-hover'), + }; +} + /** * Publish content via Helix Admin API * POST https://admin.hlx.page/live/{org}/{site}/main/{path} @@ -834,6 +873,277 @@ export async function getAllPendingRequestsByRequester(org, site, userEmail, tok ); } +/** + * Check whether a DA site (repo) exists by listing the org's children. + * GET /list/{org} returns all repos; we check if `site` is among them. + * @param {string} org - Organization + * @param {string} site - Site (repo name) + * @param {string} token - Authorization token + * @returns {Promise} true if the site exists, false otherwise + */ +export async function checkSiteExists(org, site, token) { + try { + const url = `${DA_ADMIN}/list/${org}`; + const resp = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) return false; + const items = await resp.json(); + return items.some((item) => item.name === site); + } catch { + return false; + } +} + +/** + * Check whether org--site is registered in the worker's KV store. + * GET /api/site-config?org={org}&site={site} + * + * The worker returns different shapes depending on registration status: + * - Registered: 200 with the site config object + * - Not registered: 200 with { error, availableProviders: string[] } + * - Legacy fallback: 404 for unregistered (older worker versions) + * + * @param {string} org - Organization + * @param {string} site - Site + * @param {string} token - Authorization token + * @returns {Promise<{registered: boolean, config: Object|null, availableProviders: string[]}>} + */ +export async function checkSiteRegistration(org, site, token) { + try { + const url = `${getWorkerUrl()}/api/site-config?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}`; + const resp = await fetch(url, getOpts(token, 'GET')); + let data; + try { data = await resp.json(); } catch { data = null; } + + if (data?.availableProviders) { + return { + registered: false, + config: null, + availableProviders: data.availableProviders, + }; + } + if (!resp.ok) { + return { registered: false, config: null, availableProviders: ['custom-api'] }; + } + return { registered: true, config: data, availableProviders: [] }; + } catch { + return { registered: false, config: null, availableProviders: ['custom-api'] }; + } +} + +// Per-tab inspectors. Each returns 'ok' or 'missing' for a single config +// object — provenance promotion (site-level vs. org-level) is layered on +// top by `inspectAtBothLevels` below. + +// Match a row's `path` cell against a substring needle. Substring (rather +// than endsWith / equality) keeps the check stable across env refs +// (`?env=ci`), with-or-without `.html` extensions, and forks that change +// the host portion of the URL — what we really care about is that the row +// points at the request-publish plugin / inbox app, not the exact URL. +function hasRowWithPathContaining(rows, needle) { + if (!Array.isArray(rows)) return false; + return rows.some((row) => (row?.path || row?.Path || '').toLowerCase().includes(needle)); +} + +function inspectLibraryAt(config) { + const rows = config?.library?.data; + return hasRowWithPathContaining(rows, '/tools/plugins/request-for-publish') ? 'ok' : 'missing'; +} + +function inspectAppsAt(config) { + const rows = config?.apps?.data; + return hasRowWithPathContaining(rows, '/tools/apps/publish-requests-inbox') ? 'ok' : 'missing'; +} + +function inspectWorkflowConfigAt(config) { + const rows = config?.['publish-workflow-config']?.data; + if (!Array.isArray(rows) || rows.length === 0) return 'missing'; + return 'ok'; +} + +/** + * Promote a single-config inspector to a (site, org) pair, returning a + * status object with provenance. Site wins when both have it. + * @param {Object|null} siteConfig + * @param {Object|null} orgConfig + * @param {(c: Object|null) => 'ok'|'missing'} inspectAt + * @returns {{state: 'ok'|'missing', source: 'site'|'org'|null}} + */ +function inspectAtBothLevels(siteConfig, orgConfig, inspectAt) { + if (inspectAt(siteConfig) === 'ok') return { state: 'ok', source: 'site' }; + if (inspectAt(orgConfig) === 'ok') return { state: 'ok', source: 'org' }; + return { state: 'missing', source: null }; +} + +/** + * Site-only variant for tabs that DA does not inherit from org config + * (`library` and `apps`). DA's per-site Library/Apps surfaces only read + * from the site-level config sheet, so org-level rows have no effect for + * these tabs even though the API would happily return them. + * @param {Object|null} siteConfig + * @param {(c: Object|null) => 'ok'|'missing'} inspectAt + * @returns {{state: 'ok'|'missing', source: 'site'|null}} + */ +function inspectAtSiteOnly(siteConfig, inspectAt) { + if (inspectAt(siteConfig) === 'ok') return { state: 'ok', source: 'site' }; + return { state: 'missing', source: null }; +} + +/** + * Inspect the optional `publish-workflow-settings` tab. Distinguishes: + * - 'configured': tab exists with at least one row + * - 'empty': tab exists but has no rows (likely an authoring mistake) + * - 'default': tab is absent — workflow runs with documented defaults + * + * @returns {{state: 'configured'|'empty'|'default', source: 'site'|'org'|null}} + */ +function inspectWorkflowSettings(siteConfig, orgConfig) { + const inspect = (config) => { + const rows = config?.['publish-workflow-settings']?.data; + if (!Array.isArray(rows)) return null; + return rows.length > 0 ? 'configured' : 'empty'; + }; + const siteState = inspect(siteConfig); + if (siteState) return { state: siteState, source: 'site' }; + const orgState = inspect(orgConfig); + if (orgState) return { state: orgState, source: 'org' }; + return { state: 'default', source: null }; +} + +/** + * Detect whether `publish-workflow-config` rows reference any distribution + * list (DL) names. A non-email entry in approvers/CC means a DL — which in + * turn means `publish-workflow-groups-to-email` must exist for the workflow + * to resolve recipients. If everything is plain emails, that tab is + * genuinely unnecessary. + * + * @param {Array} rows - publish-workflow-config rows + * @returns {boolean} + */ +function workflowConfigUsesDistributionLists(rows) { + if (!Array.isArray(rows)) return false; + for (const row of rows) { + const approvers = (row.approvers || row.Approvers || '').split(','); + const cc = (row.cc || row.CC || '').split(','); + for (const entry of [...approvers, ...cc]) { + const trimmed = entry.trim(); + if (trimmed && !trimmed.includes('@')) return true; + } + } + return false; +} + +/** + * Inspect the optional `publish-workflow-groups-to-email` tab. Smart about + * whether it's actually needed for this site's workflow rules: + * - 'ok': tab is present and populated + * - 'missing': workflow rules use DL names but the tab is absent/empty + * - 'not-needed': workflow rules use only plain email addresses, so no + * DL→email mapping is required + * + * @returns {{state: 'ok'|'missing'|'not-needed', source: 'site'|'org'|null}} + */ +function inspectGroupsToEmail(siteConfig, orgConfig) { + const wfRows = siteConfig?.['publish-workflow-config']?.data + || orgConfig?.['publish-workflow-config']?.data + || []; + if (!workflowConfigUsesDistributionLists(wfRows)) { + return { state: 'not-needed', source: null }; + } + const inspect = (config) => { + const rows = config?.['publish-workflow-groups-to-email']?.data; + return Array.isArray(rows) && rows.length > 0; + }; + if (inspect(siteConfig)) return { state: 'ok', source: 'site' }; + if (inspect(orgConfig)) return { state: 'ok', source: 'org' }; + return { state: 'missing', source: null }; +} + +/** + * Check that the required and recommended DA configuration tabs are set up + * for the Request Publish workflow. Mirrors the setup steps in + * https://docs.da.live/about/early-access/request-publish (Step 2). + * + * Scope rules: + * - `publish-workflow-*` tabs (config / settings / groups-to-email) are + * inherited from org config when not present at site level. Provenance + * is reported as `'site'` or `'org'`. + * - `library` and `apps` are site-only: DA does not inherit them from org + * config, so we never look at orgConfig for these. Provenance is always + * `'site'` (when configured) or `null` (when missing). + * + * The site/org configs themselves are returned alongside the status so the + * inbox app can derive other settings (e.g. accent colors via + * `extractAccentSettings`) without re-fetching. + * + * Status values per tab: + * - workflowConfig: { state: 'ok'|'missing', source } + * - library: { state: 'ok'|'missing', source } + * - apps: { state: 'ok'|'missing', source } + * - workflowSettings: { state: 'configured'|'empty'|'default', source } + * - groupsToEmail: { state: 'ok'|'missing'|'not-needed', source } + * + * @param {string} org + * @param {string} site + * @param {string} token + * @returns {Promise<{ + * status: Object, + * siteConfig: Object|null, + * orgConfig: Object|null, + * error: string|null, + * }>} + */ +export async function checkDaConfiguration(org, site, token) { + const { siteConfig, orgConfig } = await fetchSiteAndOrgConfig(org, site, token); + + const status = { + workflowConfig: inspectAtBothLevels(siteConfig, orgConfig, inspectWorkflowConfigAt), + // library + apps are site-only; DA does not inherit them from org config. + library: inspectAtSiteOnly(siteConfig, inspectLibraryAt), + apps: inspectAtSiteOnly(siteConfig, inspectAppsAt), + workflowSettings: inspectWorkflowSettings(siteConfig, orgConfig), + groupsToEmail: inspectGroupsToEmail(siteConfig, orgConfig), + }; + + // If neither config request returned anything, surface that distinct error + // — most often a permissions issue on the DA config sheet. + const error = !siteConfig && !orgConfig + ? 'Could not read DA configuration. You may not have access to the config sheet for this site.' + : null; + + return { + status, + siteConfig, + orgConfig, + error, + }; +} + +/** + * Register a new org--site in the worker's KV store with email provider config. + * POST /api/site-config + * @param {string} org - Organization + * @param {string} site - Site + * @param {Object} emailConfig - Email provider configuration + * @param {string} token - Authorization token + * @returns {Promise} Result + */ +export async function registerSite(org, site, emailConfig, token) { + try { + const opts = getOpts(token, 'POST', { org, site, ...emailConfig }); + const resp = await fetch(`${getWorkerUrl()}/api/site-config`, opts); + const result = await resp.json(); + if (!resp.ok) { + return { success: false, error: result.error || 'Registration failed' }; + } + return { success: true, data: result }; + } catch (error) { + console.error('Error registering site:', error); + return { success: false, error: error.message || 'An error occurred' }; + } +} + /** * Fetch the current user's email from Adobe IMS profile * @param {string} token - The authorization token diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.css b/tools/apps/publish-requests-inbox/publish-requests-inbox.css index d9f9816..84e2c5b 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.css +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.css @@ -748,3 +748,195 @@ sl-button.pw-quiet-secondary::part(base):hover:not(:disabled) { color: var(--s2-gray-700); font-size: var(--s2-body-s-size); } + +/* ========== Registration ========== */ +.register-container { + max-width: 560px; + margin: var(--spacing-600) auto 0; +} + +.register-banner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: var(--spacing-400); +} + +.register-heading { + margin: 0 0 var(--spacing-100) 0; + font-size: var(--s2-heading-s-size); + font-weight: 700; + color: var(--s2-gray-900); +} + +.register-body { + margin: 0 0 var(--spacing-300) 0; + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + line-height: 1.5; +} + +.register-site-chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-100); + margin: 0 0 var(--spacing-200) 0; + padding: 4px var(--spacing-200); + background: var(--s2-gray-100); + border: 1px solid var(--s2-gray-200); + border-radius: 999px; + font-size: var(--s2-body-xs-size); + color: var(--s2-gray-800); +} + +.register-site-chip-label { + font-weight: 600; + color: var(--s2-gray-700); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 11px; +} + +.register-site-chip code { + font-family: var(--s2-code-family, ui-monospace, "SF Mono", monospace); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-900); +} + +.register-form-card { + text-align: left; +} + +/* ========== DA configuration status (Step 2 of onboarding) ========== */ +.config-status-list { + list-style: none; + margin: var(--spacing-200) 0 var(--spacing-300); + padding: 0; +} + +.config-status-row { + display: flex; + align-items: center; + gap: var(--spacing-200); + padding: var(--spacing-100) 0; + border-bottom: 1px solid var(--s2-gray-200); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-800); +} + +.config-status-row:last-child { + border-bottom: none; +} + +.config-status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + flex-shrink: 0; +} + +.config-status-icon svg { + width: 14px; + height: 14px; +} + +.config-status-icon--ok { + background: var(--s2-green-100); + color: var(--s2-green-900); +} + +.config-status-icon--missing { + background: var(--s2-red-100); + color: var(--s2-red-900); +} + +.config-status-icon--neutral { + background: var(--s2-gray-200); + color: var(--s2-gray-700); +} + +.config-status-label { + flex: 1; + min-width: 0; +} + +.config-status-text { + font-weight: 600; + color: var(--s2-gray-700); +} + +.setup-where-list { + margin: 0 0 var(--spacing-300) 0; + padding-left: var(--spacing-400); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-700); + line-height: 1.5; +} + +.setup-where-list li + li { + margin-top: var(--spacing-150, 8px); +} + +.setup-where-list strong { + color: var(--s2-gray-900); +} + +.setup-where-list code { + font-family: var(--s2-code-family, ui-monospace, "SF Mono", monospace); + font-size: 0.95em; + color: var(--s2-gray-900); +} + +.register-form { + display: flex; + flex-direction: column; + gap: var(--spacing-100); +} + +.register-form .form-group { + margin-bottom: var(--spacing-100); +} + +.reg-select { + display: block; + width: 100%; + padding: var(--spacing-100) var(--spacing-200); + font-family: var(--body-font-family); + font-size: var(--s2-body-s-size); + color: var(--s2-gray-900); + background: var(--s2-gray-25, #fff); + border: 1px solid var(--s2-gray-300); + border-radius: var(--s2-radius-100); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23464646'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--spacing-200) center; + cursor: pointer; +} + +.reg-select:hover { + border-color: var(--s2-gray-400); +} + +.reg-select:focus-visible { + outline: 2px solid var(--pw-accent); + outline-offset: 1px; +} + +.form-hint { + display: block; + margin-top: var(--spacing-50); + font-size: var(--s2-detail-s-size, 11px); + color: var(--s2-gray-600); + line-height: 1.4; +} + +.register-form-actions { + display: flex; + gap: var(--spacing-200); + margin-top: var(--spacing-200); +} diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.html b/tools/apps/publish-requests-inbox/publish-requests-inbox.html index 10117cc..33f4f7d 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.html +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.html @@ -1,6 +1,7 @@ + Publish Request Review diff --git a/tools/apps/publish-requests-inbox/publish-requests-inbox.js b/tools/apps/publish-requests-inbox/publish-requests-inbox.js index fa6c00c..4baaefb 100644 --- a/tools/apps/publish-requests-inbox/publish-requests-inbox.js +++ b/tools/apps/publish-requests-inbox/publish-requests-inbox.js @@ -18,9 +18,15 @@ import { resendPublishRequest, fetchSiteConfig, getLiveHostFromConfig, - fetchAccentSettings, + extractAccentSettings, + checkSiteExists, + checkSiteRegistration, + checkDaConfiguration, + registerSite, } from './api.js'; +const REQUEST_PUBLISH_DOCS_URL = 'https://docs.da.live/about/early-access/request-publish'; + // Super Lite (sl-*) — Spectrum-aligned controls for DA; pairs with S2 tokens in CSS. // NX style pipeline matches other da.live shell apps (e.g. MSM): nexter.js loadStyle + getStyle. const NX = 'https://da.live/nx'; @@ -71,7 +77,8 @@ class PublishRequestsApp extends LitElement { context: { attribute: false }, token: { attribute: false }, // view states: 'loading', 'idle', 'inbox', 'review', 'approved', - // 'rejected', 'error', 'unauthorized', 'no-request' + // 'rejected', 'error', 'unauthorized', 'no-request', + // 'site-not-found', 'unregistered', 'config-missing' _state: { state: true }, _isProcessing: { state: true }, _message: { state: true }, @@ -94,8 +101,17 @@ class PublishRequestsApp extends LitElement { _myRequestActions: { state: true }, // Toolbar _orgSiteValue: { state: true }, + _siteSelectLoading: { state: true }, // Inline reject error _rejectError: { state: true }, + // Registration + _siteRegistered: { state: true }, + _registrationChecked: { state: true }, + _registerProcessing: { state: true }, + _availableProviders: { state: true }, + // DA configuration check (Step 2 of onboarding) + _daConfig: { state: true }, + _daConfigRechecking: { state: true }, }; constructor() { @@ -118,6 +134,13 @@ class PublishRequestsApp extends LitElement { this._requester = false; this._myRequestActions = new Map(); this._orgSiteValue = ''; + this._siteSelectLoading = false; + this._siteRegistered = null; + this._registrationChecked = false; + this._registerProcessing = false; + this._availableProviders = []; + this._daConfig = null; + this._daConfigRechecking = false; } connectedCallback() { @@ -172,18 +195,60 @@ class PublishRequestsApp extends LitElement { return `${this.appBaseUrl}?${params.toString()}`; } + /** + * Step 2 of onboarding: verify the DA-side configuration is in place for + * this org/site. Returns true when the workflow can proceed, false when + * the caller should stop and let the `'config-missing'` view render. + * + * Three tabs gate the inbox because, in practice, all three are needed + * for any publish request to ever reach this inbox: + * - publish-workflow-config: defines approver routing rules + * - library: makes the request-publish plugin available in DA's sidebar + * so authors can submit a request in the first place + * - apps: registers the inbox app so DA can surface it + * + * Without any one of these, the inbox would render "No Pending Requests" + * indefinitely — which is misleading. We block on all three and surface + * the full status (including the optional tabs) on the setup screen. + * + * Called after the registration check passes so we never block on DA + * config for a site that's also unregistered. + */ + async checkDaConfig(org, site) { + const result = await checkDaConfiguration(org, site, this.token); + this._daConfig = result; + + const required = ['workflowConfig', 'library', 'apps']; + const blocked = required.some((key) => result.status[key]?.state === 'missing'); + if (blocked) { + this._state = 'config-missing'; + return false; + } + + return true; + } + + /** + * Load post-onboarding site settings: live host (from admin.hlx.page) and + * the customer's accent color overrides. + * + * Reuses the DA configs already fetched by `checkDaConfig` (cached on + * `_daConfig`) to derive accent settings without a second round trip to + * `admin.da.live`. For paths that don't go through `checkDaConfig` first + * we'd fall back to fetching, but in practice all callsites do. + */ async loadSiteSettings(org, site) { try { - const [siteConfig, accentSettings] = await Promise.all([ - fetchSiteConfig(org, site), - fetchAccentSettings(org, site, this.token), - ]); - this._liveHost = getLiveHostFromConfig(org, site, siteConfig); - if (accentSettings.accentColor) { - this.style.setProperty('--pw-accent', accentSettings.accentColor); + const hlxConfig = await fetchSiteConfig(org, site); + this._liveHost = getLiveHostFromConfig(org, site, hlxConfig); + + const { siteConfig, orgConfig } = this._daConfig || {}; + const accent = extractAccentSettings(siteConfig, orgConfig); + if (accent.accentColor) { + this.style.setProperty('--pw-accent', accent.accentColor); } - if (accentSettings.accentColorHover) { - this.style.setProperty('--pw-accent-hover', accentSettings.accentColorHover); + if (accent.accentColorHover) { + this.style.setProperty('--pw-accent-hover', accent.accentColorHover); } } catch { this._liveHost = null; @@ -219,6 +284,31 @@ class PublishRequestsApp extends LitElement { return; } + // Check site existence and registration before proceeding + const [siteExists, regStatus] = await Promise.all([ + checkSiteExists(this._org, this._site, this.token), + checkSiteRegistration(this._org, this._site, this.token), + ]); + this._registrationChecked = true; + this._siteRegistered = regStatus.registered; + this._availableProviders = regStatus.availableProviders || []; + + if (!siteExists) { + this._state = 'site-not-found'; + return; + } + + if (!regStatus.registered) { + this._state = 'unregistered'; + return; + } + + // Step 2: DA configuration check. Stop here if the required + // `publish-workflow-config` tab is missing — `checkDaConfig` will have + // set `_state` to `'config-missing'` for us. + const daConfigOk = await this.checkDaConfig(this._org, this._site); + if (!daConfigOk) return; + await this.loadSiteSettings(this._org, this._site); // Sample RUM enhancer if the RUM script is loaded @@ -340,6 +430,9 @@ class PublishRequestsApp extends LitElement { async handleSiteSelect(e) { if (e) e.preventDefault(); this._message = null; + // Drop the previous site's DA config snapshot so a stale status doesn't + // briefly render while the new site's config is being fetched. + this._daConfig = null; const input = this.shadowRoot.querySelector('#org-site'); const orgSite = (input?.value ?? '').trim(); @@ -372,6 +465,43 @@ class PublishRequestsApp extends LitElement { this._path = ''; this._pendingRequests = []; + // Check site existence and registration in parallel + const [siteExists, regStatus] = await Promise.all([ + checkSiteExists(org, site, this.token), + checkSiteRegistration(org, site, this.token), + ]); + this._registrationChecked = true; + this._siteRegistered = regStatus.registered; + this._availableProviders = regStatus.availableProviders || []; + + if (!siteExists) { + this._siteSelectLoading = false; + this._state = 'site-not-found'; + const urlParams = { org, site }; + if (this._requester) urlParams.requester = 'true'; + this.updateUrl(urlParams); + return; + } + + if (!regStatus.registered) { + this._siteSelectLoading = false; + this._state = 'unregistered'; + const urlParams = { org, site }; + if (this._requester) urlParams.requester = 'true'; + this.updateUrl(urlParams); + return; + } + + // Step 2: DA configuration check. + const daConfigOk = await this.checkDaConfig(org, site); + if (!daConfigOk) { + this._siteSelectLoading = false; + const urlParams = { org, site }; + if (this._requester) urlParams.requester = 'true'; + this.updateUrl(urlParams); + return; + } + await this.loadSiteSettings(org, site); const urlParams = { org, site }; @@ -723,13 +853,74 @@ class PublishRequestsApp extends LitElement { } } + // ======== Registration handlers ======== + + async handleRegister() { + this._registerProcessing = true; + this._message = null; + + const getVal = (id) => (this.shadowRoot.querySelector(`#${id}`)?.value ?? '').trim(); + const emailProvider = this.selectedProvider; + const apiUrl = getVal('reg-api-url'); + const apiKey = getVal('reg-api-key'); + const fromAddress = getVal('reg-from-address'); + const fromName = getVal('reg-from-name'); + const domainsRaw = getVal('reg-allowed-domains'); + const allowedEmailDomains = domainsRaw + ? domainsRaw.split(',').map((d) => d.trim()).filter(Boolean) + : []; + + const emailConfig = { emailProvider }; + if (emailProvider === 'custom-api') { + if (!apiUrl) { + this._registerProcessing = false; + this._message = { type: 'error', text: 'API URL is required for custom API provider.' }; + return; + } + emailConfig.apiUrl = apiUrl; + if (apiKey) emailConfig.apiKey = apiKey; + if (fromAddress) emailConfig.fromAddress = fromAddress; + if (fromName) emailConfig.fromName = fromName; + } + if (allowedEmailDomains.length > 0) emailConfig.allowedEmailDomains = allowedEmailDomains; + + const result = await registerSite(this._org, this._site, emailConfig, this.token); + this._registerProcessing = false; + + if (result.success) { + this._siteRegistered = true; + this._message = { type: 'success', text: `Site ${this._org}/${this._site} registered successfully!` }; + + // Now proceed to load the inbox + this._siteSelectLoading = true; + try { + // Step 2: DA configuration check. A freshly-registered site is + // very likely to not have its DA config set up yet, so this is + // the natural place to surface the next setup step. + const daConfigOk = await this.checkDaConfig(this._org, this._site); + if (!daConfigOk) return; + + await this.loadSiteSettings(this._org, this._site); + if (this._requester) { + await this.initMyRequests(); + } else { + await this.initInbox(); + } + } finally { + this._siteSelectLoading = false; + } + } else { + this._message = { type: 'error', text: result.error }; + } + } + // ======== Render helpers ======== renderLoading() { return html`
-

Loading…

+

Loading...

`; } @@ -768,7 +959,7 @@ class PublishRequestsApp extends LitElement { class="pw-fill-accent site-select-submit" @click=${() => this.handleSiteSelect()} > - ${primaryLabel} + ${this._siteSelectLoading ? 'Loading...' : primaryLabel} @@ -787,6 +978,12 @@ class PublishRequestsApp extends LitElement { return this.renderUnauthorized(); case 'no-request': return this.renderNoRequest(); + case 'site-not-found': + return this.renderSiteNotFound(); + case 'unregistered': + return this.renderUnregistered(); + case 'config-missing': + return this.renderConfigMissing(); case 'inbox': return this.renderInbox(); case 'my-requests': @@ -802,6 +999,318 @@ class PublishRequestsApp extends LitElement { } } + // ======== Unregistered site — registration form ======== + + renderUnregistered() { + return html` +
+
+
+ +
+

Site Not Registered

+

+ ${this._org}/${this._site} is not yet registered for the publish workflow. + Register it to enable publish request approvals and email notifications. +

+
+ + ${this.renderRegisterForm()} +
+ `; + } + + getProviderLabel(provider) { + const labels = { + default: 'Default', + 'custom-api': 'Custom API', + }; + return labels[provider] || provider; + } + + get selectedProvider() { + const providers = this._availableProviders; + if (providers.length <= 1) return providers[0] || 'custom-api'; + const sel = this.shadowRoot?.querySelector('#reg-email-provider'); + return sel?.value || providers[0]; + } + + renderRegisterForm() { + const providers = this._availableProviders; + const showDropdown = providers.length > 1; + + return html` +
+

Registration Settings

+

Configure the email provider and notification settings for this site.

+ +
+ ${showDropdown ? html` +
+ + +
+ ` : nothing} + + ${this.selectedProvider === 'custom-api' ? this.renderCustomApiFieldsAlways() : nothing} + +
+ + + Comma-separated list of domains allowed to receive notifications. +
+ + ${this.renderMessage()} + +
+ this.handleRegister()} + ?disabled=${this._registerProcessing} + > + ${this._registerProcessing ? 'Registering...' : 'Register'} + +
+
+
+ `; + } + + renderCustomApiFieldsAlways() { + return html` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + } + + // ======== Site not found render ======== + + renderSiteNotFound() { + return html` +
+
+ +
+

Site Not Available

+

+ The site ${this._org}/${this._site} could not be found. + Please check the organization and site names and try again. +

+
+ `; + } + + // ======== Config missing (DA setup) render ======== + + /** + * Re-run the DA configuration check from the `config-missing` setup + * screen. If everything required is now in place, fall through to the + * normal post-check flow (load settings → inbox or my-requests). Otherwise + * `checkDaConfig` will leave us in `'config-missing'` with a refreshed + * status list. + */ + async handleRecheckConfig() { + if (this._daConfigRechecking) return; + this._daConfigRechecking = true; + try { + const ok = await this.checkDaConfig(this._org, this._site); + if (!ok) return; + + this._state = 'loading'; + await this.loadSiteSettings(this._org, this._site); + if (this._requester) { + await this.initMyRequests(); + } else { + await this.initInbox(); + } + } finally { + this._daConfigRechecking = false; + } + } + + /** + * Map a per-tab status object to the visual treatment used in the row. + * Centralizing the (state → icon variant + text + tone) mapping keeps the + * blocking config-missing page and the toolbar Setup panel in sync. + * + * @param {{state: string, source: 'site'|'org'|null}} status + * @returns {{variant: 'ok'|'missing'|'neutral', label: string}} + */ + // eslint-disable-next-line class-methods-use-this + describeStatus(status) { + let sourceSuffix = ''; + if (status?.source === 'site') sourceSuffix = ' (site-level)'; + else if (status?.source === 'org') sourceSuffix = ' (org-level)'; + switch (status?.state) { + case 'ok': + case 'configured': + return { variant: 'ok', label: `Configured${sourceSuffix}` }; + case 'default': + return { variant: 'neutral', label: 'Using documented defaults' }; + case 'not-needed': + return { + variant: 'neutral', + label: 'Not needed — approver rules use individual emails', + }; + case 'empty': + return { variant: 'missing', label: `Tab present but empty${sourceSuffix}` }; + case 'missing': + default: + return { variant: 'missing', label: 'Not configured' }; + } + } + + renderConfigStatusRow(label, status) { + const { variant, label: statusLabel } = this.describeStatus(status); + const iconClass = `config-status-icon--${variant}`; + let icon; + if (variant === 'ok') { + icon = html``; + } else if (variant === 'missing') { + icon = html``; + } else { + icon = html``; + } + return html` +
  • + + ${label} + ${statusLabel} +
  • + `; + } + + /** + * Render the full 5-tab setup status list. Shared between the + * `config-missing` blocking page and the toolbar Setup panel so the two + * surfaces never drift. + */ + renderSetupRows() { + const status = this._daConfig?.status; + if (!status) return nothing; + return html` +
      + ${this.renderConfigStatusRow('publish-workflow-config (required)', status.workflowConfig)} + ${this.renderConfigStatusRow('library — Request Publish plugin (required)', status.library)} + ${this.renderConfigStatusRow('apps — Publish Requests Inbox (required)', status.apps)} + ${this.renderConfigStatusRow('publish-workflow-settings (optional)', status.workflowSettings)} + ${this.renderConfigStatusRow('publish-workflow-groups-to-email (optional)', status.groupsToEmail)} +
    + `; + } + + renderConfigMissing() { + const fetchError = this._daConfig?.error; + const siteConfigUrl = `https://da.live/config#/${this._org}/${this._site}/`; + const orgConfigUrl = `https://da.live/config#/${this._org}/`; + + return html` +
    +
    +
    + +
    +

    DA Configuration Required

    +

    + This site is registered, but the DA-side setup for the Request + Publish workflow isn't complete yet. Finish the required steps + below in your DA config sheet, then re-check. +

    +

    + Site + ${this._org}/${this._site} +

    +
    + +
    +

    Configuration status

    + ${fetchError ? html`

    ${fetchError}

    ` : nothing} + ${this.renderSetupRows()} +

    + Where to add these tabs: +

    +
      +
    • + Site config — + open the site config editor + for ${this._org}/${this._site}. + library and apps must + live here — DA doesn't inherit them from org config. Workflow + tabs (publish-workflow-config, + publish-workflow-settings, + publish-workflow-groups-to-email) added here apply + only to this site. +
    • +
    • + Org config — + open the org config editor + for ${this._org}. Only the workflow tabs are + inherited from org config; rows added here apply to every site + under the org unless a site overrides the same tab. Site-level + workflow config takes precedence over org-level. +
    • +
    +

    + See the + Request Publish setup guide + for the exact rows each tab needs. The + publish-workflow-config, library, and + apps tabs are all required — without them, no publish + requests can reach this inbox. +

    +
    + this.handleRecheckConfig()} + ?disabled=${this._daConfigRechecking} + > + ${this._daConfigRechecking ? 'Re-checking...' : 'Re-check configuration'} + +
    +
    +
    + `; + } + // ======== Inbox renders ======== renderInbox() { @@ -1245,9 +1754,13 @@ class PublishRequestsApp extends LitElement { if (this._state === 'loading') { return this.renderLoading(); } + // Hide the org/site toolbar during the onboarding states (registration + // and DA configuration). The full-page status view owns the screen + // until the user has finished those setup steps. + const hideToolbar = this._state === 'unregistered' || this._state === 'config-missing'; return html` - ${this.renderToolbar()} - ${this.renderMessage()} + ${hideToolbar ? nothing : this.renderMessage()} + ${hideToolbar ? nothing : this.renderToolbar()}
    ${this.renderContent()}
    diff --git a/tools/plugins/request-for-publish/request-for-publish.html b/tools/plugins/request-for-publish/request-for-publish.html index 55d3414..771c248 100644 --- a/tools/plugins/request-for-publish/request-for-publish.html +++ b/tools/plugins/request-for-publish/request-for-publish.html @@ -1,6 +1,7 @@ + Request Publish diff --git a/tools/plugins/request-for-publish/request-for-publish.js b/tools/plugins/request-for-publish/request-for-publish.js index 8a6568a..2423124 100644 --- a/tools/plugins/request-for-publish/request-for-publish.js +++ b/tools/plugins/request-for-publish/request-for-publish.js @@ -10,6 +10,7 @@ import { withdrawPublishRequest, getUserEmail, checkExistingRequest, + checkSiteRegistration, } from './utils.js'; // Super Lite (sl-*) — Spectrum-aligned controls for DA; pairs with S2 tokens in CSS. @@ -66,6 +67,8 @@ class RequestForPublishPlugin extends LitElement { _commentsRequired: { state: true }, _commentsMinLength: { state: true }, _submitPhase: { state: true }, + _siteRegistered: { state: true }, + _configMissing: { state: true }, }; constructor() { @@ -85,6 +88,8 @@ class RequestForPublishPlugin extends LitElement { this._commentsRequired = false; this._commentsMinLength = 10; this._submitPhase = ''; + this._siteRegistered = null; + this._configMissing = false; } connectedCallback() { @@ -123,14 +128,27 @@ class RequestForPublishPlugin extends LitElement { return `https://da.live/app/aemsites/da-blog-tools/tools/apps/publish-requests-inbox/publish-requests-inbox?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}&requester=true`; } + get inboxAppUrl() { + const { org, repo: site } = this.context; + return `https://da.live/app/aemsites/da-blog-tools/tools/apps/publish-requests-inbox/publish-requests-inbox?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}`; + } + async init() { this._isLoading = true; // Fetch user email from Adobe IMS profile this._userEmail = await getUserEmail(this.token); - // Detect approvers for this content path const { org, repo: site } = this.context; + + // Check if the site is registered for the publish workflow + this._siteRegistered = await checkSiteRegistration(org, site, this.token); + if (!this._siteRegistered) { + this._isLoading = false; + return; + } + + // Detect approvers for this content path const result = await resolveWorkflowConfig(this.contentPath, org, site, this.token); this._approvers = result.approvers || []; this._cc = result.cc || []; @@ -145,7 +163,17 @@ class RequestForPublishPlugin extends LitElement { this.style.setProperty('--pw-accent-hover', result.accentColorHover); } - // Show error if config is missing or no matching rule found + // If the publish-workflow-config tab is missing entirely, route the user + // to the inbox app to finish setup (mirrors the unregistered flow). + if (result.source === 'error') { + this._configMissing = true; + this._isLoading = false; + return; + } + + // For other errors (e.g. config exists but no matching rule for this path), + // keep the inline banner — that's a different problem the admin can fix + // by adding a rule to the existing publish-workflow-config tab. if (result.error) { this._message = { type: 'error', text: result.error }; this._isLoading = false; @@ -273,11 +301,56 @@ class RequestForPublishPlugin extends LitElement { } } + renderUnregistered() { + const { org, repo: site } = this.context; + return html` +
    +
    + +
    +

    Site Not Registered

    +

    + ${org}/${site} is not yet registered for the publish workflow. + A site administrator needs to register it before publish requests can be submitted. +

    +

    + + + Go to Publish Requests Inbox to register this site + +

    +
    + `; + } + + renderConfigMissing() { + const { org, repo: site } = this.context; + return html` +
    +
    + +
    +

    Setup Incomplete

    +

    + ${org}/${site} is registered, but its publish workflow + configuration isn't complete yet. A site administrator needs to finish + setup before publish requests can be submitted. +

    +

    + + + Go to Publish Requests Inbox to finish setup + +

    +
    + `; + } + renderLoading() { return html`
    -

    Loading…

    +

    Loading...

    `; } @@ -474,6 +547,14 @@ class RequestForPublishPlugin extends LitElement { return this.renderLoading(); } + if (this._siteRegistered === false) { + return this.renderUnregistered(); + } + + if (this._configMissing) { + return this.renderConfigMissing(); + } + if (this._withdrawn) { return this.renderWithdrawn(); } diff --git a/tools/plugins/request-for-publish/utils.js b/tools/plugins/request-for-publish/utils.js index b5c5c3a..c317d83 100644 --- a/tools/plugins/request-for-publish/utils.js +++ b/tools/plugins/request-for-publish/utils.js @@ -596,6 +596,33 @@ export async function checkExistingRequest(org, site, path, requesterEmail, toke } } +/** + * Check whether org/site is registered in the worker's KV store. + * GET /api/site-config?org={org}&site={site} + * + * The worker returns different shapes depending on registration status: + * - Registered: 200 with the site config object + * - Not registered: 200 with { error, availableProviders: string[] } + * - Legacy fallback: 404 for unregistered (older worker versions) + * + * @param {string} org - Organization + * @param {string} site - Site + * @param {string} token - Authorization token + * @returns {Promise} true if registered, false otherwise + */ +export async function checkSiteRegistration(org, site, token) { + try { + const url = `${getWorkerUrl()}/api/site-config?org=${encodeURIComponent(org)}&site=${encodeURIComponent(site)}`; + const resp = await fetch(url, getOpts(token, 'GET')); + let data; + try { data = await resp.json(); } catch { data = null; } + if (data?.availableProviders) return false; + return resp.ok; + } catch { + return false; + } +} + /** * Fetch the current user's email from Adobe IMS profile * @param {string} token - The authorization token