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 [
- '',
- ' ',
- '
',
- '
',
- '
' + 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() {