From 8db171d97928d1bc9848472379d69cf0a506d4ee Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 16 May 2026 13:00:51 -0500 Subject: [PATCH] feat(workspaces): members page read-only view (slice 2A) Adds the workspace members page at #/workspace/:id/members. Read-only listing only - mutations land in slice 2B, accept-invite URL handler lands in slice 2C. Three sections render based on access path: - Members: direct workspace_members rows with role + join date - Organization access: org_owner/org_admin who reach this workspace via org-level access (via_org=true). 75% opacity + italic "via organization" label to distinguish from direct membership. Section hidden if empty. - Pending invites: workspace_invites rows (admin-only - section silently absent for non-admins via 403-suppress) Switcher dropdown adds a "members" icon next to the rename pencil, gated on can_admin (same predicate). Icon visible on hover, mirrors the existing pencil pattern. 24 i18n keys added under members.* (read-only set; mutation keys land in 2B). Backend coverage from c4fbd2b unchanged; pre-flight curl verification (13/13 cases) confirmed all 7 endpoints work as documented before slice 2 first-exercised the four previously untested ones (GET /invites, DELETE /invites/:id, PUT /members/:userId, DELETE /members/:userId). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/css/main.css | 56 ++++++++ frontend/js/api.js | 4 + frontend/js/app.js | 5 + frontend/js/components/workspace-switcher.js | 23 ++- frontend/js/i18n/en.js | 24 ++++ frontend/js/views/workspace-members.js | 140 +++++++++++++++++++ 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 frontend/js/views/workspace-members.js diff --git a/frontend/css/main.css b/frontend/css/main.css index b34fa35..4ceca37 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -106,6 +106,62 @@ body { } .workspace-switcher-item:hover .workspace-switcher-pencil { visibility: visible; } .workspace-switcher-pencil:hover { color: var(--accent); background: var(--bg-input); } +/* Members icon - same shape as the pencil; navigates to #/workspace/:id/members. */ +.workspace-switcher-members { + flex-shrink: 0; visibility: hidden; + background: none; border: none; padding: 4px; + color: var(--text-muted); cursor: pointer; + border-radius: 4px; transition: all var(--transition); +} +.workspace-switcher-item:hover .workspace-switcher-members { visibility: visible; } +.workspace-switcher-members:hover { color: var(--accent); background: var(--bg-input); } + +/* Workspace members page (Phase 2 user-mgmt, slice 2A read-only). Three + sections render via JS: direct members, via_org access, pending invites. + Row layout mirrors the sidebar user card's avatar pattern for visual + continuity. via_org rows are opacity-reduced and invite rows use the + input-bg shade so the three states are distinguishable at a glance. */ +.members-list { display: flex; flex-direction: column; gap: 4px; } +.member-row { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; border: 1px solid var(--border); + border-radius: var(--radius); background: var(--bg-card); +} +.member-row--via-org { opacity: 0.75; } +.member-row--invited { background: var(--bg-input); } +.member-avatar { + flex-shrink: 0; + width: 32px; height: 32px; border-radius: 50%; + background: var(--accent); color: white; + display: flex; align-items: center; justify-content: center; + font-size: 13px; font-weight: 600; +} +.member-avatar--muted { background: var(--text-muted); } +.member-meta { flex: 1; min-width: 0; } +.member-name { + font-size: 13px; font-weight: 500; color: var(--text-primary); + display: flex; align-items: center; gap: 8px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.member-email { + font-size: 11px; color: var(--text-muted); margin-top: 1px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.member-role { + flex-shrink: 0; font-size: 12px; color: var(--text-secondary); + padding: 4px 8px; background: var(--bg-input); + border-radius: 4px; min-width: 60px; text-align: center; +} +.member-detail { + flex-shrink: 0; font-size: 11px; color: var(--text-muted); + min-width: 110px; text-align: right; +} +.member-via-org { font-style: italic; } +.member-badge { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; + padding: 2px 6px; background: var(--bg-input); color: var(--text-muted); + border-radius: 3px; font-weight: 600; +} .nav-links { flex: 1; diff --git a/frontend/js/api.js b/frontend/js/api.js index 543537f..7cc1fb2 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -159,6 +159,10 @@ 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) + getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`), + getWorkspaceInvites: (id) => request(`/workspaces/${id}/invites`), + // Admin - Users getUsers: () => request('/auth/users'), deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }), diff --git a/frontend/js/app.js b/frontend/js/app.js index 237ea1f..9a71624 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -19,6 +19,7 @@ import * as admin from './views/admin.js'; import * as adminPlayerDebug from './views/admin-player-debug.js'; import * as designer from './views/designer.js'; import * as playlists from './views/playlists.js'; +import * as workspaceMembers from './views/workspace-members.js'; import { applyBranding } from './branding.js'; import { t } from './i18n.js'; import { isPlatformAdmin } from './utils.js'; @@ -211,6 +212,10 @@ function route() { } else if (hash === '#/teams' || hash.startsWith('#/team/')) { currentView = teams; teams.render(app); + } else if (hash.startsWith('#/workspace/') && hash.includes('/members')) { + const wsId = hash.split('#/workspace/')[1].split('/')[0]; + currentView = workspaceMembers; + workspaceMembers.render(app, wsId); } else if (hash === '#/help' || hash.startsWith('#/help')) { currentView = help; help.render(app); diff --git a/frontend/js/components/workspace-switcher.js b/frontend/js/components/workspace-switcher.js index 1c8d213..1f324b9 100644 --- a/frontend/js/components/workspace-switcher.js +++ b/frontend/js/components/workspace-switcher.js @@ -67,6 +67,14 @@ export function renderWorkspaceSwitcher(me) {
${subtitle}
${w.can_admin ? ` +