feat(dashboard): group tree, detail page, and members tab

SvelteKit UI for Phase-2 groups, mirroring the apps/users patterns:
- api.ts: Group/GroupDetail/GroupMember types + an api.groups client
  (list/get/create/update/reparent/delete + nested members CRUD); optional
  group selector wired into app create.
- routes/groups: a collapsible tree overview (assembled from parent_id)
  with a New-group form, and a detail page with breadcrumb, subgroups/apps
  lists, a rename form (no slug field — slug is frozen), a reparent
  control, a delete button that surfaces the 409 RESTRICT message, and a
  Members tab reusing RoleChip/ConfirmModal/ActionMenu.
- +layout.svelte: a Groups nav entry beside Apps.

npm run check: 0 errors; npm run build: success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 20:15:44 +02:00
parent eea1d8984e
commit 8725939172
5 changed files with 1270 additions and 2 deletions

View File

@@ -50,6 +50,47 @@ export interface App {
export type AppRole = 'app_admin' | 'editor' | 'viewer';
export interface Group {
id: string;
parent_id: string | null;
slug: string;
name: string;
description: string | null;
structure_version: number;
created_at: string;
updated_at: string;
}
export interface GroupDetail extends Group {
/** Root → … → this node breadcrumb. */
path: Group[];
subgroups: Group[];
apps: App[];
}
export interface GroupMember {
user_id: string;
username: string;
email: string | null;
instance_role: InstanceRole;
is_active: boolean;
role: AppRole;
created_at: string;
}
export interface CreateGroupInput {
slug: string;
name: string;
description?: string | null;
/** Parent group slug or id; omit (or null) for a root group. */
parent?: string | null;
}
export interface PatchGroupInput {
name?: string;
description?: string | null;
}
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain {
@@ -89,6 +130,8 @@ export interface CreateAppInput {
name: string;
description?: string | null;
force_takeover?: boolean;
/** Parent group slug or id; omit for the root group. */
group?: string | null;
}
export interface PatchAppInput {
@@ -778,6 +821,52 @@ export const api = {
)
},
groups: {
list: () => adminRequest<Group[]>('/api/v1/admin/groups'),
get: (idOrSlug: string) =>
adminRequest<GroupDetail>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`),
create: (input: CreateGroupInput) =>
adminRequest<Group>('/api/v1/admin/groups', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, input: PatchGroupInput) =>
adminRequest<Group>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
reparent: (idOrSlug: string, parent: string | null) =>
adminRequest<Group>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/reparent`,
{ method: 'POST', body: JSON.stringify({ parent }) }
),
delete: (idOrSlug: string) =>
adminRequest<null>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
method: 'DELETE'
}),
members: {
list: (idOrSlug: string) =>
adminRequest<GroupMember[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`
),
grant: (idOrSlug: string, input: GrantAppMemberInput) =>
adminRequest<GroupMember>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`,
{ method: 'POST', body: JSON.stringify(input) }
),
update: (idOrSlug: string, userId: string, role: AppRole) =>
adminRequest<GroupMember>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'PATCH', body: JSON.stringify({ role }) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
}
},
domains: {
listForApp: (idOrSlug: string) =>
adminRequest<AppDomain[]>(

View File

@@ -55,6 +55,7 @@
<a href={base + '/'} class="brand">PiCloud</a>
<nav>
<a href={base + '/apps'}>Apps</a>
<a href={base + '/groups'}>Groups</a>
{#if user && user.instance_role !== 'member'}
<a href={base + '/users'}>Users</a>
{/if}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api';
import { api, ApiError, type App, type Group } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth';
@@ -36,6 +36,17 @@
let createSlug = $state('');
let createName = $state('');
let createDescription = $state('');
// Optional parent group for the new app (defaults to root). Loaded
// lazily; failure leaves the picker empty (root-only).
let createGroup = $state('');
let groups = $state<Group[]>([]);
async function loadGroups() {
try {
groups = await api.groups.list();
} catch {
groups = [];
}
}
// Auto-derive slug from name until the user takes manual control of
// the slug field. Clearing the slug input releases the lock so the
// auto-derive resumes — matches the GitLab project-create UX.
@@ -69,6 +80,7 @@
listError = null;
try {
apps = await api.apps.list();
void loadGroups();
if (apps && apps.length > 0) {
void loadDlCounts(apps);
}
@@ -84,6 +96,7 @@
createSlug = '';
createName = '';
createDescription = '';
createGroup = '';
createError = null;
createHistoricalConflict = null;
slugTouched = false;
@@ -99,7 +112,8 @@
slug: createSlug.trim(),
name: createName.trim(),
description: createDescription.trim() || null,
force_takeover: forceTakeover || undefined
force_takeover: forceTakeover || undefined,
group: createGroup.trim() || undefined
});
showCreate = false;
resetCreate();
@@ -172,6 +186,15 @@
<span>Description</span>
<input bind:value={createDescription} placeholder="optional" />
</label>
<label>
<span>Group (optional)</span>
<select bind:value={createGroup}>
<option value="">— root —</option>
{#each groups as g (g.id)}
<option value={g.slug}>{g.name} (/{g.slug})</option>
{/each}
</select>
</label>
{#if createHistoricalConflict}
<div class="warning">
<strong>Slug previously redirected.</strong>

View File

@@ -0,0 +1,366 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type Group } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth';
const me = $derived($currentUser);
// Group creation mirrors app creation's gate — owner/admin only.
const canCreate = $derived(canCreateApp(me));
let groups = $state<Group[] | null>(null);
let listError = $state<string | null>(null);
let loading = $state(true);
// Tree assembly: index children by parent_id so we can render the
// flat list as a nested tree. Roots have parent_id === null.
interface TreeNode {
group: Group;
children: TreeNode[];
}
const tree = $derived.by<TreeNode[]>(() => {
if (!groups) return [];
const byParent = new Map<string | null, Group[]>();
for (const g of groups) {
const key = g.parent_id;
const bucket = byParent.get(key) ?? [];
bucket.push(g);
byParent.set(key, bucket);
}
const build = (parentId: string | null): TreeNode[] =>
(byParent.get(parentId) ?? [])
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => ({ group, children: build(group.id) }));
return build(null);
});
// Collapse state, keyed by group id. Default: expanded.
let collapsed = $state<Record<string, boolean>>({});
function toggle(id: string) {
collapsed = { ...collapsed, [id]: !collapsed[id] };
}
// Create form
let showCreate = $state(false);
let createSlug = $state('');
let createName = $state('');
let createDescription = $state('');
let createParent = $state('');
let slugTouched = $state(false);
let creating = $state(false);
let createError = $state<string | null>(null);
function onNameInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
createName = value;
if (!slugTouched) {
createSlug = slugify(value);
}
}
function onSlugInput(event: Event) {
const raw = (event.target as HTMLInputElement).value;
const normalized = slugify(raw);
createSlug = normalized;
if (raw !== normalized) {
(event.target as HTMLInputElement).value = normalized;
}
slugTouched = normalized.length > 0;
}
async function load() {
loading = true;
listError = null;
try {
groups = await api.groups.list();
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
groups = null;
} finally {
loading = false;
}
}
function resetCreate() {
createSlug = '';
createName = '';
createDescription = '';
createParent = '';
createError = null;
slugTouched = false;
}
async function submitCreate(event: Event) {
event.preventDefault();
creating = true;
createError = null;
try {
await api.groups.create({
slug: createSlug.trim(),
name: createName.trim(),
description: createDescription.trim() || null,
parent: createParent.trim() || undefined
});
showCreate = false;
resetCreate();
await load();
} catch (e) {
createError =
e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
$effect(() => {
void load();
});
</script>
<section>
<header class="page-header">
<h1>Groups</h1>
{#if canCreate}
<button
type="button"
onclick={() => {
showCreate = !showCreate;
if (!showCreate) resetCreate();
}}
>
{showCreate ? 'Cancel' : 'New group'}
</button>
{/if}
</header>
{#if showCreate && canCreate}
<form class="create-form" onsubmit={submitCreate}>
<div class="row">
<label>
<span>Name</span>
<input value={createName} oninput={onNameInput} required placeholder="My Group" />
</label>
<label>
<span>Slug</span>
<input
value={createSlug}
oninput={onSlugInput}
required
pattern="[a-z0-9][a-z0-9-]*"
maxlength={SLUG_MAX}
placeholder="my-group"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
</label>
</div>
<label>
<span>Parent group (optional)</span>
<select bind:value={createParent}>
<option value="">— root —</option>
{#each groups ?? [] as g (g.id)}
<option value={g.slug}>{g.name} (/{g.slug})</option>
{/each}
</select>
</label>
<label>
<span>Description</span>
<input bind:value={createDescription} placeholder="optional" />
</label>
{#if createError}
<div class="error">{createError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create group'}
</button>
</div>
</form>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if listError}
<div class="error">
<strong>Could not load groups.</strong>
<p>{listError}</p>
<button type="button" onclick={() => void load()}>Retry</button>
</div>
{:else if groups && groups.length === 0}
<p class="muted">No groups yet. Create one above to get started.</p>
{:else if groups}
<ul class="tree">
{#each tree as node (node.group.id)}
{@render branch(node, 0)}
{/each}
</ul>
{/if}
</section>
{#snippet branch(node: TreeNode, depth: number)}
<li>
<div class="node" style="padding-left: {depth * 1.25}rem">
{#if node.children.length > 0}
<button
type="button"
class="twisty"
aria-label={collapsed[node.group.id] ? 'Expand' : 'Collapse'}
onclick={() => toggle(node.group.id)}
>
{collapsed[node.group.id] ? '▸' : '▾'}
</button>
{:else}
<span class="twisty-spacer"></span>
{/if}
<a href="{base}/groups/{node.group.slug}">
<strong>{node.group.name}</strong>
<span class="muted">/{node.group.slug}</span>
</a>
</div>
{#if node.children.length > 0 && !collapsed[node.group.id]}
<ul>
{#each node.children as child (child.group.id)}
{@render branch(child, depth + 1)}
{/each}
</ul>
{/if}
</li>
{/snippet}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.tree {
list-style: none;
padding: 0;
margin: 0;
}
.tree ul {
list-style: none;
padding: 0;
margin: 0;
}
.node {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.25rem;
}
.node a {
flex: 1;
display: flex;
gap: 0.5rem;
align-items: baseline;
padding: 0.55rem 0.75rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.node a:hover {
background: #283549;
}
.twisty {
background: transparent;
color: #94a3b8;
border: none;
padding: 0;
width: 1.25rem;
font-size: 0.85rem;
font-weight: 400;
cursor: pointer;
flex: none;
}
.twisty-spacer {
width: 1.25rem;
flex: none;
}
</style>

View File

@@ -0,0 +1,789 @@
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
api,
ApiError,
type AdminDto,
type Group,
type GroupDetail,
type GroupMember,
type AppRole
} from '$lib/api';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
import { canCreateApp } from '$lib/capabilities';
const me = $derived($currentUser);
// Mirror app-admin gating: instance owner/admin manage groups, their
// members, structure, and deletion. The backend is the ground truth.
const canManage = $derived(canCreateApp(me));
type Tab = 'overview' | 'members' | 'settings';
const isValidTab = (s: string | null): s is Tab =>
s === 'overview' || s === 'members' || s === 'settings';
let activeTab = $derived.by<Tab>(() => {
const qp = page.url.searchParams.get('tab');
return isValidTab(qp) ? qp : 'overview';
});
let slug = $derived(page.params.slug ?? '');
let group = $state<GroupDetail | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(true);
// All groups — used for the reparent + rename forms and to offer a
// parent picker that excludes the node itself.
let allGroups = $state<Group[]>([]);
// Settings (rename — name/description only; slug is frozen).
let editName = $state('');
let editDescription = $state('');
let savingSettings = $state(false);
let settingsError = $state<string | null>(null);
// Reparent.
let reparentTarget = $state('');
let reparenting = $state(false);
let reparentError = $state<string | null>(null);
// Delete.
let confirmingDelete = $state(false);
let deleting = $state(false);
let deleteError = $state<string | null>(null);
// Members.
let members = $state<GroupMember[]>([]);
let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(null);
let addMemberUserId = $state('');
let addMemberRole = $state<AppRole>('viewer');
let addingMember = $state(false);
let addMemberError = $state<string | null>(null);
let memberToRemove = $state<GroupMember | null>(null);
let removingMember = $state(false);
let removeMemberError = $state<string | null>(null);
let roleChangeBusy = $state<string | null>(null);
let memberActionError = $state<string | null>(null);
async function loadGroup() {
loading = true;
loadError = null;
try {
const fetched = await api.groups.get(slug);
group = fetched;
editName = fetched.name;
editDescription = fetched.description ?? '';
reparentTarget = fetched.parent_id ?? '';
const loaders: Promise<unknown>[] = [loadAllGroups()];
if (canManage) {
loaders.push(loadMembers(slug), loadEligibleUsers());
}
await Promise.all(loaders);
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function loadAllGroups() {
try {
allGroups = await api.groups.list();
} catch {
allGroups = [];
}
}
async function loadMembers(idOrSlug: string) {
try {
members = await api.groups.members.list(idOrSlug);
} catch (e) {
members = [];
memberActionError = e instanceof Error ? e.message : String(e);
}
}
async function loadEligibleUsers() {
eligibleLoadError = null;
try {
const all = await api.admins.list();
eligibleUsers = all.filter((u) => u.is_active && u.instance_role === 'member');
} catch (e) {
eligibleUsers = [];
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))
);
// Reparent picker options: every group except this node (a group
// can't parent itself; the backend also rejects descendant cycles
// with a 409, surfaced below).
const reparentOptions = $derived(allGroups.filter((g) => g.id !== group?.id));
async function saveSettings(event: Event) {
event.preventDefault();
if (!group) return;
savingSettings = true;
settingsError = null;
try {
const updated = await api.groups.update(group.id, {
name: editName.trim() !== group.name ? editName.trim() : undefined,
description:
editDescription !== (group.description ?? '')
? editDescription || null
: undefined
});
group = { ...group, name: updated.name, description: updated.description };
} catch (e) {
settingsError = e instanceof Error ? e.message : String(e);
} finally {
savingSettings = false;
}
}
async function submitReparent(event: Event) {
event.preventDefault();
if (!group) return;
reparenting = true;
reparentError = null;
try {
await api.groups.reparent(group.id, reparentTarget.trim() || null);
await loadGroup();
} catch (e) {
reparentError =
e instanceof ApiError && e.status === 409
? e.message
: e instanceof Error
? e.message
: String(e);
} finally {
reparenting = false;
}
}
function askDelete() {
deleteError = null;
confirmingDelete = true;
}
async function confirmDelete() {
if (!group) return;
deleting = true;
deleteError = null;
try {
await api.groups.delete(group.id);
await goto(`${base}/groups`);
} catch (e) {
// 409 RESTRICT: the group still has subgroups or apps. Surface
// the server's message cleanly rather than a bare status code.
deleteError = e instanceof Error ? e.message : String(e);
} finally {
deleting = false;
}
}
async function submitAddMember(event: Event) {
event.preventDefault();
if (!group || !addMemberUserId) return;
addingMember = true;
addMemberError = null;
try {
await api.groups.members.grant(group.id, {
user_id: addMemberUserId,
role: addMemberRole
});
addMemberUserId = '';
addMemberRole = 'viewer';
await loadMembers(group.id);
} catch (e) {
addMemberError = e instanceof Error ? e.message : String(e);
} finally {
addingMember = false;
}
}
async function changeMemberRole(member: GroupMember, role: AppRole) {
if (!group || member.role === role) return;
roleChangeBusy = member.user_id;
memberActionError = null;
try {
await api.groups.members.update(group.id, member.user_id, role);
await loadMembers(group.id);
} catch (e) {
memberActionError = e instanceof Error ? e.message : String(e);
} finally {
roleChangeBusy = null;
}
}
function askRemoveMember(member: GroupMember) {
removeMemberError = null;
memberToRemove = member;
}
async function confirmRemoveMember() {
if (!group || !memberToRemove) return;
removingMember = true;
removeMemberError = null;
try {
await api.groups.members.remove(group.id, memberToRemove.user_id);
memberToRemove = null;
await loadMembers(group.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;
}
}
$effect(() => {
void slug;
void loadGroup();
});
// A viewer following a stale link to a manage-only tab gets bounced
// to the overview. The backend still 403s the underlying calls.
$effect(() => {
if (!canManage && (activeTab === 'settings' || activeTab === 'members')) {
void goto(`${base}/groups/${slug}?tab=overview`, { replaceState: true });
}
});
</script>
<header class="page-head">
<div class="breadcrumb">
<a href="{base}/groups">Groups</a>
{#if group}
{#each group.path as p (p.id)}
<span aria-hidden="true">/</span>
{#if p.id === group.id}
<code>{p.slug}</code>
{:else}
<a href="{base}/groups/{p.slug}">{p.name}</a>
{/if}
{/each}
{:else}
<span aria-hidden="true">/</span>
<code>{slug}</code>
{/if}
</div>
{#if group}
<h1>{group.name}</h1>
{#if group.description}<p class="muted">{group.description}</p>{/if}
{/if}
</header>
{#if group}
<nav class="tabbar">
<a href="{base}/groups/{slug}?tab=overview" class:active={activeTab === 'overview'}>Overview</a>
{#if canManage}
<a href="{base}/groups/{slug}?tab=members" class:active={activeTab === 'members'}>Members</a>
<a href="{base}/groups/{slug}?tab=settings" class:active={activeTab === 'settings'}>Settings</a>
{/if}
</nav>
{#if activeTab === 'overview'}
<section>
<h2>Subgroups</h2>
{#if group.subgroups.length === 0}
<p class="muted">No subgroups.</p>
{:else}
<ul class="list">
{#each group.subgroups as sub (sub.id)}
<li>
<a href="{base}/groups/{sub.slug}">
<div class="primary">
<strong>{sub.name}</strong>
<span class="muted">/{sub.slug}</span>
</div>
<div class="secondary muted">{sub.description ?? '—'}</div>
</a>
</li>
{/each}
</ul>
{/if}
<h2>Apps</h2>
{#if group.apps.length === 0}
<p class="muted">No apps in this group.</p>
{:else}
<ul class="list">
{#each group.apps as app (app.id)}
<li>
<a href="{base}/apps/{app.slug}">
<div class="primary">
<strong>{app.name}</strong>
<span class="muted">/{app.slug}</span>
</div>
<div class="secondary muted">{app.description ?? '—'}</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'members' && canManage}
<section>
<h2>Members</h2>
<p class="muted">
Users with explicit access to this group. Instance owners and admins
already have implicit access — they are not listed here. Use the Users
page to invite a <code>member</code> first, then grant them group access
below.
</p>
<form class="create-form" onsubmit={submitAddMember}>
<div class="row">
<label class="grow">
<span>User</span>
<select
bind:value={addMemberUserId}
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
required
>
<option value="" disabled>Pick a member to invite…</option>
{#each eligibleAfterFilter as u (u.id)}
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
{/each}
</select>
</label>
<label>
<span>Role</span>
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
<option value="viewer">viewer</option>
<option value="editor">editor</option>
<option value="app_admin">app admin</option>
</select>
</label>
</div>
{#if eligibleLoadError}
<p class="muted">{eligibleLoadError}</p>
{:else if eligibleAfterFilter.length === 0}
<p class="muted">
No eligible users to invite. Create a <code>member</code> on the Users
page first.
</p>
{/if}
{#if addMemberError}
<div class="error">{addMemberError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
>
{addingMember ? 'Adding…' : 'Add member'}
</button>
</div>
</form>
{#if memberActionError}
<div class="error">{memberActionError}</div>
{/if}
{#if members.length === 0}
<p class="muted">No explicit members yet.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>User</div>
<div>Instance</div>
<div>Group role</div>
<div>Joined</div>
<div class="actions-col"></div>
</div>
{#each members as m (m.user_id)}
<div class="row member-row" class:inactive={!m.is_active}>
<div>
<strong>{m.username}</strong>
{#if m.email}<span class="muted">{m.email}</span>{/if}
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
</div>
<div><RoleChip role={m.instance_role} size="sm" /></div>
<div><RoleChip appRole={m.role} size="sm" /></div>
<div>{shortDate(m.created_at)}</div>
<div class="actions-col">
<ActionMenu
label="Member actions for {m.username}"
items={[
{
label: 'Make app admin',
disabled: m.role === 'app_admin' || roleChangeBusy === m.user_id,
onClick: () => 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 group',
danger: true,
onClick: () => askRemoveMember(m)
}
]}
/>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'settings' && canManage}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={saveSettings}>
<label>
<span>Name</span>
<input bind:value={editName} required />
</label>
<label>
<span>Description</span>
<input bind:value={editDescription} />
</label>
<p class="muted small">
The slug <code>/{group.slug}</code> is frozen and cannot be changed.
</p>
{#if settingsError}
<div class="error">{settingsError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={savingSettings}>
{savingSettings ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
<h2>Move group</h2>
<form class="create-form" onsubmit={submitReparent}>
<label>
<span>Parent group</span>
<select bind:value={reparentTarget}>
<option value="">— root —</option>
{#each reparentOptions as g (g.id)}
<option value={g.id}>{g.name} (/{g.slug})</option>
{/each}
</select>
</label>
{#if reparentError}
<div class="error">{reparentError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={reparenting}>
{reparenting ? 'Moving…' : 'Move group'}
</button>
</div>
</form>
<div class="danger-zone">
<h3>Delete group</h3>
<p class="muted">
Removes this group. The group must be empty first — it can't have any
subgroups or apps.
</p>
<button type="button" class="danger" onclick={askDelete}>Delete group</button>
</div>
</section>
{/if}
{#if confirmingDelete}
<ConfirmModal
title="Delete group “{group.name}”"
variant="danger"
confirmLabel="Delete group"
busyLabel="Deleting…"
busy={deleting}
onConfirm={confirmDelete}
onCancel={() => (confirmingDelete = false)}
>
<p>
This permanently removes the group <strong>{group.name}</strong>. The
group must be empty — if it still has subgroups or apps, the delete is
rejected and the reason appears below.
</p>
{#if deleteError}
<p class="modal-error">{deleteError}</p>
{/if}
</ConfirmModal>
{/if}
{#if memberToRemove}
<ConfirmModal
title="Remove {memberToRemove.username} from {group.name}"
variant="danger"
confirmLabel="Remove member"
busyLabel="Removing…"
busy={removingMember}
onConfirm={confirmRemoveMember}
onCancel={() => (memberToRemove = null)}
>
<p>
<strong>{memberToRemove.username}</strong> will lose access to this
group. Their other memberships and account are untouched.
</p>
{#if removeMemberError}
<p class="modal-error">{removeMemberError}</p>
{/if}
</ConfirmModal>
{/if}
{:else if loading}
<p class="muted">Loading…</p>
{:else if loadError}
<p class="error">{loadError}</p>
{/if}
<style>
.page-head {
margin-bottom: 1rem;
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: baseline;
font-size: 0.875rem;
color: var(--text-muted);
}
.breadcrumb a {
color: var(--color-link);
text-decoration: none;
}
.breadcrumb code {
background: var(--bg-elevated);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
}
h1 {
margin: 0.25rem 0 0.25rem;
font-size: 1.5rem;
}
h2 {
font-size: 1.125rem;
margin: 1.5rem 0 1rem;
}
h3 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
.tabbar {
display: flex;
gap: 1.25rem;
border-bottom: 1px solid #1e293b;
margin-bottom: 1.5rem;
}
.tabbar a {
color: #94a3b8;
text-decoration: none;
font-size: 0.9rem;
padding: 0.5rem 0;
border-bottom: 2px solid transparent;
}
.tabbar a:hover {
color: #e2e8f0;
}
.tabbar a.active {
color: #e2e8f0;
border-bottom-color: #38bdf8;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.small {
font-size: 0.85rem;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.modal-error {
color: #fca5a5;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form .row > label.grow {
grid-column: span 2;
}
.create-form input,
.create-form select {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
.danger-zone {
margin-top: 2rem;
padding: 1rem;
border: 1px solid #7f1d1d;
border-radius: 0.5rem;
background: #1e0a0a;
}
.table {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.table .row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
gap: 0.75rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
align-items: center;
margin: 0;
}
.table .head-row {
background: transparent;
padding: 0.25rem 1rem;
color: #64748b;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table .member-row.inactive {
opacity: 0.55;
}
.table .member-row strong {
margin-right: 0.4rem;
}
.table .member-row .muted {
font-size: 0.8rem;
}
.table .actions-col {
display: flex;
justify-content: flex-end;
}
</style>