ui: harden workspace setup rendering

This commit is contained in:
a.tolmachev
2026-05-02 13:28:35 +00:00
parent a8dcd574fb
commit 73de4d4024
+218 -48
View File
@@ -66,6 +66,35 @@ function formElements() {
};
}
function createElement(tag, className, text) {
var node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined && text !== null) {
node.textContent = text;
}
return node;
}
function buildIconSvg(href, width, height, viewBox, attrs) {
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', String(width));
svg.setAttribute('height', String(height));
if (viewBox) {
svg.setAttribute('viewBox', viewBox);
}
if (attrs) {
Object.keys(attrs).forEach(function(key) {
svg.setAttribute(key, attrs[key]);
});
}
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', href);
svg.appendChild(use);
return svg;
}
function workspacePayload() {
var elements = formElements();
return {
@@ -111,30 +140,89 @@ function addMemberRow(container, membership) {
row.className = 'member-row';
var isCurrentUser = membership.user.id === workspaceFormState.currentUserId;
var canManage = membership.role !== 'owner';
row.innerHTML =
'<div class="member-avatar" style="background:linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1);">' +
membership.user.display_name.slice(0, 2).toUpperCase() +
'</div>' +
'<div class="member-info">' +
'<div class="member-name">' + membership.user.display_name +
(isCurrentUser ? ' <span style="font-size:11px;font-weight:400;color:var(--text-muted);">(you)</span>' : '') +
'</div>' +
'<div class="member-email">' + membership.user.email + '</div>' +
'</div>' +
'<div class="member-last-active">—</div>' +
(
canManage
? '<select class="member-role-select" data-previous="' + membership.role + '" onchange="updateRole(this, \'' + membership.user.id + '\')">' +
'<option value="owner"' + (membership.role === 'owner' ? ' selected' : '') + '>' + tKey('workspace_setup.role.owner') + '</option>' +
'<option value="admin"' + (membership.role === 'admin' ? ' selected' : '') + '>' + tKey('workspace_setup.role.admin') + '</option>' +
'<option value="operator"' + (membership.role === 'operator' ? ' selected' : '') + '>' + tKey('workspace_setup.role.operator') + '</option>' +
'<option value="viewer"' + (membership.role === 'viewer' ? ' selected' : '') + '>' + tKey('workspace_setup.role.viewer') + '</option>' +
'</select>' +
'<button class="member-remove-btn" type="button" onclick="removeMember(\'' + membership.user.id + '\', \'' + membership.user.display_name.replace(/'/g, "\\'") + '\')" title="' + tKey('workspace_setup.members.remove') + '">' +
'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>' +
'</button>'
: '<span class="member-role-badge ' + roleClass(membership.role) + '">' + roleLabel(membership.role) + '</span>'
var avatar = createElement(
'div',
'member-avatar',
membership.user.display_name.slice(0, 2).toUpperCase()
);
avatar.style.background =
'linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1)';
row.appendChild(avatar);
var info = createElement('div', 'member-info');
var name = createElement('div', 'member-name', membership.user.display_name);
if (isCurrentUser) {
name.appendChild(document.createTextNode(' '));
var currentUserLabel = createElement('span', null, '(you)');
currentUserLabel.style.fontSize = '11px';
currentUserLabel.style.fontWeight = '400';
currentUserLabel.style.color = 'var(--text-muted)';
name.appendChild(currentUserLabel);
}
info.appendChild(name);
info.appendChild(createElement('div', 'member-email', membership.user.email));
row.appendChild(info);
row.appendChild(createElement('div', 'member-last-active', '—'));
if (canManage) {
var select = createElement('select', 'member-role-select');
select.dataset.previous = membership.role;
[
['owner', tKey('workspace_setup.role.owner')],
['admin', tKey('workspace_setup.role.admin')],
['operator', tKey('workspace_setup.role.operator')],
['viewer', tKey('workspace_setup.role.viewer')]
].forEach(function(optionData) {
var option = document.createElement('option');
option.value = optionData[0];
option.textContent = optionData[1];
option.selected = membership.role === optionData[0];
select.appendChild(option);
});
select.addEventListener('change', function() {
updateRole(select, membership.user.id);
});
row.appendChild(select);
var removeButton = createElement('button', 'member-remove-btn');
removeButton.type = 'button';
removeButton.title = tKey('workspace_setup.members.remove');
removeButton.appendChild(
buildIconSvg(
'',
12,
12,
'0 0 16 16',
{
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.8',
'stroke-linecap': 'round',
'stroke-linejoin': 'round'
}
)
);
var removeSvg = removeButton.querySelector('svg');
var pathA = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathA.setAttribute('d', 'M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4');
var pathB = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathB.setAttribute('d', 'M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9');
removeSvg.appendChild(pathA);
removeSvg.appendChild(pathB);
removeButton.addEventListener('click', function() {
removeMember(membership.user.id, membership.user.display_name);
});
row.appendChild(removeButton);
} else {
row.appendChild(
createElement(
'span',
'member-role-badge ' + roleClass(membership.role),
roleLabel(membership.role)
)
);
}
container.appendChild(row);
}
@@ -174,26 +262,80 @@ function renderInvitations() {
}
pending.style.display = '';
pending.innerHTML =
'<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">' +
tKey('workspace_setup.members.pending') + ' <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">' + items.length + '</span>' +
'</div>';
pending.innerHTML = '';
var heading = createElement('div', null, tKey('workspace_setup.members.pending') + ' ');
heading.style.fontSize = '11px';
heading.style.fontWeight = '600';
heading.style.textTransform = 'uppercase';
heading.style.letterSpacing = '0.06em';
heading.style.color = 'var(--text-muted)';
heading.style.marginBottom = '10px';
var countPill = createElement('span', null, String(items.length));
countPill.id = 'pending-count';
countPill.style.background = 'rgba(139,148,158,0.15)';
countPill.style.borderRadius = '10px';
countPill.style.padding = '1px 7px';
countPill.style.fontSize = '10px';
heading.appendChild(countPill);
pending.appendChild(heading);
items.forEach(function (record) {
var invitation = record.invitation;
var row = document.createElement('div');
row.className = 'member-row';
row.innerHTML =
'<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>' +
'<div class="member-info">' +
'<div class="member-name">' + invitation.email + '</div>' +
'<div class="member-email">' + tfKey('workspace_setup.members.invited_pending', { date: invitation.created_at.slice(0, 10) }) + '</div>' +
'</div>' +
'<div class="member-last-active">—</div>' +
'<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);">' + roleLabel(invitation.role) + '</span>' +
'<button class="member-remove-btn" type="button" onclick="revokeInviteById(\'' + invitation.id + '\')" title="' + tKey('workspace_setup.members.revoke') + '">' +
'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>' +
'</button>';
var inviteAvatar = createElement('div', 'member-avatar', '?');
inviteAvatar.style.background = 'var(--bg-canvas)';
inviteAvatar.style.border = '1.5px dashed var(--border)';
inviteAvatar.style.color = 'var(--text-muted)';
inviteAvatar.style.fontSize = '16px';
row.appendChild(inviteAvatar);
var inviteInfo = createElement('div', 'member-info');
inviteInfo.appendChild(createElement('div', 'member-name', invitation.email));
inviteInfo.appendChild(
createElement(
'div',
'member-email',
tfKey('workspace_setup.members.invited_pending', { date: invitation.created_at.slice(0, 10) })
)
);
row.appendChild(inviteInfo);
row.appendChild(createElement('div', 'member-last-active', '—'));
var roleBadge = createElement('span', 'member-role-badge', roleLabel(invitation.role));
roleBadge.style.background = 'rgba(139,148,158,0.12)';
roleBadge.style.color = 'var(--text-muted)';
roleBadge.style.border = '1px solid var(--border)';
row.appendChild(roleBadge);
var revokeButton = createElement('button', 'member-remove-btn');
revokeButton.type = 'button';
revokeButton.title = tKey('workspace_setup.members.revoke');
var revokeSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
revokeSvg.setAttribute('width', '12');
revokeSvg.setAttribute('height', '12');
revokeSvg.setAttribute('viewBox', '0 0 16 16');
revokeSvg.setAttribute('fill', 'none');
revokeSvg.setAttribute('stroke', 'currentColor');
revokeSvg.setAttribute('stroke-width', '1.8');
revokeSvg.setAttribute('stroke-linecap', 'round');
var lineA = document.createElementNS('http://www.w3.org/2000/svg', 'line');
lineA.setAttribute('x1', '2');
lineA.setAttribute('y1', '2');
lineA.setAttribute('x2', '14');
lineA.setAttribute('y2', '14');
var lineB = document.createElementNS('http://www.w3.org/2000/svg', 'line');
lineB.setAttribute('x1', '14');
lineB.setAttribute('y1', '2');
lineB.setAttribute('x2', '2');
lineB.setAttribute('y2', '14');
revokeSvg.appendChild(lineA);
revokeSvg.appendChild(lineB);
revokeButton.appendChild(revokeSvg);
revokeButton.addEventListener('click', function() {
revokeInviteById(invitation.id);
});
row.appendChild(revokeButton);
pending.appendChild(row);
});
}
@@ -364,16 +506,44 @@ function addInviteRow() {
var rows = document.getElementById('invite-rows');
var node = document.createElement('div');
node.className = 'invite-row';
node.innerHTML =
'<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">' +
'<select class="form-input" style="width:130px;margin-bottom:0;">' +
'<option value="admin">' + tKey('workspace_setup.role.admin') + '</option>' +
'<option value="developer" selected>' + tKey('workspace_setup.role.operator') + '</option>' +
'<option value="viewer">' + tKey('workspace_setup.role.viewer') + '</option>' +
'</select>' +
'<button class="invite-row-remove" type="button" onclick="removeInviteRow(this)" title="' + tKey('workspace_setup.members.remove') + '">' +
'<svg width="12" height="12"><use href="' + (window.APP_BASE || '') + 'icons/general/close.svg#icon"/></svg>' +
'</button>';
var emailInput = createElement('input', 'form-input');
emailInput.type = 'email';
emailInput.placeholder = 'colleague@company.com';
emailInput.autocomplete = 'off';
emailInput.style.flex = '1';
emailInput.style.marginBottom = '0';
node.appendChild(emailInput);
var roleSelect = createElement('select', 'form-input');
roleSelect.style.width = '130px';
roleSelect.style.marginBottom = '0';
[
['admin', tKey('workspace_setup.role.admin')],
['developer', tKey('workspace_setup.role.operator')],
['viewer', tKey('workspace_setup.role.viewer')]
].forEach(function(entry) {
var option = document.createElement('option');
option.value = entry[0];
option.textContent = entry[1];
option.selected = entry[0] === 'developer';
roleSelect.appendChild(option);
});
node.appendChild(roleSelect);
var removeButton = createElement('button', 'invite-row-remove');
removeButton.type = 'button';
removeButton.title = tKey('workspace_setup.members.remove');
removeButton.appendChild(
buildIconSvg(
(window.APP_BASE || '') + 'icons/general/close.svg#icon',
12,
12
)
);
removeButton.addEventListener('click', function() {
removeInviteRow(removeButton);
});
node.appendChild(removeButton);
rows.appendChild(node);
}