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:
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