ui: harden workspace and secrets rendering

This commit is contained in:
a.tolmachev
2026-04-12 02:05:05 +03:00
parent f30c63146b
commit c69c327123
14 changed files with 219 additions and 62 deletions
+6 -7
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="css/wizard.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/workspace.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
+1
View File
@@ -25,6 +25,7 @@
</style>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/auth.js"></script>
+1
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="css/pages.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
+1
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="css/logs.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
+1
View File
@@ -42,6 +42,7 @@
</style>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/auth.js"></script>
+1
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="css/settings.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
+1
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="css/pages.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
+1
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="css/usage.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
+1
View File
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="css/workspace-setup.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/auth.js"></script>
+1
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="css/pages.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/dom.js"></script>
<script src="js/workspace.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
+32
View File
@@ -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,
};
}());
+120 -40
View File
@@ -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 = [
'<td class="col-name">' + secret.name + '</td>',
'<td>' + secretKindLabel(secret.kind) + '</td>',
'<td class="col-mono">v' + secret.current_version + '</td>',
'<td>' + (secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never')) + '</td>',
'<td>' + window.tPlural('secrets.used_by_count', references.length, { count: references.length }) + '</td>',
'<td><span class="badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active') + '">' + tKey('secrets.status.' + secret.status) + '</span></td>',
'<td class="col-actions"></td>'
].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 = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.error_title') + '</div><div class="empty-state-text">' + state.error + '</div></div>';
profilesList.appendChild(window.CrankDom.createEmptyState(
tKey('secrets.profiles.error_title'),
state.error
));
return;
}
if (state.loading && state.profiles.length === 0) {
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.loading_title') + '</div><div class="empty-state-text">' + tKey('secrets.profiles.loading_body') + '</div></div>';
profilesList.appendChild(window.CrankDom.createEmptyState(
tKey('secrets.profiles.loading_title'),
tKey('secrets.profiles.loading_body')
));
return;
}
if (!state.profiles.length) {
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.empty_title') + '</div><div class="empty-state-text">' + tKey('secrets.profiles.empty_body') + '</div></div>';
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 '<span class="secret-ref-pill"><strong>' + secretName(secretsById, secretId) + '</strong><span>' + tfKey('secrets.profiles.reference_count', { count: users.length }) + '</span></span>';
}).join('');
return [
'<div class="resource-card">',
' <div class="resource-card-header">',
' <div>',
' <div class="resource-card-title">' + profile.name + '</div>',
' <div class="resource-card-subtitle">' + profileSummary(profile, secretsById) + '</div>',
' <div class="resource-pill-row">',
' <span class="resource-status-pill active">' + authKindLabel(profile.kind) + '</span>',
' </div>',
' </div>',
' </div>',
' <div class="resource-meta-grid">',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('secrets.profiles.meta.created') + '</div><div class="resource-meta-value">' + formatDate(profile.created_at) + '</div></div>',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('secrets.profiles.meta.updated') + '</div><div class="resource-meta-value">' + formatDate(profile.updated_at) + '</div></div>',
' </div>',
' <div class="resource-detail-block">',
' <div class="resource-detail-title">' + tKey('secrets.profiles.references') + '</div>',
' <div class="secret-ref-list">' + references + '</div>',
' </div>',
'</div>'
].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() {
+51 -15
View File
@@ -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 '<div class="ws-dropdown-item' + (active ? ' active' : '') + '" onclick="switchWorkspace(\'' + workspace.id + '\')">' +
'<div class="ws-item-dot" style="background:' + workspace.color + '">' + workspace.letter + '</div>' +
'<div class="ws-item-info">' +
'<div class="ws-item-name">' + workspace.name + '</div>' +
'<div class="ws-item-role">' + workspace.role + '</div>' +
'</div>' +
(active ? '<svg width="12" height="12"><use href="' + (window.APP_BASE || '') + 'icons/general/check.svg#icon"/></svg>' : '') +
'</div>';
}).join('') +
'<div class="ws-dropdown-divider"></div>' +
'<a class="ws-dropdown-mgmt-link" href="' + ((window.CrankRoutes && window.CrankRoutes.workspaceSetup) || '/workspace-setup') + '" onclick="var dd=document.getElementById(\'ws-dropdown\');if(dd)dd.style.display=\'none\'">' +
'<svg width="14" height="14"><use href="' + (window.APP_BASE || '') + 'icons/general/settings.svg#icon"/></svg>' +
tKey('settings.ws.title') +
'</a>';
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 = '<use href="' + (window.APP_BASE || '') + 'icons/general/check.svg#icon"></use>';
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 = '<svg width="14" height="14"><use href="' + (window.APP_BASE || '') + 'icons/general/settings.svg#icon"/></svg>';
manageLink.appendChild(document.createTextNode(tKey('settings.ws.title')));
list.appendChild(manageLink);
}
async function loadWorkspaces() {