// 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'; import { openAddUserModal } from '../components/workspace-members-add-user-modal.js'; export async function render(container, workspaceId) { container.innerHTML = `
${t('members.loading')}
`; const content = document.getElementById('workspaceMembersContent'); const headerActions = document.getElementById('membersHeaderActions'); // 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 { 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 || ''; if (/Workspace access required|Workspace not found/.test(msg)) { content.innerHTML = renderError(t('members.workspace_not_found')); } else { content.innerHTML = renderError(t('members.load_error', { error: esc(msg) })); } return; } 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; if (canAdmin) { try { invites = await api.getWorkspaceInvites(workspaceId); } catch (err) { console.warn('getWorkspaceInvites failed:', err.message); invites = null; } } // Invite + Add User buttons - admin only. Invite is self-service (emails a // link); Add User (#10) provisions an account directly with an admin-set // password (for instances with no outbound email). They coexist. 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, }); }); document.getElementById('addUserBtn').addEventListener('click', () => { openAddUserModal({ id: workspaceId, name: workspaceName }, { onSuccess: (result) => { showToast(t('members.success.user_created', { email: result.email }), 'success'); render(container, workspaceId); }, mapError: mapMutationError, }); }); } const direct = members.filter(m => !m.via_org); const viaOrg = members.filter(m => m.via_org); content.innerHTML = ` ${renderSection({ titleKey: 'members.section.direct', count: direct.length, emptyKey: 'members.empty.members', 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, canAdmin })).join(''), }) : ''} ${invites !== null ? renderSection({ titleKey: 'members.section.pending', count: invites.length, emptyKey: 'members.empty.invites', rows: invites.map(inv => renderInviteRow(inv, { canAdmin })).join(''), }) : ''} `; if (canAdmin) attachMutationHandlers(container, workspaceId); } function renderSection({ titleKey, count, emptyKey, rows }) { const countLabel = count > 0 ? ` (${count})` : ''; const body = (count === 0 && emptyKey) ? `

${t(emptyKey)}

` : `
${rows}
`; return `

${t(titleKey)}${countLabel}

${body}
`; } function renderMemberRow(m, 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)}
${esc(m.name || m.email)}
${esc(m.email)}
${roleCell}
${rightCell}
${actionsCell}
`; } 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)}
${esc(inv.email)} ${t('members.invited_label')}
${esc(invitedBy)}
${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