// 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 = `
${t('members.loading')}
`;
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
? ` (${count})`
: '';
const body = (count === 0 && emptyKey)
? `${t(emptyKey)}
`
: `${rows}
`;
return `
${t(titleKey)}${countLabel}
${body}
`;
}
function renderMemberRow(m, opts = {}) {
const { showJoined = false, viaOrg = false } = opts;
const initial = ((m.name || m.email || '?')[0] || '?').toUpperCase();
const rightCell = viaOrg
? `${t('members.via_org_label')}`
: (showJoined ? esc(formatDate(m.joined_at)) : '');
return `
${esc(initial)}
${esc(t('members.role.' + m.role))}
${rightCell}
`;
}
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 `
${esc(initial)}
${esc(t('members.role.' + inv.role))}
${esc(expires)}
`;
}
function renderError(message) {
return `${message}
`;
}
// 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]));
}