diff --git a/TASKS.md b/TASKS.md index d5f3809..6c3df06 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,19 @@ ## Current -### `feat/frontend-xss-hardening` +### `feat/frontend-shell-unification` Status: in_progress DoD: -- API-derived data is not inserted into `innerHTML` without escaping -- shared DOM-safe helpers are introduced for frontend rendering paths that still need markup -- known unsafe rendering paths from `__REVIEW_FRONT.md` are removed or hardened -- the current UI behavior remains intact after the hardening pass -- targeted frontend smoke checks cover the touched pages +- duplicated shell identity logic is removed from `auth.js` and `nav.js` +- one shared source of truth renders avatar, name, role, and menu state +- pages keep the current shell behavior after the consolidation +- no page depends on divergent copies of `crank_user` handling ## Next -- `feat/frontend-shell-unification` +- `feat/frontend-wizard-modularization` ## Backlog diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index ac052c0..439f69d 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -13,6 +13,7 @@ + diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index 7ed42a5..28a02a7 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -25,6 +25,7 @@ + diff --git a/apps/ui/html/async-jobs.html b/apps/ui/html/async-jobs.html index 0e3cafd..87847b5 100644 --- a/apps/ui/html/async-jobs.html +++ b/apps/ui/html/async-jobs.html @@ -11,6 +11,7 @@ + diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html index 48466f2..d488b21 100644 --- a/apps/ui/html/logs.html +++ b/apps/ui/html/logs.html @@ -12,6 +12,7 @@ + diff --git a/apps/ui/html/secrets.html b/apps/ui/html/secrets.html index 98d73aa..1a355ae 100644 --- a/apps/ui/html/secrets.html +++ b/apps/ui/html/secrets.html @@ -42,6 +42,7 @@ + diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index dbba109..9bbac1d 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -12,6 +12,7 @@ + diff --git a/apps/ui/html/stream-sessions.html b/apps/ui/html/stream-sessions.html index 446b6a4..610d4e2 100644 --- a/apps/ui/html/stream-sessions.html +++ b/apps/ui/html/stream-sessions.html @@ -11,6 +11,7 @@ + diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html index 306a7a2..6916f86 100644 --- a/apps/ui/html/usage.html +++ b/apps/ui/html/usage.html @@ -12,6 +12,7 @@ + diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index 4fa41fd..1734dab 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -13,6 +13,7 @@ + diff --git a/apps/ui/index.html b/apps/ui/index.html index b4adbb0..8063d5a 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -11,6 +11,7 @@ + diff --git a/apps/ui/js/dom.js b/apps/ui/js/dom.js new file mode 100644 index 0000000..6bb4e95 --- /dev/null +++ b/apps/ui/js/dom.js @@ -0,0 +1,32 @@ +(function() { + function clear(element) { + if (!element) { + return; + } + while (element.firstChild) { + element.removeChild(element.firstChild); + } + } + + function createEmptyState(title, body) { + var root = document.createElement('div'); + root.className = 'empty-state'; + + var titleNode = document.createElement('div'); + titleNode.className = 'empty-state-title'; + titleNode.textContent = title; + root.appendChild(titleNode); + + var bodyNode = document.createElement('div'); + bodyNode.className = 'empty-state-text'; + bodyNode.textContent = body; + root.appendChild(bodyNode); + + return root; + } + + window.CrankDom = { + clear: clear, + createEmptyState: createEmptyState, + }; +}()); diff --git a/apps/ui/js/secrets.js b/apps/ui/js/secrets.js index fd958b6..8ac166c 100644 --- a/apps/ui/js/secrets.js +++ b/apps/ui/js/secrets.js @@ -222,7 +222,7 @@ document.addEventListener('DOMContentLoaded', function () { total: state.secrets.length, }); - tbody.innerHTML = ''; + window.CrankDom.clear(tbody); if (state.loading && state.secrets.length === 0) { var loadingRow = document.createElement('tr'); @@ -260,17 +260,38 @@ document.addEventListener('DOMContentLoaded', function () { rows.forEach(function (secret) { var tr = document.createElement('tr'); var references = usage[secret.id] || []; - tr.innerHTML = [ - '' + secret.name + '', - '' + secretKindLabel(secret.kind) + '', - 'v' + secret.current_version + '', - '' + (secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never')) + '', - '' + window.tPlural('secrets.used_by_count', references.length, { count: references.length }) + '', - '' + tKey('secrets.status.' + secret.status) + '', - '' - ].join(''); - var actions = tr.querySelector('.col-actions'); + var nameCell = document.createElement('td'); + nameCell.className = 'col-name'; + nameCell.textContent = secret.name; + tr.appendChild(nameCell); + + var kindCell = document.createElement('td'); + kindCell.textContent = secretKindLabel(secret.kind); + tr.appendChild(kindCell); + + var versionCell = document.createElement('td'); + versionCell.className = 'col-mono'; + versionCell.textContent = 'v' + secret.current_version; + tr.appendChild(versionCell); + + var lastUsedCell = document.createElement('td'); + lastUsedCell.textContent = secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never'); + tr.appendChild(lastUsedCell); + + var usedByCell = document.createElement('td'); + usedByCell.textContent = window.tPlural('secrets.used_by_count', references.length, { count: references.length }); + tr.appendChild(usedByCell); + + var statusCell = document.createElement('td'); + var badge = document.createElement('span'); + badge.className = 'badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active'); + badge.textContent = tKey('secrets.status.' + secret.status); + statusCell.appendChild(badge); + tr.appendChild(statusCell); + + var actions = document.createElement('td'); + actions.className = 'col-actions'; var rotateButton = document.createElement('button'); rotateButton.className = 'icon-btn'; rotateButton.textContent = tKey('secrets.action.rotate'); @@ -286,6 +307,7 @@ document.addEventListener('DOMContentLoaded', function () { await deleteSecret(secret); }); actions.appendChild(deleteButton); + tr.appendChild(actions); tbody.appendChild(tr); }); @@ -304,49 +326,107 @@ document.addEventListener('DOMContentLoaded', function () { { count: state.profiles.length } ); + window.CrankDom.clear(profilesList); + if (state.error) { - profilesList.innerHTML = '
' + tKey('secrets.profiles.error_title') + '
' + state.error + '
'; + profilesList.appendChild(window.CrankDom.createEmptyState( + tKey('secrets.profiles.error_title'), + state.error + )); return; } if (state.loading && state.profiles.length === 0) { - profilesList.innerHTML = '
' + tKey('secrets.profiles.loading_title') + '
' + tKey('secrets.profiles.loading_body') + '
'; + profilesList.appendChild(window.CrankDom.createEmptyState( + tKey('secrets.profiles.loading_title'), + tKey('secrets.profiles.loading_body') + )); return; } if (!state.profiles.length) { - profilesList.innerHTML = '
' + tKey('secrets.profiles.empty_title') + '
' + tKey('secrets.profiles.empty_body') + '
'; + profilesList.appendChild(window.CrankDom.createEmptyState( + tKey('secrets.profiles.empty_title'), + tKey('secrets.profiles.empty_body') + )); return; } - profilesList.innerHTML = state.profiles.map(function (profile) { + state.profiles.forEach(function (profile) { var secretIds = secretIdsForProfile(profile); - var references = secretIds.map(function (secretId) { + var card = document.createElement('div'); + card.className = 'resource-card'; + + var header = document.createElement('div'); + header.className = 'resource-card-header'; + var headerBody = document.createElement('div'); + + var title = document.createElement('div'); + title.className = 'resource-card-title'; + title.textContent = profile.name; + headerBody.appendChild(title); + + var subtitle = document.createElement('div'); + subtitle.className = 'resource-card-subtitle'; + subtitle.textContent = profileSummary(profile, secretsById); + headerBody.appendChild(subtitle); + + var pillRow = document.createElement('div'); + pillRow.className = 'resource-pill-row'; + var statusPill = document.createElement('span'); + statusPill.className = 'resource-status-pill active'; + statusPill.textContent = authKindLabel(profile.kind); + pillRow.appendChild(statusPill); + headerBody.appendChild(pillRow); + header.appendChild(headerBody); + card.appendChild(header); + + var metaGrid = document.createElement('div'); + metaGrid.className = 'resource-meta-grid'; + [ + [tKey('secrets.profiles.meta.created'), formatDate(profile.created_at)], + [tKey('secrets.profiles.meta.updated'), formatDate(profile.updated_at)] + ].forEach(function(entry) { + var item = document.createElement('div'); + item.className = 'resource-meta-item'; + var label = document.createElement('div'); + label.className = 'resource-meta-label'; + label.textContent = entry[0]; + var value = document.createElement('div'); + value.className = 'resource-meta-value'; + value.textContent = entry[1]; + item.appendChild(label); + item.appendChild(value); + metaGrid.appendChild(item); + }); + card.appendChild(metaGrid); + + var detail = document.createElement('div'); + detail.className = 'resource-detail-block'; + var detailTitle = document.createElement('div'); + detailTitle.className = 'resource-detail-title'; + detailTitle.textContent = tKey('secrets.profiles.references'); + detail.appendChild(detailTitle); + + var refList = document.createElement('div'); + refList.className = 'secret-ref-list'; + secretIds.forEach(function(secretId) { var users = usage[secretId] || []; - return '' + secretName(secretsById, secretId) + '' + tfKey('secrets.profiles.reference_count', { count: users.length }) + ''; - }).join(''); - return [ - '
', - '
', - '
', - '
' + profile.name + '
', - '
' + profileSummary(profile, secretsById) + '
', - '
', - ' ' + authKindLabel(profile.kind) + '', - '
', - '
', - '
', - '
', - '
' + tKey('secrets.profiles.meta.created') + '
' + formatDate(profile.created_at) + '
', - '
' + tKey('secrets.profiles.meta.updated') + '
' + formatDate(profile.updated_at) + '
', - '
', - '
', - '
' + tKey('secrets.profiles.references') + '
', - '
' + references + '
', - '
', - '
' - ].join(''); - }).join(''); + var pill = document.createElement('span'); + pill.className = 'secret-ref-pill'; + var strong = document.createElement('strong'); + strong.textContent = secretName(secretsById, secretId); + var count = document.createElement('span'); + count.textContent = tfKey('secrets.profiles.reference_count', { count: users.length }); + pill.appendChild(strong); + pill.appendChild(count); + refList.appendChild(pill); + }); + detail.appendChild(refList); + card.appendChild(detail); + + profilesList.appendChild(card); + }); } async function load() { diff --git a/apps/ui/js/workspace.js b/apps/ui/js/workspace.js index 451b77e..2b82d97 100644 --- a/apps/ui/js/workspace.js +++ b/apps/ui/js/workspace.js @@ -110,23 +110,59 @@ function renderWorkspaceList() { var list = document.getElementById('ws-dropdown-list'); if (!list) return; + window.CrankDom.clear(list); - list.innerHTML = WS_LIST.map(function(workspace) { + WS_LIST.forEach(function(workspace) { var active = current && workspace.id === current.id; - return '
' + - '
' + workspace.letter + '
' + - '
' + - '
' + workspace.name + '
' + - '
' + workspace.role + '
' + - '
' + - (active ? '' : '') + - '
'; - }).join('') + - '
' + - '' + - '' + - tKey('settings.ws.title') + - ''; + var item = document.createElement('div'); + item.className = 'ws-dropdown-item' + (active ? ' active' : ''); + item.addEventListener('click', function() { + switchWorkspace(workspace.id); + }); + + var dot = document.createElement('div'); + dot.className = 'ws-item-dot'; + dot.style.background = workspace.color; + dot.textContent = workspace.letter; + item.appendChild(dot); + + var info = document.createElement('div'); + info.className = 'ws-item-info'; + var name = document.createElement('div'); + name.className = 'ws-item-name'; + name.textContent = workspace.name; + var role = document.createElement('div'); + role.className = 'ws-item-role'; + role.textContent = workspace.role; + info.appendChild(name); + info.appendChild(role); + item.appendChild(info); + + if (active) { + var check = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + check.setAttribute('width', '12'); + check.setAttribute('height', '12'); + check.innerHTML = ''; + item.appendChild(check); + } + + list.appendChild(item); + }); + + var divider = document.createElement('div'); + divider.className = 'ws-dropdown-divider'; + list.appendChild(divider); + + var manageLink = document.createElement('a'); + manageLink.className = 'ws-dropdown-mgmt-link'; + manageLink.href = (window.CrankRoutes && window.CrankRoutes.workspaceSetup) || '/workspace-setup'; + manageLink.addEventListener('click', function() { + var dd = document.getElementById('ws-dropdown'); + if (dd) dd.style.display = 'none'; + }); + manageLink.innerHTML = ''; + manageLink.appendChild(document.createTextNode(tKey('settings.ws.title'))); + list.appendChild(manageLink); } async function loadWorkspaces() {