Feat v0.9.3 team user roles (#77)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m5s
CI/CD / build-and-deploy (push) Successful in 27s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #77.
This commit is contained in:
2026-04-03 15:51:31 +00:00
committed by xcaliber
parent 0cae963480
commit 0661e1d768
17 changed files with 852 additions and 12 deletions

View File

@@ -16,7 +16,7 @@ export default function MembersSection({ teamId }) {
const loadRoles = useCallback(async () => {
try {
const resp = await sw.api.get(`/api/v1/teams/${teamId}/roles`);
setRoles(resp.data || ['admin', 'member']);
setRoles(Array.isArray(resp) ? resp : (resp.data || ['admin', 'member']));
} catch { setRoles(['admin', 'member']); }
}, [teamId]);
@@ -63,6 +63,38 @@ export default function MembersSection({ teamId }) {
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Additional roles (many-to-many) ───────
const [memberRoles, setMemberRoles] = useState({});
async function loadMemberRoles(memberId) {
try {
const resp = await sw.api.get(`/api/v1/teams/${teamId}/members/${memberId}/roles`);
const roles = Array.isArray(resp) ? resp : (resp.data || []);
setMemberRoles(prev => ({ ...prev, [memberId]: roles }));
} catch { /* ignore */ }
}
async function addExtraRole(memberId, role) {
try {
await sw.api.post(`/api/v1/teams/${teamId}/members/${memberId}/roles`, { role });
sw.toast('Role assigned', 'success');
loadMemberRoles(memberId);
} catch (e) { sw.toast(e.message, 'error'); }
}
async function removeExtraRole(memberId, role) {
try {
await sw.api.del(`/api/v1/teams/${teamId}/members/${memberId}/roles/${role}`);
sw.toast('Role removed', 'success');
loadMemberRoles(memberId);
} catch (e) { sw.toast(e.message, 'error'); }
}
// Load additional roles for each member once members are loaded
useEffect(() => {
members.forEach(m => { if (m.id) loadMemberRoles(m.id); });
}, [members]);
async function removeMember(memberId) {
const ok = await sw.confirm('Remove this member from the team?', true);
if (!ok) return;
@@ -106,16 +138,35 @@ export default function MembersSection({ teamId }) {
<div class="admin-list">
${members.length === 0 && html`<div class="empty-hint">No members</div>`}
${members.map(m => html`
<div class="admin-surface-row" key=${m.id || m.user_id}>
${members.map(m => {
const mid = m.id || m.user_id;
const extra = (memberRoles[mid] || []).filter(r => r !== m.role);
const available = roles.filter(r => r !== m.role && !extra.includes(r));
return html`
<div class="admin-surface-row" key=${mid} style="flex-wrap:wrap;">
<strong style="flex:1;">${m.display_name || m.username || 'Unknown'}</strong>
<select value=${m.role || 'member'} style="width:100px;"
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
onChange=${e => updateRole(mid, e.target.value)}>
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(mid)}>Remove</button>
${extra.length > 0 && html`
<div style="width:100%;display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;">
${extra.map(r => html`
<span key=${r} class="badge" style="cursor:pointer;" title="Click to remove"
onClick=${() => removeExtraRole(mid, r)}>${r} \u00d7</span>
`)}
</div>
`}
${available.length > 0 && html`
<select style="width:auto;margin-top:4px;font-size:12px;"
onChange=${e => { if (e.target.value) { addExtraRole(mid, e.target.value); e.target.value = ''; } }}>
<option value="">+ Role\u2026</option>
${available.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
`}
</div>
`)}
`})}
</div>
<div style="margin-top:24px;border-top:1px solid var(--border-1);padding-top:12px;">