diff --git a/dashboard/src/lib/RoleChip.svelte b/dashboard/src/lib/RoleChip.svelte index 606e08b..e827042 100644 --- a/dashboard/src/lib/RoleChip.svelte +++ b/dashboard/src/lib/RoleChip.svelte @@ -1,15 +1,24 @@ -{role} +{label} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 64a1d75..8723ce6 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -296,6 +296,21 @@ export interface PatchAdminInput { email?: string | null; } +export interface AppMemberDto { + user_id: string; + username: string; + email: string | null; + instance_role: InstanceRole; + is_active: boolean; + role: AppRole; + created_at: string; +} + +export interface GrantAppMemberInput { + user_id: string; + role: AppRole; +} + export interface ApiKeyDto { id: string; prefix: string; @@ -479,6 +494,28 @@ export const api = { ) }, + appMembers: { + list: (idOrSlug: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members` + ), + add: (idOrSlug: string, input: GrantAppMemberInput) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`, + { method: 'POST', body: JSON.stringify(input) } + ), + setRole: (idOrSlug: string, userId: string, role: AppRole) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`, + { method: 'PATCH', body: JSON.stringify({ role }) } + ), + remove: (idOrSlug: string, userId: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`, + { method: 'DELETE' } + ) + }, + execute: async ( id: string, body: unknown, diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte index c18edeb..3f6e583 100644 --- a/dashboard/src/routes/apps/[slug]/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -5,26 +5,35 @@ import { api, ApiError, + type AdminDto, type App, type AppDomain, + type AppMemberDto, + type AppRole, type Script } from '$lib/api'; import CodeEditor from '$lib/CodeEditor.svelte'; import ConfirmModal from '$lib/ConfirmModal.svelte'; + import ActionMenu from '$lib/ActionMenu.svelte'; + import RoleChip from '$lib/RoleChip.svelte'; const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}'; - type Tab = 'scripts' | 'domains' | 'settings'; + type Tab = 'scripts' | 'domains' | 'members' | 'settings'; let slug = $derived(page.params.slug ?? ''); let app = $state(null); + let myRole = $state(null); let loadError = $state(null); let loading = $state(true); let activeTab = $state('scripts'); let scripts = $state([]); let domains = $state([]); + let members = $state([]); + + const canAdminMembers = $derived(myRole === 'app_admin'); // Script create let showCreateScript = $state(false); @@ -55,6 +64,19 @@ let removingDomain = $state(false); let removeDomainError = $state(null); + // Members tab + let eligibleUsers = $state([]); + let eligibleLoadError = $state(null); + let addMemberUserId = $state(''); + let addMemberRole = $state('viewer'); + let addingMember = $state(false); + let addMemberError = $state(null); + let memberToRemove = $state(null); + let removingMember = $state(false); + let removeMemberError = $state(null); + let roleChangeBusy = $state(null); + let memberActionError = $state(null); + async function loadApp() { loading = true; loadError = null; @@ -72,10 +94,15 @@ created_at: fetched.created_at, updated_at: fetched.updated_at }; + myRole = fetched.my_role; editName = app.name; editDescription = app.description ?? ''; editSlug = app.slug; - await Promise.all([loadScripts(app.id), loadDomains(app.id)]); + const loaders: Promise[] = [loadScripts(app.id), loadDomains(app.id)]; + if (canAdminMembers) { + loaders.push(loadMembers(app.id), loadEligibleUsers()); + } + await Promise.all(loaders); } catch (e) { loadError = e instanceof Error ? e.message : String(e); } finally { @@ -101,6 +128,42 @@ } } + async function loadMembers(appId: string) { + try { + members = await api.appMembers.list(appId); + } catch (e) { + members = []; + memberActionError = e instanceof Error ? e.message : String(e); + } + } + + async function loadEligibleUsers() { + eligibleLoadError = null; + try { + const all = await api.admins.list(); + // Only inactive=false members are valid invite targets — the + // API rejects everyone else anyway, so filter upfront. + eligibleUsers = all.filter( + (u) => u.is_active && u.instance_role === 'member' + ); + } catch (e) { + eligibleUsers = []; + // member-with-app_admin can hit /apps/.../members but cannot + // browse /admins (gated on InstanceManageUsers). The add form + // will render disabled with the explanatory message below. + eligibleLoadError = + e instanceof ApiError && e.status === 403 + ? 'Only instance owners/admins can browse the user directory to invite new members.' + : e instanceof Error + ? e.message + : String(e); + } + } + + const eligibleAfterFilter = $derived( + eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id)) + ); + async function submitCreateScript(event: Event) { event.preventDefault(); if (!app) return; @@ -201,6 +264,68 @@ } } + async function submitAddMember(event: Event) { + event.preventDefault(); + if (!app || !addMemberUserId) return; + addingMember = true; + addMemberError = null; + try { + await api.appMembers.add(app.id, { + user_id: addMemberUserId, + role: addMemberRole + }); + addMemberUserId = ''; + addMemberRole = 'viewer'; + await loadMembers(app.id); + } catch (e) { + addMemberError = e instanceof Error ? e.message : String(e); + } finally { + addingMember = false; + } + } + + async function changeMemberRole(member: AppMemberDto, role: AppRole) { + if (!app || member.role === role) return; + roleChangeBusy = member.user_id; + memberActionError = null; + try { + await api.appMembers.setRole(app.id, member.user_id, role); + await loadMembers(app.id); + } catch (e) { + memberActionError = e instanceof Error ? e.message : String(e); + } finally { + roleChangeBusy = null; + } + } + + function askRemoveMember(member: AppMemberDto) { + removeMemberError = null; + memberToRemove = member; + } + + async function confirmRemoveMember() { + if (!app || !memberToRemove) return; + removingMember = true; + removeMemberError = null; + try { + await api.appMembers.remove(app.id, memberToRemove.user_id); + memberToRemove = null; + await loadMembers(app.id); + } catch (e) { + removeMemberError = e instanceof Error ? e.message : String(e); + } finally { + removingMember = false; + } + } + + function shortDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString(); + } catch { + return iso; + } + } + function askDeleteApp() { deleteAppError = null; confirmingDeleteApp = true; @@ -258,6 +383,13 @@ class:active={activeTab === 'domains'} onclick={() => (activeTab = 'domains')}>Domains ({domains.length}) + {#if canAdminMembers} + + {/if} + + + + {#if memberActionError} +
{memberActionError}
+ {/if} + + {#if members.length === 0} +

No explicit members yet.

+ {:else} +
+
+
User
+
Instance
+
App role
+
Joined
+
+
+ {#each members as m (m.user_id)} +
+
+ {m.username} + {#if m.email}{m.email}{/if} + {#if !m.is_active}(inactive){/if} +
+
+
+
{shortDate(m.created_at)}
+
+ changeMemberRole(m, 'app_admin') + }, + { + label: 'Make editor', + disabled: + m.role === 'editor' || roleChangeBusy === m.user_id, + onClick: () => changeMemberRole(m, 'editor') + }, + { + label: 'Make viewer', + disabled: + m.role === 'viewer' || roleChangeBusy === m.user_id, + onClick: () => changeMemberRole(m, 'viewer') + }, + { + label: 'Remove from app', + danger: true, + onClick: () => askRemoveMember(m) + } + ]} + /> +
+
+ {/each} +
+ {/if} + {:else if activeTab === 'settings'}

Settings

@@ -502,6 +749,26 @@ {/if} {/if} + + {#if memberToRemove} + (memberToRemove = null)} + > +

+ {memberToRemove.username} will lose access to this + app. Their other app memberships and account are untouched. +

+ {#if removeMemberError} + + {/if} +
+ {/if} {/if}