diff --git a/frontend/css/main.css b/frontend/css/main.css
index 4ceca37..60957d3 100644
--- a/frontend/css/main.css
+++ b/frontend/css/main.css
@@ -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;
diff --git a/frontend/js/api.js b/frontend/js/api.js
index 2768c78..52cec21 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -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' }),
diff --git a/frontend/js/components/workspace-members-invite-modal.js b/frontend/js/components/workspace-members-invite-modal.js
new file mode 100644
index 0000000..4ca6fb2
--- /dev/null
+++ b/frontend/js/components/workspace-members-invite-modal.js
@@ -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 = `
+
+
+
+
+ ${t('members.modal.email_label')}
+
+
+
+ ${t('members.modal.role_label')}
+
+ ${t('members.role.workspace_viewer')}
+ ${t('members.role.workspace_editor')}
+ ${t('members.role.workspace_admin')}
+
+
+
+
+
+
+ `;
+ 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 = `
+ ${t('members.button.invite')}
+ `;
+ 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)
+ ? `
+ ${WORKSPACE_ROLES.map(r => `${esc(t('members.role.' + r))} `).join('')}
+ `
+ : `${esc(t('members.role.' + m.role))}
`;
+
+ // Actions cell: remove on direct-member rows only when canAdmin.
+ const actionsCell = (canAdmin && !viaOrg)
+ ? `
+ ${REMOVE_ICON}
+
`
+ : '';
+
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
+ ? `
+ ${REMOVE_ICON}
+
`
+ : '';
+
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 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 `${message}
`;
}
-// 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 = ' ';