From caa9fd0f4011090da80b8dea99a67003fbffa60b Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 17 May 2026 14:45:34 -0500 Subject: [PATCH] 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 + +
+ + +
+ + + + + `; + 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])); +} diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index f56856c..ea00789 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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}", diff --git a/frontend/js/views/workspace-members.js b/frontend/js/views/workspace-members.js index 79fa779..2cca907 100644 --- a/frontend/js/views/workspace-members.js +++ b/frontend/js/views/workspace-members.js @@ -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 = `
${t('members.loading')}
`; 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 = ` + + `; + 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 ? `${t('members.via_org_label')}` : (showJoined ? esc(formatDate(m.joined_at)) : ''); + + // Role cell: select for direct-member rows when canAdmin, plain text otherwise. + const roleCell = (canAdmin && !viaOrg) + ? `` + : `
${esc(t('members.role.' + m.role))}
`; + + // Actions cell: remove on direct-member rows only when canAdmin. + const actionsCell = (canAdmin && !viaOrg) + ? `
+ +
` + : ''; + return `
${esc(initial)}
@@ -96,18 +152,32 @@ function renderMemberRow(m, opts = {}) {
${esc(m.name || m.email)}
${esc(m.email)}
-
${esc(t('members.role.' + m.role))}
+ ${roleCell}
${rightCell}
+ ${actionsCell} `; } -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 + ? `
+ +
` + : ''; + return `
${esc(initial)}
@@ -120,16 +190,90 @@ function renderInviteRow(inv) {
${esc(t('members.role.' + inv.role))}
${esc(expires)}
+ ${actionsCell} `; } +// 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