mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
feat(workspaces): mutation UI for members (slice 2B)
Completes P2 user-management. Adds the full admin surface for managing workspace membership: invite modal, role change, member remove, cancel pending invite. All admin-gated client-side via can_admin from /me, server-gated via canAdminWorkspace. Component additions: - NEW workspace-members-invite-modal.js (~115 LOC). Mirrors workspace-rename-modal.js pattern (imperative open + listeners + close + esc/click-outside/enter). Two key differences: onSuccess callback instead of window.location.reload (allows targeted re-render of pending-invites section), and mapError callback so the parent's mapMutationError is the single regex-to-i18n source of truth (instead of duplicating in the modal). - workspace-members.js: header invite button (can_admin gated), per-row affordances (role select + remove on direct members, cancel on invited rows, none on via_org rows), exported mapMutationError mapper, re-render on both success AND error for role-select to resync state when the server rejects. - 4 api.js helpers (inviteWorkspaceMember, cancelWorkspaceInvite, updateWorkspaceMemberRole, removeWorkspaceMember). - 24 i18n keys under members.modal.*, members.button.*, members.confirm.*, members.error.*, members.success.* - CSS for .member-actions family (action buttons + role select + hover states). UX decisions: - Direct-member rows: role <select> replaces role text in same column; remove button right of detail - via_org rows: no actions cell (server would 403; UI respects boundary) - Invited rows: cancel button only (handoff rule was over-broad - cancel-invite IS a valid mutation on invited rows, refined during 2B survey) - Role select fires on change, no Save button (matches teams.js pattern; mitigations for accidental clicks noted in handoff if reports come in) - Mutations re-fetch + re-render rather than optimistic updates - simpler, no state-drift bugs, endpoints respond fast - /invites endpoint skipped entirely when !can_admin (saves a request; server still enforces) Verification: 21/21 Playwright assertions PASS across 6 cases (invite happy path, invite collision, role change, remove member, last-admin block, cancel invite). Test infrastructure stashed at ~/Documents/screentinker-2b-playwright-2026-05.py. Closes P2 (user-management feature). Slice 1+3 backend landedc4fbd2b, 2A read-only view landed8db171d, 2C accept-invite handler landed399af54, 2B mutation UI landed here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
399af54839
commit
caa9fd0f40
|
|
@ -163,6 +163,30 @@ body {
|
|||
border-radius: 3px; font-weight: 600;
|
||||
}
|
||||
|
||||
/* Slice 2B - mutation affordances. .member-actions is the cell that holds
|
||||
per-row admin buttons (remove on direct-member rows, cancel on invited
|
||||
rows). via_org rows omit the cell entirely. The role select replaces the
|
||||
.member-role div for admins on direct-member rows. */
|
||||
.member-role-select {
|
||||
flex-shrink: 0; font-size: 12px;
|
||||
background: var(--bg-input); color: var(--text-primary);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 4px 8px; min-width: 90px; cursor: pointer;
|
||||
}
|
||||
.member-role-select:hover { border-color: var(--accent); }
|
||||
.member-actions {
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.member-action-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: none; border: none; padding: 6px;
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
border-radius: 4px; transition: all var(--transition);
|
||||
}
|
||||
.member-action-btn:hover { background: var(--bg-input); }
|
||||
.member-action-btn--danger:hover { color: var(--danger); }
|
||||
|
||||
.nav-links {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
|
|
|
|||
|
|
@ -159,10 +159,18 @@ export const api = {
|
|||
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
|
||||
renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
|
||||
// Workspace members + invites (slice 2A read-only; mutation helpers land in 2B)
|
||||
// Workspace members + invites (slice 2A read-only)
|
||||
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
|
||||
getWorkspaceInvites: (id) => request(`/workspaces/${id}/invites`),
|
||||
|
||||
// Workspace member/invite mutations (slice 2B). All admin-only server-side
|
||||
// (canAdminWorkspace gate). Server returns translated English error messages
|
||||
// mapped to i18n keys via mapMutationError() in workspace-members.js.
|
||||
inviteWorkspaceMember: (workspaceId, data) => request(`/workspaces/${workspaceId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
cancelWorkspaceInvite: (workspaceId, inviteId) => request(`/workspaces/${workspaceId}/invites/${inviteId}`, { method: 'DELETE' }),
|
||||
updateWorkspaceMemberRole: (workspaceId, userId, role) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||
removeWorkspaceMember: (workspaceId, userId) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
// Slice 2C - accept a workspace invite by id (post-auth flow)
|
||||
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
|
||||
|
||||
|
|
|
|||
124
frontend/js/components/workspace-members-invite-modal.js
Normal file
124
frontend/js/components/workspace-members-invite-modal.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// Invite-member modal. Mirrors workspace-rename-modal.js's structure
|
||||
// (overlay + listeners + close + esc/click-outside/enter) with two key
|
||||
// differences:
|
||||
//
|
||||
// 1. On success calls an onSuccess(result) callback instead of
|
||||
// window.location.reload(). The parent view (workspace-members.js)
|
||||
// re-fetches and re-renders just the pending-invites section - no
|
||||
// full-page flash for a single row addition.
|
||||
//
|
||||
// 2. Server errors map to translated strings via a mapError callback
|
||||
// passed by the parent (mapMutationError lives in workspace-members.js).
|
||||
// That keeps a single error mapper for ALL slice 2B mutations rather
|
||||
// than scattering modal-specific copies. Inline display below the form
|
||||
// (not toast) so user can correct + resubmit without closing.
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// open the modal.
|
||||
// workspace: { id, name } - id used for the API call, name shown in title
|
||||
// opts.onSuccess: (result) => void - fires on 200; result is the server
|
||||
// response body { id, email, role, expires_at }
|
||||
// opts.mapError: (err) => string - translates server error to display text
|
||||
export function openInviteMemberModal(workspace, opts = {}) {
|
||||
const { onSuccess, mapError } = opts;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>${t('members.modal.invite_title', { workspace: esc(workspace.name) })}</h3>
|
||||
<button class="btn-icon" type="button" data-invite-close aria-label="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="inviteEmail">${t('members.modal.email_label')}</label>
|
||||
<input id="inviteEmail" type="email" class="input" placeholder="${t('members.modal.email_placeholder')}" style="width:100%" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="inviteRole">${t('members.modal.role_label')}</label>
|
||||
<select id="inviteRole" class="input" style="width:100%">
|
||||
<option value="workspace_viewer">${t('members.role.workspace_viewer')}</option>
|
||||
<option value="workspace_editor">${t('members.role.workspace_editor')}</option>
|
||||
<option value="workspace_admin">${t('members.role.workspace_admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="inviteModalError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" type="button" data-invite-close>${t('members.modal.cancel')}</button>
|
||||
<button class="btn btn-primary" type="button" id="inviteSendBtn">${t('members.modal.send')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const emailInput = overlay.querySelector('#inviteEmail');
|
||||
const roleSelect = overlay.querySelector('#inviteRole');
|
||||
const errorEl = overlay.querySelector('#inviteModalError');
|
||||
const sendBtn = overlay.querySelector('#inviteSendBtn');
|
||||
emailInput.focus();
|
||||
|
||||
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') close();
|
||||
else if (e.key === 'Enter' && (e.target === emailInput || e.target === roleSelect)) send();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelectorAll('[data-invite-close]').forEach(b => b.addEventListener('click', close));
|
||||
|
||||
async function send() {
|
||||
errorEl.style.display = 'none';
|
||||
const email = emailInput.value.trim().toLowerCase();
|
||||
const role = roleSelect.value;
|
||||
// Client-side email validation - server validates too, but this avoids a
|
||||
// round-trip and gives immediate feedback on obvious typos.
|
||||
if (!email || !EMAIL_RE.test(email)) {
|
||||
showError(t('members.error.invalid_email'));
|
||||
emailInput.focus();
|
||||
return;
|
||||
}
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = t('members.modal.sending');
|
||||
try {
|
||||
const result = await api.inviteWorkspaceMember(workspace.id, { email, role });
|
||||
close();
|
||||
// Defensive: undefined onSuccess is a no-op; a thrown onSuccess (parent
|
||||
// bug) is logged but not propagated so the modal-close still succeeded
|
||||
// from the user's perspective.
|
||||
if (typeof onSuccess === 'function') {
|
||||
try { onSuccess(result); }
|
||||
catch (e) { console.error('invite modal onSuccess threw:', e); }
|
||||
}
|
||||
} catch (err) {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = t('members.modal.send');
|
||||
// Map via parent-supplied helper. Fallback to raw message if no mapper
|
||||
// was provided (shouldn't happen in normal use, defensive only).
|
||||
const msg = (typeof mapError === 'function')
|
||||
? mapError(err)
|
||||
: (err?.message || t('members.error.mutation_generic', { error: '' }));
|
||||
showError(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', send);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
|
|
@ -1143,6 +1143,45 @@ export default {
|
|||
'members.load_error': 'Failed to load members: {error}',
|
||||
'members.workspace_not_found': 'Workspace not found or no access.',
|
||||
|
||||
// Mutation UI (Slice 2B): invite modal, action buttons, confirms, error
|
||||
// toasts, success toasts. Grouped under five sub-namespaces for clarity.
|
||||
|
||||
// Modal — invite form
|
||||
'members.modal.invite_title': 'Invite to {workspace}',
|
||||
'members.modal.email_label': 'Email',
|
||||
'members.modal.email_placeholder': 'user@example.com',
|
||||
'members.modal.role_label': 'Role',
|
||||
'members.modal.cancel': 'Cancel',
|
||||
'members.modal.send': 'Send invite',
|
||||
'members.modal.sending': 'Sending...',
|
||||
|
||||
// Buttons — page header + per-row action affordances (titles double as
|
||||
// ARIA labels for the icon-only buttons).
|
||||
'members.button.invite': 'Invite member',
|
||||
'members.button.remove': 'Remove member',
|
||||
'members.button.cancel_invite': 'Cancel invite',
|
||||
|
||||
// Native confirm() text for destructive actions.
|
||||
'members.confirm.remove_member': 'Remove {name} from this workspace?',
|
||||
'members.confirm.cancel_invite': 'Cancel invite for {email}?',
|
||||
|
||||
// Errors mapped from server response text by mapMutationError().
|
||||
'members.error.rate_limit': 'Invite rate limit reached. Try again later.',
|
||||
'members.error.invite_exists': 'An invite for that email is already pending.',
|
||||
'members.error.last_admin_demote': 'Cannot change role - this user is the only admin.',
|
||||
'members.error.last_admin_remove': 'Cannot remove the last admin.',
|
||||
'members.error.already_member': 'That user is already a member of this workspace.',
|
||||
'members.error.invalid_email': 'Please enter a valid email address.',
|
||||
'members.error.org_owner_remove': 'Cannot remove the organization owner.',
|
||||
'members.error.email_send_failed': 'Email send failed. Try again.',
|
||||
'members.error.mutation_generic': 'Action failed: {error}',
|
||||
|
||||
// Success toasts fired post-mutation.
|
||||
'members.success.invite_sent': 'Invite sent to {email}',
|
||||
'members.success.invite_cancelled': 'Invite cancelled',
|
||||
'members.success.role_changed': 'Role updated',
|
||||
'members.success.member_removed': '{name} removed',
|
||||
|
||||
// Accept-invite flow (Slice 2C). Toasts that fire post-accept on the
|
||||
// dashboard. Error variants share one helper in app.js's mapAcceptError().
|
||||
'accept.success': "You've joined {name}",
|
||||
|
|
|
|||
|
|
@ -1,29 +1,42 @@
|
|||
// Workspace members view - read-only listing of direct workspace_members,
|
||||
// org-level access entries (via_org flag), and pending invites. Slice 2A
|
||||
// (read-only only). Slice 2B will add the invite modal + role-change +
|
||||
// remove buttons; slice 2C will add the accept-invite URL handler.
|
||||
// Workspace members view. Slice 2A established the read-only listing;
|
||||
// slice 2B adds the mutation surface (invite modal + per-row role change /
|
||||
// remove / cancel-invite) gated by can_admin from /me.
|
||||
//
|
||||
// Affordance rules (locked from 2A's CSS design, refined during 2B):
|
||||
// - direct-member rows: role select + remove button
|
||||
// - via_org rows: no actions (server would 403; access lives in org_members)
|
||||
// - invited rows: cancel-invite button only (server returns 200)
|
||||
// Server enforces all three boundaries; UI must match.
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { openInviteMemberModal } from '../components/workspace-members-invite-modal.js';
|
||||
|
||||
export async function render(container, workspaceId) {
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>${t('members.title')}</h1>
|
||||
<div id="membersHeaderActions"></div>
|
||||
</div>
|
||||
<div id="workspaceMembersContent" style="color:var(--text-muted)">${t('members.loading')}</div>
|
||||
`;
|
||||
const content = document.getElementById('workspaceMembersContent');
|
||||
const headerActions = document.getElementById('membersHeaderActions');
|
||||
|
||||
let members;
|
||||
// Fetch members, invites, and /me (for can_admin) in parallel. /me is the
|
||||
// source of truth for can_admin in THIS workspace - the same field the
|
||||
// switcher uses to gate the members icon.
|
||||
let members, meWorkspace;
|
||||
try {
|
||||
members = await api.getWorkspaceMembers(workspaceId);
|
||||
const [m, me] = await Promise.all([
|
||||
api.getWorkspaceMembers(workspaceId),
|
||||
api.getMe().catch(() => null),
|
||||
]);
|
||||
members = m;
|
||||
meWorkspace = (me?.accessible_workspaces || []).find(w => w.id === workspaceId) || null;
|
||||
} catch (err) {
|
||||
const msg = err.message || '';
|
||||
// /members is gated by canAccessWorkspace; server returns 403 with
|
||||
// "Workspace access required" or 404 with "Workspace not found". Either
|
||||
// one is the same UX from the caller's perspective: they cannot view
|
||||
// this workspace.
|
||||
if (/Workspace access required|Workspace not found/.test(msg)) {
|
||||
content.innerHTML = renderError(t('members.workspace_not_found'));
|
||||
} else {
|
||||
|
|
@ -32,15 +45,37 @@ export async function render(container, workspaceId) {
|
|||
return;
|
||||
}
|
||||
|
||||
// /invites is admin-only. Non-admin members will get 403; that's expected -
|
||||
// suppress the section silently rather than surfacing an "error" they can't
|
||||
// act on. Other failures also suppress (logged to console for debugging).
|
||||
const canAdmin = !!(meWorkspace && meWorkspace.can_admin);
|
||||
const workspaceName = meWorkspace?.name || '';
|
||||
|
||||
// /invites is admin-only. Non-admins get 403; suppress silently. We could
|
||||
// skip the call entirely when !canAdmin to save a request, but defending
|
||||
// in depth: if /me drift ever leaves can_admin stale, the server still
|
||||
// returns the right answer.
|
||||
let invites = null;
|
||||
try {
|
||||
invites = await api.getWorkspaceInvites(workspaceId);
|
||||
} catch (err) {
|
||||
console.warn('getWorkspaceInvites failed (expected for non-admins):', err.message);
|
||||
invites = null;
|
||||
if (canAdmin) {
|
||||
try {
|
||||
invites = await api.getWorkspaceInvites(workspaceId);
|
||||
} catch (err) {
|
||||
console.warn('getWorkspaceInvites failed:', err.message);
|
||||
invites = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Invite button - admin only, opens modal.
|
||||
if (canAdmin) {
|
||||
headerActions.innerHTML = `
|
||||
<button class="btn btn-primary" id="inviteMemberBtn">${t('members.button.invite')}</button>
|
||||
`;
|
||||
document.getElementById('inviteMemberBtn').addEventListener('click', () => {
|
||||
openInviteMemberModal({ id: workspaceId, name: workspaceName }, {
|
||||
onSuccess: (result) => {
|
||||
showToast(t('members.success.invite_sent', { email: result.email }), 'success');
|
||||
render(container, workspaceId);
|
||||
},
|
||||
mapError: mapMutationError,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const direct = members.filter(m => !m.via_org);
|
||||
|
|
@ -51,21 +86,23 @@ export async function render(container, workspaceId) {
|
|||
titleKey: 'members.section.direct',
|
||||
count: direct.length,
|
||||
emptyKey: 'members.empty.members',
|
||||
rows: direct.map(m => renderMemberRow(m, { showJoined: true })).join(''),
|
||||
rows: direct.map(m => renderMemberRow(m, { showJoined: true, canAdmin })).join(''),
|
||||
})}
|
||||
${viaOrg.length > 0 ? renderSection({
|
||||
titleKey: 'members.section.via_org',
|
||||
count: viaOrg.length,
|
||||
emptyKey: null,
|
||||
rows: viaOrg.map(m => renderMemberRow(m, { showJoined: false, viaOrg: true })).join(''),
|
||||
rows: viaOrg.map(m => renderMemberRow(m, { showJoined: false, viaOrg: true, canAdmin })).join(''),
|
||||
}) : ''}
|
||||
${invites !== null ? renderSection({
|
||||
titleKey: 'members.section.pending',
|
||||
count: invites.length,
|
||||
emptyKey: 'members.empty.invites',
|
||||
rows: invites.map(renderInviteRow).join(''),
|
||||
rows: invites.map(inv => renderInviteRow(inv, { canAdmin })).join(''),
|
||||
}) : ''}
|
||||
`;
|
||||
|
||||
if (canAdmin) attachMutationHandlers(container, workspaceId);
|
||||
}
|
||||
|
||||
function renderSection({ titleKey, count, emptyKey, rows }) {
|
||||
|
|
@ -84,11 +121,30 @@ function renderSection({ titleKey, count, emptyKey, rows }) {
|
|||
}
|
||||
|
||||
function renderMemberRow(m, opts = {}) {
|
||||
const { showJoined = false, viaOrg = false } = opts;
|
||||
const { showJoined = false, viaOrg = false, canAdmin = false } = opts;
|
||||
const initial = ((m.name || m.email || '?')[0] || '?').toUpperCase();
|
||||
const rightCell = viaOrg
|
||||
? `<span class="member-via-org">${t('members.via_org_label')}</span>`
|
||||
: (showJoined ? esc(formatDate(m.joined_at)) : '');
|
||||
|
||||
// Role cell: select for direct-member rows when canAdmin, plain text otherwise.
|
||||
const roleCell = (canAdmin && !viaOrg)
|
||||
? `<select class="member-role-select" data-member-id="${esc(m.user_id)}" aria-label="${esc(t('members.col.role'))}">
|
||||
${WORKSPACE_ROLES.map(r => `<option value="${r}"${r === m.role ? ' selected' : ''}>${esc(t('members.role.' + r))}</option>`).join('')}
|
||||
</select>`
|
||||
: `<div class="member-role">${esc(t('members.role.' + m.role))}</div>`;
|
||||
|
||||
// Actions cell: remove on direct-member rows only when canAdmin.
|
||||
const actionsCell = (canAdmin && !viaOrg)
|
||||
? `<div class="member-actions">
|
||||
<button class="member-action-btn member-action-btn--danger" type="button"
|
||||
data-remove-member="${esc(m.user_id)}"
|
||||
data-member-name="${esc(m.name || m.email)}"
|
||||
aria-label="${esc(t('members.button.remove'))}"
|
||||
title="${esc(t('members.button.remove'))}">${REMOVE_ICON}</button>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="member-row${viaOrg ? ' member-row--via-org' : ''}">
|
||||
<div class="member-avatar">${esc(initial)}</div>
|
||||
|
|
@ -96,18 +152,32 @@ function renderMemberRow(m, opts = {}) {
|
|||
<div class="member-name">${esc(m.name || m.email)}</div>
|
||||
<div class="member-email">${esc(m.email)}</div>
|
||||
</div>
|
||||
<div class="member-role">${esc(t('members.role.' + m.role))}</div>
|
||||
${roleCell}
|
||||
<div class="member-detail">${rightCell}</div>
|
||||
${actionsCell}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInviteRow(inv) {
|
||||
function renderInviteRow(inv, opts = {}) {
|
||||
const { canAdmin = false } = opts;
|
||||
const initial = ((inv.email || '?')[0] || '?').toUpperCase();
|
||||
const invitedBy = inv.invited_by_email
|
||||
? t('members.invited_by', { email: inv.invited_by_email })
|
||||
: '';
|
||||
const expires = t('members.expires_in', { when: formatDate(inv.expires_at) });
|
||||
|
||||
// Refined affordance rule: invited rows DO get one action - cancel.
|
||||
const actionsCell = canAdmin
|
||||
? `<div class="member-actions">
|
||||
<button class="member-action-btn member-action-btn--danger" type="button"
|
||||
data-cancel-invite="${esc(inv.id)}"
|
||||
data-invite-email="${esc(inv.email)}"
|
||||
aria-label="${esc(t('members.button.cancel_invite'))}"
|
||||
title="${esc(t('members.button.cancel_invite'))}">${REMOVE_ICON}</button>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="member-row member-row--invited">
|
||||
<div class="member-avatar member-avatar--muted">${esc(initial)}</div>
|
||||
|
|
@ -120,16 +190,90 @@ function renderInviteRow(inv) {
|
|||
</div>
|
||||
<div class="member-role">${esc(t('members.role.' + inv.role))}</div>
|
||||
<div class="member-detail">${esc(expires)}</div>
|
||||
${actionsCell}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Wire all mutation handlers after innerHTML write. Each handler: confirm
|
||||
// (if destructive), call API, on success toast + re-render, on error toast
|
||||
// + re-render (to revert UI state in case the failed mutation was an
|
||||
// optimistic display - belt and suspenders).
|
||||
function attachMutationHandlers(container, workspaceId) {
|
||||
// Role change - fires on <select> change.
|
||||
container.querySelectorAll('select[data-member-id]').forEach(sel => {
|
||||
sel.addEventListener('change', async () => {
|
||||
const userId = sel.dataset.memberId;
|
||||
const newRole = sel.value;
|
||||
try {
|
||||
await api.updateWorkspaceMemberRole(workspaceId, userId, newRole);
|
||||
showToast(t('members.success.role_changed'), 'success');
|
||||
render(container, workspaceId);
|
||||
} catch (err) {
|
||||
showToast(mapMutationError(err), 'error');
|
||||
render(container, workspaceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Remove member - confirm then DELETE.
|
||||
container.querySelectorAll('[data-remove-member]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const userId = btn.dataset.removeMember;
|
||||
const name = btn.dataset.memberName;
|
||||
if (!confirm(t('members.confirm.remove_member', { name }))) return;
|
||||
try {
|
||||
await api.removeWorkspaceMember(workspaceId, userId);
|
||||
showToast(t('members.success.member_removed', { name }), 'success');
|
||||
render(container, workspaceId);
|
||||
} catch (err) {
|
||||
showToast(mapMutationError(err), 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel pending invite - confirm then DELETE.
|
||||
container.querySelectorAll('[data-cancel-invite]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const inviteId = btn.dataset.cancelInvite;
|
||||
const email = btn.dataset.inviteEmail;
|
||||
if (!confirm(t('members.confirm.cancel_invite', { email }))) return;
|
||||
try {
|
||||
await api.cancelWorkspaceInvite(workspaceId, inviteId);
|
||||
showToast(t('members.success.invite_cancelled'), 'success');
|
||||
render(container, workspaceId);
|
||||
} catch (err) {
|
||||
showToast(mapMutationError(err), 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Map a backend mutation-error message to a translated user-facing string.
|
||||
// Exported so the invite modal can reuse the same mapper (single source of
|
||||
// truth - the "third regex mapper" per the slice 2A follow-up note;
|
||||
// cumulative-debt cleanup tracked there).
|
||||
//
|
||||
// Order matters - most specific patterns first. Server message stability is
|
||||
// the implicit contract; if the regex chain ever produces wrong matches,
|
||||
// it's because server wording changed without updating this mapper.
|
||||
export function mapMutationError(err) {
|
||||
const msg = err?.message || '';
|
||||
if (/rate limit/i.test(msg)) return t('members.error.rate_limit');
|
||||
if (/already pending/i.test(msg)) return t('members.error.invite_exists');
|
||||
if (/Cannot demote the last admin/i.test(msg)) return t('members.error.last_admin_demote');
|
||||
if (/Cannot remove the last admin/i.test(msg)) return t('members.error.last_admin_remove');
|
||||
if (/already a member/i.test(msg)) return t('members.error.already_member');
|
||||
if (/Valid email required/i.test(msg)) return t('members.error.invalid_email');
|
||||
if (/Cannot remove the organization owner/i.test(msg)) return t('members.error.org_owner_remove');
|
||||
if (/Email send failed/i.test(msg)) return t('members.error.email_send_failed');
|
||||
return t('members.error.mutation_generic', { error: msg });
|
||||
}
|
||||
|
||||
function renderError(message) {
|
||||
return `<div style="color:var(--danger);font-size:14px;padding:16px;background:var(--bg-input);border-radius:6px">${message}</div>`;
|
||||
}
|
||||
|
||||
// Unix-seconds -> locale-aware short date. Mirrors the playlists.js inline
|
||||
// helper; not extracting to utils.js until a third caller appears.
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '';
|
||||
return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
|
@ -138,3 +282,6 @@ function formatDate(ts) {
|
|||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
|
||||
const WORKSPACE_ROLES = ['workspace_admin', 'workspace_editor', 'workspace_viewer'];
|
||||
const REMOVE_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
|
|
|
|||
Loading…
Reference in a new issue