mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -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-item:hover .workspace-switcher-pencil { visibility: visible; }
|
||||||
.workspace-switcher-pencil:hover { color: var(--accent); background: var(--bg-input); }
|
.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 {
|
.nav-links {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,10 @@ export const api = {
|
||||||
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
|
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) }),
|
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
|
// Admin - Users
|
||||||
getUsers: () => request('/auth/users'),
|
getUsers: () => request('/auth/users'),
|
||||||
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
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 adminPlayerDebug from './views/admin-player-debug.js';
|
||||||
import * as designer from './views/designer.js';
|
import * as designer from './views/designer.js';
|
||||||
import * as playlists from './views/playlists.js';
|
import * as playlists from './views/playlists.js';
|
||||||
|
import * as workspaceMembers from './views/workspace-members.js';
|
||||||
import { applyBranding } from './branding.js';
|
import { applyBranding } from './branding.js';
|
||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
import { isPlatformAdmin } from './utils.js';
|
import { isPlatformAdmin } from './utils.js';
|
||||||
|
|
@ -211,6 +212,10 @@ function route() {
|
||||||
} else if (hash === '#/teams' || hash.startsWith('#/team/')) {
|
} else if (hash === '#/teams' || hash.startsWith('#/team/')) {
|
||||||
currentView = teams;
|
currentView = teams;
|
||||||
teams.render(app);
|
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')) {
|
} else if (hash === '#/help' || hash.startsWith('#/help')) {
|
||||||
currentView = help;
|
currentView = help;
|
||||||
help.render(app);
|
help.render(app);
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,14 @@ export function renderWorkspaceSwitcher(me) {
|
||||||
<div class="ws-org">${subtitle}</div>
|
<div class="ws-org">${subtitle}</div>
|
||||||
</div>
|
</div>
|
||||||
${w.can_admin ? `
|
${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">
|
<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">
|
<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"/>
|
<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 => {
|
container.querySelectorAll('.workspace-switcher-item').forEach(item => {
|
||||||
item.addEventListener('click', async (e) => {
|
item.addEventListener('click', async (e) => {
|
||||||
// Ignore clicks that originated on the pencil (it has its own handler).
|
// Ignore clicks that originated on an icon button (each has its own handler).
|
||||||
if (e.target.closest('.workspace-switcher-pencil')) return;
|
if (e.target.closest('.workspace-switcher-pencil, .workspace-switcher-members')) return;
|
||||||
const wsId = item.dataset.workspaceId;
|
const wsId = item.dataset.workspaceId;
|
||||||
if (wsId === currentId) { container.classList.remove('open'); return; }
|
if (wsId === currentId) { container.classList.remove('open'); return; }
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1118,4 +1118,28 @@ export default {
|
||||||
'switcher.devices_count_one': '1 device',
|
'switcher.devices_count_one': '1 device',
|
||||||
'switcher.devices_count_other': '{n} devices',
|
'switcher.devices_count_other': '{n} devices',
|
||||||
'switcher.no_devices': 'No 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