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:
@@ -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[]>(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
366
dashboard/src/routes/groups/+page.svelte
Normal file
366
dashboard/src/routes/groups/+page.svelte
Normal 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>
|
||||
789
dashboard/src/routes/groups/[slug]/+page.svelte
Normal file
789
dashboard/src/routes/groups/[slug]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user