mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
c4fbd2ba5c
commit
8db171d979
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,14 @@ export function renderWorkspaceSwitcher(me) {
|
|||
<div class="ws-org">${subtitle}</div>
|
||||
</div>
|
||||
${w.can_admin ? `
|
||||
<button class="workspace-switcher-members" type="button" data-members-id="${esc(w.id)}" aria-label="Manage members" title="Manage members">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="workspace-switcher-pencil" type="button" data-rename-id="${esc(w.id)}" aria-label="Rename workspace" title="Rename">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
|
||||
|
|
@ -101,10 +109,21 @@ export function renderWorkspaceSwitcher(me) {
|
|||
});
|
||||
});
|
||||
|
||||
// Members icon navigates to the workspace members page. Same stopPropagation
|
||||
// pattern as the pencil so the click doesn't trigger workspace-switch.
|
||||
container.querySelectorAll('.workspace-switcher-members').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const wsId = btn.dataset.membersId;
|
||||
container.classList.remove('open');
|
||||
window.location.hash = `#/workspace/${wsId}/members`;
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.workspace-switcher-item').forEach(item => {
|
||||
item.addEventListener('click', async (e) => {
|
||||
// Ignore clicks that originated on the pencil (it has its own handler).
|
||||
if (e.target.closest('.workspace-switcher-pencil')) return;
|
||||
// Ignore clicks that originated on an icon button (each has its own handler).
|
||||
if (e.target.closest('.workspace-switcher-pencil, .workspace-switcher-members')) return;
|
||||
const wsId = item.dataset.workspaceId;
|
||||
if (wsId === currentId) { container.classList.remove('open'); return; }
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1118,4 +1118,28 @@ export default {
|
|||
'switcher.devices_count_one': '1 device',
|
||||
'switcher.devices_count_other': '{n} devices',
|
||||
'switcher.no_devices': 'No devices',
|
||||
|
||||
// Workspace members (Slice 2A - read-only listing; 2B adds mutation keys).
|
||||
'members.title': 'Workspace members',
|
||||
'members.loading': 'Loading...',
|
||||
'members.section.direct': 'Members',
|
||||
'members.section.via_org': 'Organization access',
|
||||
'members.section.pending': 'Pending invites',
|
||||
'members.col.name': 'Name',
|
||||
'members.col.email': 'Email',
|
||||
'members.col.role': 'Role',
|
||||
'members.col.joined': 'Joined',
|
||||
'members.role.workspace_admin': 'Admin',
|
||||
'members.role.workspace_editor': 'Editor',
|
||||
'members.role.workspace_viewer': 'Viewer',
|
||||
'members.role.org_owner': 'Org owner',
|
||||
'members.role.org_admin': 'Org admin',
|
||||
'members.via_org_label': 'via organization',
|
||||
'members.invited_label': 'Invited',
|
||||
'members.invited_by': 'Invited by {email}',
|
||||
'members.expires_in': 'Expires {when}',
|
||||
'members.empty.members': 'No direct members yet.',
|
||||
'members.empty.invites': 'No pending invites.',
|
||||
'members.load_error': 'Failed to load members: {error}',
|
||||
'members.workspace_not_found': 'Workspace not found or no access.',
|
||||
};
|
||||
|
|
|
|||
140
frontend/js/views/workspace-members.js
Normal file
140
frontend/js/views/workspace-members.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// 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.
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
export async function render(container, workspaceId) {
|
||||
container.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>${t('members.title')}</h1>
|
||||
</div>
|
||||
<div id="workspaceMembersContent" style="color:var(--text-muted)">${t('members.loading')}</div>
|
||||
`;
|
||||
const content = document.getElementById('workspaceMembersContent');
|
||||
|
||||
let members;
|
||||
try {
|
||||
members = await api.getWorkspaceMembers(workspaceId);
|
||||
} 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 {
|
||||
content.innerHTML = renderError(t('members.load_error', { error: esc(msg) }));
|
||||
}
|
||||
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).
|
||||
let invites = null;
|
||||
try {
|
||||
invites = await api.getWorkspaceInvites(workspaceId);
|
||||
} catch (err) {
|
||||
console.warn('getWorkspaceInvites failed (expected for non-admins):', err.message);
|
||||
invites = null;
|
||||
}
|
||||
|
||||
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 })).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(''),
|
||||
}) : ''}
|
||||
${invites !== null ? renderSection({
|
||||
titleKey: 'members.section.pending',
|
||||
count: invites.length,
|
||||
emptyKey: 'members.empty.invites',
|
||||
rows: invites.map(renderInviteRow).join(''),
|
||||
}) : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSection({ titleKey, count, emptyKey, rows }) {
|
||||
const countLabel = count > 0
|
||||
? `<span style="color:var(--text-muted);font-weight:400;font-size:13px"> (${count})</span>`
|
||||
: '';
|
||||
const body = (count === 0 && emptyKey)
|
||||
? `<p style="color:var(--text-muted);font-size:13px">${t(emptyKey)}</p>`
|
||||
: `<div class="members-list">${rows}</div>`;
|
||||
return `
|
||||
<div class="settings-section" style="margin-bottom:24px">
|
||||
<h3 style="font-size:15px;margin-bottom:12px">${t(titleKey)}${countLabel}</h3>
|
||||
${body}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMemberRow(m, opts = {}) {
|
||||
const { showJoined = false, viaOrg = false } = opts;
|
||||
const initial = ((m.name || m.email || '?')[0] || '?').toUpperCase();
|
||||
const rightCell = viaOrg
|
||||
? `<span class="member-via-org">${t('members.via_org_label')}</span>`
|
||||
: (showJoined ? esc(formatDate(m.joined_at)) : '');
|
||||
return `
|
||||
<div class="member-row${viaOrg ? ' member-row--via-org' : ''}">
|
||||
<div class="member-avatar">${esc(initial)}</div>
|
||||
<div class="member-meta">
|
||||
<div class="member-name">${esc(m.name || m.email)}</div>
|
||||
<div class="member-email">${esc(m.email)}</div>
|
||||
</div>
|
||||
<div class="member-role">${esc(t('members.role.' + m.role))}</div>
|
||||
<div class="member-detail">${rightCell}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInviteRow(inv) {
|
||||
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) });
|
||||
return `
|
||||
<div class="member-row member-row--invited">
|
||||
<div class="member-avatar member-avatar--muted">${esc(initial)}</div>
|
||||
<div class="member-meta">
|
||||
<div class="member-name">
|
||||
${esc(inv.email)}
|
||||
<span class="member-badge">${t('members.invited_label')}</span>
|
||||
</div>
|
||||
<div class="member-email">${esc(invitedBy)}</div>
|
||||
</div>
|
||||
<div class="member-role">${esc(t('members.role.' + inv.role))}</div>
|
||||
<div class="member-detail">${esc(expires)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderError(message) {
|
||||
return `<div style="color:var(--danger);font-size:14px;padding:16px;background:var(--bg-input);border-radius:6px">${message}</div>`;
|
||||
}
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
Loading…
Reference in a new issue