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 = ` + + `; + 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