// 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.title')}
${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 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);
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)
? `
`;
}
// 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