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 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 type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||||
|
|
||||||
export interface AppDomain {
|
export interface AppDomain {
|
||||||
@@ -89,6 +130,8 @@ export interface CreateAppInput {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
force_takeover?: boolean;
|
force_takeover?: boolean;
|
||||||
|
/** Parent group slug or id; omit for the root group. */
|
||||||
|
group?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchAppInput {
|
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: {
|
domains: {
|
||||||
listForApp: (idOrSlug: string) =>
|
listForApp: (idOrSlug: string) =>
|
||||||
adminRequest<AppDomain[]>(
|
adminRequest<AppDomain[]>(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
<a href={base + '/'} class="brand">PiCloud</a>
|
<a href={base + '/'} class="brand">PiCloud</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href={base + '/apps'}>Apps</a>
|
<a href={base + '/apps'}>Apps</a>
|
||||||
|
<a href={base + '/groups'}>Groups</a>
|
||||||
{#if user && user.instance_role !== 'member'}
|
{#if user && user.instance_role !== 'member'}
|
||||||
<a href={base + '/users'}>Users</a>
|
<a href={base + '/users'}>Users</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { base } from '$app/paths';
|
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 { slugify, SLUG_MAX } from '$lib/slugify';
|
||||||
import { canCreateApp } from '$lib/capabilities';
|
import { canCreateApp } from '$lib/capabilities';
|
||||||
import { currentUser } from '$lib/auth';
|
import { currentUser } from '$lib/auth';
|
||||||
@@ -36,6 +36,17 @@
|
|||||||
let createSlug = $state('');
|
let createSlug = $state('');
|
||||||
let createName = $state('');
|
let createName = $state('');
|
||||||
let createDescription = $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
|
// Auto-derive slug from name until the user takes manual control of
|
||||||
// the slug field. Clearing the slug input releases the lock so the
|
// the slug field. Clearing the slug input releases the lock so the
|
||||||
// auto-derive resumes — matches the GitLab project-create UX.
|
// auto-derive resumes — matches the GitLab project-create UX.
|
||||||
@@ -69,6 +80,7 @@
|
|||||||
listError = null;
|
listError = null;
|
||||||
try {
|
try {
|
||||||
apps = await api.apps.list();
|
apps = await api.apps.list();
|
||||||
|
void loadGroups();
|
||||||
if (apps && apps.length > 0) {
|
if (apps && apps.length > 0) {
|
||||||
void loadDlCounts(apps);
|
void loadDlCounts(apps);
|
||||||
}
|
}
|
||||||
@@ -84,6 +96,7 @@
|
|||||||
createSlug = '';
|
createSlug = '';
|
||||||
createName = '';
|
createName = '';
|
||||||
createDescription = '';
|
createDescription = '';
|
||||||
|
createGroup = '';
|
||||||
createError = null;
|
createError = null;
|
||||||
createHistoricalConflict = null;
|
createHistoricalConflict = null;
|
||||||
slugTouched = false;
|
slugTouched = false;
|
||||||
@@ -99,7 +112,8 @@
|
|||||||
slug: createSlug.trim(),
|
slug: createSlug.trim(),
|
||||||
name: createName.trim(),
|
name: createName.trim(),
|
||||||
description: createDescription.trim() || null,
|
description: createDescription.trim() || null,
|
||||||
force_takeover: forceTakeover || undefined
|
force_takeover: forceTakeover || undefined,
|
||||||
|
group: createGroup.trim() || undefined
|
||||||
});
|
});
|
||||||
showCreate = false;
|
showCreate = false;
|
||||||
resetCreate();
|
resetCreate();
|
||||||
@@ -172,6 +186,15 @@
|
|||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
<input bind:value={createDescription} placeholder="optional" />
|
<input bind:value={createDescription} placeholder="optional" />
|
||||||
</label>
|
</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}
|
{#if createHistoricalConflict}
|
||||||
<div class="warning">
|
<div class="warning">
|
||||||
<strong>Slug previously redirected.</strong>
|
<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