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 ? `
+