feat: implement drag-and-drop machine reordering with swapy and add new system administration management routes

This commit is contained in:
2026-06-23 01:20:45 +02:00
parent 856c9d3837
commit f8a17fa68a
28 changed files with 3128 additions and 395 deletions
+4 -2
View File
@@ -17,17 +17,19 @@
let { children } = $props();
const user = $derived(getUser());
let showSidebar =$derived( (cU:null|User)=> cU && (page.url.pathname.startsWith('/dashboard')||page.url.pathname.startsWith('/admin')))
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<Sidebar.Provider style="--sidebar-width: 350px;">
{@const currentUser = await user}
{#if currentUser}
{@const show = currentUser && showSidebar(currentUser)}
{#if show }
<AppSidebar user={currentUser} />
{/if}
<Sidebar.Inset>
{#if await user}
{#if show}
<header
class="bg-background sticky top-0 flex shrink-0 items-center gap-2 border-b p-4 h-15 z-50"
>
-2
View File
@@ -1,3 +1 @@
// Forces hooks.server.ts to run on every /admin navigation (client-side router
// skips the server otherwise when no server load is defined).
export const load = () => {};
+121 -114
View File
@@ -194,11 +194,17 @@
}
</script>
<div class="flex flex-col gap-4 p-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">{m.users_title()}</h1>
<p class="text-muted-foreground text-sm">{m.users_description()}</p>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight flex gap-2 items-center truncate">
{m.users_title()}
</h1>
<p class="text-muted-foreground text-sm truncate">
{m.users_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
@@ -214,10 +220,10 @@
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0" align="end">
<div class="border-b p-4">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="grid grid-cols-2 gap-3 p-4">
<div class="grid grid-cols-2 gap-3 p-2">
<div class="flex flex-col gap-1.5">
<Label class="flex items-center gap-1 text-xs"
>{m.users_filter_active()} <InfoIcon class="size-3 opacity-60" /></Label
@@ -239,7 +245,7 @@
</NativeSelect>
</div>
</div>
<div class="border-t p-4">
<div class="border-t p-2">
<Label class="mb-2 block text-xs">{m.users_filter_date_range()}</Label>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
@@ -268,7 +274,7 @@
</div>
</div>
</div>
<div class="flex flex-col gap-3 border-t p-4">
<div class="flex flex-col gap-3 border-t p-2">
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_email_verified()}</span>
<Checkbox
@@ -302,7 +308,7 @@
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-4">
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
@@ -328,7 +334,7 @@
</datalist>
</div>
</div>
<div class="flex items-center justify-between border-t p-3">
<div class="flex items-center justify-between border-t p-2">
<Button variant="ghost" size="sm" onclick={resetFilters}
>{m.users_filter_reset()}</Button
>
@@ -365,7 +371,7 @@
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.users_search_placeholder()}
value={search}
@@ -373,113 +379,114 @@
class="max-w-sm"
/>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'username', label: m.username() }, { key: 'email', label: m.email() }, { key: 'createdAt', label: m.users_created_at() }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.users_role()}</Table.Head>
<Table.Head>{m.users_status()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if usersQuery.loading && !data.users.length}
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !data.users.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.users_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each data.users as u (u.id)}
<Table.Row>
<Table.Cell class="font-medium">{u.name}</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.username ?? '—'}</Table.Cell>
<Table.Cell>{u.email}</Table.Cell>
<Table.Cell>{new Date(u.createdAt).toLocaleDateString()}</Table.Cell>
<Table.Cell>
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
{u.role === 'admin' ? m.users_role_admin() : m.users_role_user()}
</Badge>
</Table.Cell>
<Table.Cell>
{#if u.banned}
<Badge variant="destructive">{m.users_banned()}</Badge>
{:else if !u.emailVerified}
<Badge
variant="secondary"
title={u.inviteExpiresAt
? m.users_pending_expires({
date: new Date(u.inviteExpiresAt).toLocaleString()
})
: m.users_pending_no_invite()}
>
{m.users_pending()}
</Badge>
{:else}
<Badge variant="outline">{m.users_active()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(u)}
>{m.users_edit()}</DropdownMenu.Item
>
{#if !u.emailVerified}
<DropdownMenu.Item onclick={() => doResendInvite(u)}
>{m.users_resend_invite()}</DropdownMenu.Item
>
{#each [{ key: 'name', label: m.name() }, { key: 'username', label: m.username() }, { key: 'email', label: m.email() }, { key: 'createdAt', label: m.users_created_at() }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
<DropdownMenu.Item onclick={() => toggleBan(u)}>
{u.banned ? m.users_unban() : m.users_ban()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(u)}>
{m.users_delete()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.users_role()}</Table.Head>
<Table.Head>{m.users_status()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if usersQuery.loading && !data.users.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{:else if !data.users.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.users_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each data.users as u (u.id)}
<Table.Row>
<Table.Cell class="font-medium">{u.name}</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.username ?? '—'}</Table.Cell>
<Table.Cell>{u.email}</Table.Cell>
<Table.Cell>{new Date(u.createdAt).toLocaleDateString()}</Table.Cell>
<Table.Cell>
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
{u.role === 'admin' ? m.users_role_admin() : m.users_role_user()}
</Badge>
</Table.Cell>
<Table.Cell>
{#if u.banned}
<Badge variant="destructive">{m.users_banned()}</Badge>
{:else if !u.emailVerified}
<Badge
variant="secondary"
title={u.inviteExpiresAt
? m.users_pending_expires({
date: new Date(u.inviteExpiresAt).toLocaleString()
})
: m.users_pending_no_invite()}
>
{m.users_pending()}
</Badge>
{:else}
<Badge variant="outline">{m.users_active()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEdit(u)}
>{m.users_edit()}</DropdownMenu.Item
>
{#if !u.emailVerified}
<DropdownMenu.Item onclick={() => doResendInvite(u)}
>{m.users_resend_invite()}</DropdownMenu.Item
>
{/if}
<DropdownMenu.Item onclick={() => toggleBan(u)}>
{u.banned ? m.users_unban() : m.users_ban()}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(u)}>
{m.users_delete()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
+18 -15
View File
@@ -18,6 +18,7 @@
} from '@lucide/svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Trunk from '$lib/components/blocks/trunk/trunk.svelte';
import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte';
import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte';
@@ -45,6 +46,7 @@
import { Input } from '$lib/components/ui/input';
import { Progress } from '$lib/components/ui/progress';
import * as Select from '$lib/components/ui/select';
import { Spinner } from '$lib/components/ui/spinner';
import { machineDeleteSchema, machineEditSchema } from '$lib/machines/schema';
import { m } from '$lib/paraglide/messages';
import { deleteMachine, listMachines, updateMachine } from '$lib/remotes/machines.remote';
@@ -57,9 +59,10 @@
} from '$lib/remotes/server.remote';
import { toast } from 'svelte-sonner';
const info = $derived(serverInfo());
const audit = $derived(auditLog());
const details = $derived(systemDetails());
const machineId = $derived(page.params.machineId!)
const info = $derived(serverInfo(machineId));
const audit = $derived(auditLog({limit:undefined,machineId}));
const details = $derived(systemDetails(machineId));
const latest = $derived(latestAgentRelease());
const intervals = $derived([
{ label: m.dashboard_interval_second(), value: '1' },
@@ -76,19 +79,16 @@
const intervalLabel = $derived(intervals.find((i) => i.value === syncInterval)?.label);
const poll = async () => {
console.log('refreshed poll');
await serverInfo().refresh();
await auditLog().refresh();
await serverInfo(machineId).refresh();
await auditLog({limit:undefined, machineId}).refresh();
};
const refreshAll = async () => {
console.log('refreshed all');
await poll();
await systemDetails().refresh();
await systemDetails(machineId).refresh();
};
let polling = $state(true);
// $effect re-runs when rate or polling toggle, so changing the selector takes
// effect immediately and pausing actually stops the timer.
$effect(() => {
if (!polling || intervalMs <= 0) return;
const id = setInterval(poll, intervalMs);
@@ -217,7 +217,7 @@
{sys.$db.name}
</h1>
<p class="text-muted-foreground text-sm truncate">
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$agent_version} | {sys.$db.address}
</p>
</div>
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
@@ -295,7 +295,7 @@
const from = agentVersion ?? '?';
const to = latestTag ?? '?';
try {
await updateAgent();
await updateAgent(machineId);
toast.info(m.agent_update_started({ from, to }));
// Agent returns 202 and runs update in background; poll until the
// reported version flips or we give up.
@@ -387,8 +387,8 @@
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
/>
</div>
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
<div class="col-span-2">
<div class="grid items-center gap-4 justify-center grid-cols-1 lg:grid-cols-3 p-4 py-2">
<div class="col-span-2 w-full">
<StoragePanel items={sys.disks ?? []} />
</div>
@@ -402,7 +402,7 @@
/>
</div>
<div class="grid items-start gap-4 lg:grid-cols-3 p-4 py-2">
<div class="grid items-start gap-4 xl:grid-cols-3 p-4 py-2">
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
<NetworkPanel items={sys.network_interfaces ?? []} />
<TemperaturePanel items={sys.temperatures ?? []} />
@@ -518,6 +518,9 @@
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{#snippet pending()}
<Spinner class="size-24 m-auto"/>
{/snippet}
</svelte:boundary>
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import ClockIcon from '@lucide/svelte/icons/clock';
import GlobeIcon from '@lucide/svelte/icons/globe';
import PowerIcon from '@lucide/svelte/icons/power';
import ServerIcon from '@lucide/svelte/icons/server';
import {resolve} from '$app/paths'
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
const base = $derived(`/dashboard/${page.params.machineId}/system`);
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system()}</h1>
<div class="grid gap-4 sm:grid-cols-2">
<a href={resolve(`${base}/date-time` as Pathname)} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ClockIcon class="size-5" />
<Card.Title>{m.nav_system_datetime()}</Card.Title>
</div>
<Card.Description>{m.nav_system_datetime_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/hostname` as Pathname)} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<ServerIcon class="size-5" />
<Card.Title>{m.nav_system_hostname()}</Card.Title>
</div>
<Card.Description>{m.nav_system_hostname_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/localization` as Pathname)} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<GlobeIcon class="size-5" />
<Card.Title>{m.nav_system_localization()}</Card.Title>
</div>
<Card.Description>{m.nav_system_localization_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
<a href={resolve(`${base}/power` as Pathname)} class="block">
<Card.Root class="hover:bg-accent/40 h-full transition">
<Card.Header>
<div class="flex items-center gap-2">
<PowerIcon class="size-5" />
<Card.Title>{m.nav_system_power()}</Card.Title>
</div>
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
</Card.Header>
</Card.Root>
</a>
</div>
</div>
@@ -0,0 +1,164 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Command from '$lib/components/ui/command';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import {
listTimezones,
setNtp,
setTime,
setTimezone,
systemTime
} from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const time = $derived(systemTime(machineId));
const tzs = $derived(listTimezones(machineId));
const formId = $props.id();
let tzOpen = $state(false);
let saving = $state(false);
// ponytail: builds an RFC3339 UTC string from a datetime-local value (treated as local).
function rfc3339FromLocal(v: string) {
return new Date(v).toISOString().replace(/\.\d{3}Z$/, 'Z');
}
async function withSaving<T>(fn: () => Promise<T>) {
saving = true;
try {
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
} finally {
saving = false;
}
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_datetime()}</h1>
<svelte:boundary>
{#snippet failed(err)}
<Card.Root>
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
</Card.Root>
{/snippet}
{@const t = await time}
{@const zones = await tzs}
<Card.Root>
<Card.Header>
<Card.Title>{m.system_time_current()}</Card.Title>
<Card.Description>{t.time}</Card.Description>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_time_timezone()}</Card.Title>
<Card.Description>{t.timezone}</Card.Description>
</Card.Header>
<Card.Content>
<Popover.Root bind:open={tzOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={tzOpen}
class="w-full justify-between sm:w-80"
{...props}
>
{t.timezone}
<ChevronsUpDownIcon class="size-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0">
<Command.Root>
<Command.Input placeholder={m.system_time_search_timezone_placeholder()} />
<Command.List class="max-h-72">
<Command.Empty>{m.system_time_no_timezone_found()}</Command.Empty>
{#each zones as z (z)}
<Command.Item
value={z}
onSelect={async () => {
tzOpen = false;
if (z === t.timezone) return;
await withSaving(() => setTimezone({machineId,timezone:z}));
}}
>
<CheckIcon
class={'mr-2 size-4 ' + (z === t.timezone ? 'opacity-100' : 'opacity-0')}
/>
{z}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_time_ntp()}</Card.Title>
<Card.Description>{m.system_time_ntp_hint()}</Card.Description>
</Card.Header>
<Card.Content class="flex items-center justify-between gap-4">
<div class="text-muted-foreground text-sm">
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
</div>
<Switch
checked={t.ntp}
disabled={saving || !t.can_ntp}
onCheckedChange={(v) => withSaving(() => setNtp({enabled:v, machineId}))}
/>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_time_manual()}</Card.Title>
<Card.Description>{m.system_time_manual_hint()}</Card.Description>
</Card.Header>
<Card.Content>
<form
class="flex flex-col gap-2 sm:flex-row sm:items-end"
onsubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const v = String(fd.get('time') ?? '').trim();
if (!v) return;
await withSaving(() => setTime({machineId,time:rfc3339FromLocal(v)}));
}}
>
<div class="flex grow flex-col gap-1.5">
<Label for={formId + 'time'}>{m.system_time_current()}</Label>
<Input
id={formId + 'time'}
name="time"
type="datetime-local"
step="1"
disabled={t.ntp}
/>
</div>
<Button type="submit" disabled={t.ntp || saving}>{m.save()}</Button>
</form>
</Card.Content>
</Card.Root>
</svelte:boundary>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const host = $derived(systemHostname(machineId));
const formId = $props.id();
let saving = $state(false);
// ponytail: hostname syntax is RFC1123 — letters, digits, hyphen, max 63 chars per label.
const HOSTNAME_RE = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_hostname()}</h1>
<svelte:boundary>
{#snippet failed(err)}
<Card.Root>
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
</Card.Root>
{/snippet}
{@const h = await host}
<Card.Root>
<Card.Header>
<Card.Title>{m.system_hostname_current()}</Card.Title>
<Card.Description>{h.hostname}</Card.Description>
</Card.Header>
<Card.Content>
<form
class="flex flex-col gap-2 sm:flex-row sm:items-end"
onsubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const v = String(fd.get('hostname') ?? '').trim();
if (!HOSTNAME_RE.test(v)) {
toast.error(m.system_hostname_invalid());
return;
}
if (v === h.hostname) return;
saving = true;
try {
await setHostname({hostname:v,machineId,});
toast.success(m.saved());
} catch (err) {
toast.error((err as Error).message || 'Error');
} finally {
saving = false;
}
}}
>
<div class="flex grow flex-col gap-1.5">
<Label for={formId + 'hn'}>{m.nav_system_hostname()}</Label>
<Input
id={formId + 'hn'}
name="hostname"
value={h.hostname}
required
pattern="[A-Za-z0-9.-]+"
maxlength={253}
/>
</div>
<Button type="submit" disabled={saving}>{m.save()}</Button>
</form>
</Card.Content>
</Card.Root>
</svelte:boundary>
</div>
@@ -0,0 +1,296 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import PlusIcon from '@lucide/svelte/icons/plus';
import XIcon from '@lucide/svelte/icons/x';
import { page } from '$app/state';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Command from '$lib/components/ui/command';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import {
generateLocale,
listKeymaps,
listLocales,
setKeymap,
setLocale,
systemLocale
} from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const locale = $derived(systemLocale(machineId));
const locales = $derived(listLocales(machineId));
const keymaps = $derived(listKeymaps(machineId));
let open = $state(false);
let kmOpen = $state(false);
let langOpen = $state(false);
let saving = $state(false);
let newLocale = $state('');
let langList = $state<string[]>([]);
let serverLanguage = $state('');
$effect(() => {
locale.then((data) => {
if (data && data.language !== serverLanguage) {
serverLanguage = data.language || '';
langList = serverLanguage ? serverLanguage.split(':').filter(Boolean) : [];
}
});
});
const languageDirty = $derived(langList.join(':') !== serverLanguage);
// glibc locale: language[_TERRITORY][.codeset][@modifier] — require codeset for clarity.
const LOCALE_RE = /^[a-z]{2,3}_[A-Z]{2}\.[A-Za-z0-9-]+(@[A-Za-z0-9]+)?$/;
const localeValid = $derived(LOCALE_RE.test(newLocale.trim()));
async function pick(fn: () => Promise<unknown>) {
if (saving) return;
saving = true;
try {
await fn();
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
} finally {
saving = false;
}
}
async function handleSetLanguage() {
const l = await locale;
await setLocale({ lang: l.lang,language: langList.join(':'), machineId });
serverLanguage = '';
}
async function handleGenerate() {
const loc = newLocale.trim();
if (!LOCALE_RE.test(loc)) {
toast.error(m.system_locale_generate_invalid());
return;
}
await generateLocale({locale:loc,machineId});
newLocale = '';
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_localization()}</h1>
<svelte:boundary>
{#snippet failed(err)}
<Card.Root>
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
</Card.Root>
{/snippet}
{@const l = await locale}
{@const list = await locales}
{@const kms = await keymaps}
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_lang()}</Card.Title>
<Card.Description>{l.lang}</Card.Description>
</Card.Header>
<Card.Content>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={open}
class="w-full justify-between sm:w-80"
{...props}
>
{l.lang}
<ChevronsUpDownIcon class="size-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0">
<Command.Root>
<Command.Input placeholder={m.system_locale_search_locale_placeholder()} />
<Command.List class="max-h-72">
<Command.Empty>{m.system_locale_no_locale_found()}</Command.Empty>
{#each list as loc (loc)}
<Command.Item
value={loc}
onSelect={() => {
open = false;
if (loc !== l.lang)
pick(() => setLocale({ lang: loc, language: l.language || undefined,machineId }));
}}
>
<CheckIcon
class={'mr-2 size-4 ' + (loc === l.lang ? 'opacity-100' : 'opacity-0')}
/>
{loc}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_language()}</Card.Title>
<Card.Description>{m.system_locale_language_desc()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
{#each langList as loc, i (loc + i)}
<Badge variant="secondary" class="gap-1 pe-1">
{loc}
<button
type="button"
class="hover:bg-muted ms-0.5 rounded-sm p-0.5"
aria-label="Remove"
disabled={saving}
onclick={() => (langList = langList.filter((_, j) => j !== i))}
>
<XIcon class="size-3" />
</button>
</Badge>
{:else}
<span class="text-muted-foreground text-sm">{m.system_locale_language_empty()}</span>
{/each}
<Popover.Root bind:open={langOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" size="sm" disabled={saving} {...props}>
<PlusIcon class="size-4" />
{m.system_locale_language_add()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0">
<Command.Root>
<Command.Input placeholder={m.system_locale_search_locale_placeholder()} />
<Command.List class="max-h-72">
<Command.Empty>{m.system_locale_no_locale_found()}</Command.Empty>
{#each list as loc (loc)}
<Command.Item
value={loc}
onSelect={() => {
langOpen = false;
if (!langList.includes(loc)) langList = [...langList, loc];
}}
>
<CheckIcon
class={'mr-2 size-4 ' +
(langList.includes(loc) ? 'opacity-100' : 'opacity-0')}
/>
{loc}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</div>
<div>
<Button
variant="default"
disabled={saving || !languageDirty}
onclick={() => pick(handleSetLanguage)}
>
{m.system_locale_language_button()}
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_keymap()}</Card.Title>
<Card.Description>{l.vc_keymap || '—'}</Card.Description>
</Card.Header>
<Card.Content>
{#if kms.reason && kms.keymaps.length === 0}
<p class="text-muted-foreground text-sm">{kms.reason}</p>
{:else}
<Popover.Root bind:open={kmOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={kmOpen}
class="w-full justify-between sm:w-80"
{...props}
>
{l.vc_keymap || '—'}
<ChevronsUpDownIcon class="size-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-0">
<Command.Root>
<Command.Input placeholder={m.system_locale_search_keymap_placeholder()} />
<Command.List class="max-h-72">
<Command.Empty>{m.system_locale_no_keymap_found()}</Command.Empty>
{#each kms.keymaps as km (km)}
<Command.Item
value={km}
onSelect={() => {
kmOpen = false;
if (km !== l.vc_keymap) pick(() => setKeymap({keymap:km,machineId}));
}}
>
<CheckIcon
class={'mr-2 size-4 ' + (km === l.vc_keymap ? 'opacity-100' : 'opacity-0')}
/>
{km}
</Command.Item>
{/each}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_x11()}</Card.Title>
<Card.Description>{l.x11_layout || '—'}</Card.Description>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.system_locale_generate()}</Card.Title>
<Card.Description>{m.system_locale_generate_desc()}</Card.Description>
</Card.Header>
<Card.Content>
<div class="flex flex-col sm:flex-row gap-3 max-w-md">
<Input
type="text"
placeholder={m.system_locale_generate_placeholder()}
bind:value={newLocale}
disabled={saving}
/>
<Button
variant="default"
disabled={saving || !localeValid}
onclick={() => pick(handleGenerate)}
>
{m.system_locale_generate_button()}
</Button>
</div>
</Card.Content>
</Card.Root>
</svelte:boundary>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import PowerIcon from '@lucide/svelte/icons/power';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { page } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { buttonVariants } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { powerOff, reboot } from '$lib/remotes/system.remote';
import { toast } from 'svelte-sonner';
let pending = $state<'off' | 'reboot' | null>(null);
let busy = $state(false);
const machineId = $derived(page.params.machineId!)
async function run(action: 'off' | 'reboot') {
busy = true;
try {
await (action === 'off' ? powerOff({machineId,when:''}) : reboot({machineId,when:''}));
toast.success(m.saved());
} catch (e) {
toast.error((e as Error).message || 'Error');
} finally {
busy = false;
pending = null;
}
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_power()}</h1>
<Card.Root>
<Card.Header>
<Card.Title>{m.nav_system_power()}</Card.Title>
<Card.Description>{m.nav_system_power_desc()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-wrap gap-2">
<Button variant="outline" disabled={busy} onclick={() => (pending = 'reboot')}>
<RotateCcwIcon class="size-4" />
{m.system_power_reboot()}
</Button>
<Button variant="destructive" disabled={busy} onclick={() => (pending = 'off')}>
<PowerIcon class="size-4" />
{m.system_power_poweroff()}
</Button>
</Card.Content>
</Card.Root>
</div>
<AlertDialog.Root
open={pending !== null}
onOpenChange={(v) => {
if (!v) pending = null;
}}
>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
{pending === 'off' ? m.system_power_confirm_poweroff_title() : m.system_power_confirm_reboot_title()}
</AlertDialog.Title>
<AlertDialog.Description>{m.system_power_confirm_description()}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={busy}>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action
disabled={busy}
class={buttonVariants({ variant: 'destructive' })}
onclick={() => pending && run(pending)}
>
{pending === 'off' ? m.system_power_poweroff() : m.system_power_reboot()}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,512 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
createPamUser,
deletePamUser,
listPamUsers,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type PamUser = {
comment: string;
gid: number;
home: string;
shell: string;
system: boolean;
uid: number;
username: string;
};
type SortBy = 'shell' | 'uid' | 'username';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const users = $derived(listPamUsers(machineId));
const pageSize = new PersistedState<number>('pam.users.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('pam.users.sort', {
sortBy: 'uid',
sortDir: 'asc'
});
let sortBy = $state<SortBy>(sortStore.current.sortBy);
let sortDir = $state<Dir>(sortStore.current.sortDir);
$effect(() => {
sortStore.current = { sortBy, sortDir };
});
const filtersStore = new PersistedState('pam.users.filters', {
shellOnly: false,
showSystem: false
});
let filters = $state({ ...filtersStore.current });
$effect(() => {
filtersStore.current = filters;
});
const activeFilterCount = $derived(
(filters.showSystem ? 1 : 0) + (filters.shellOnly ? 1 : 0)
);
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
sortBy = col;
sortDir = 'asc';
}
page = 1;
}
const NOLOGIN = /(nologin|false)$/;
const filtered = $derived.by(() => {
const all = (users.current ?? []) as PamUser[];
const q = debouncedSearch.trim().toLowerCase();
const out = all.filter((u) => {
if (!filters.showSystem && u.system) return false;
if (filters.shellOnly && NOLOGIN.test(u.shell)) return false;
if (!q) return true;
return (
u.username.toLowerCase().includes(q) ||
u.comment.toLowerCase().includes(q) ||
String(u.uid).includes(q)
);
});
const dir = sortDir === 'asc' ? 1 : -1;
out.sort((a, b) => {
const av = a[sortBy] as number | string;
const bv = b[sortBy] as number | string;
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
return out;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
);
// Dialogs
let createOpen = $state(false);
let createForm = $state({
comment: '',
create_home: true,
shell: '/bin/bash',
system: false,
username: ''
});
let pwOpen = $state(false);
let pwUser = $state<null | PamUser>(null);
let pwValue = $state('');
let deleteOpen = $state(false);
let deleting = $state<null | PamUser>(null);
let removeHome = $state(false);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function doCreate() {
try {
await createPamUser({
comment: createForm.comment || undefined,
create_home: createForm.create_home,
machineId,
shell: createForm.shell || undefined,
system: createForm.system,
username: createForm.username.trim()
});
toast.success(m.users_created());
createOpen = false;
createForm = { comment: '', create_home: true, shell: '/bin/bash', system: false, username: '' };
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
await deletePamUser({
machineId,
remove_home: removeHome, username: deleting.username });
toast.success(m.users_deleted());
deleteOpen = false;
deleting = null;
removeHome = false;
} catch (e) {
handleError(e);
}
}
async function doSetPassword() {
if (!pwUser || !pwValue) return;
try {
await setPamUserPassword({
machineId,
password: pwValue, username: pwUser.username });
toast.success(m.saved());
pwOpen = false;
pwUser = null;
pwValue = '';
} catch (e) {
handleError(e);
}
}
</script>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.users_nav_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.users_nav_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.users_filter_show_system()}</span>
<small>{m.users_filter_system_uid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
<label class="flex items-center justify-between text-sm">
<span>{m.users_filter_shell_only()}</span>
<Checkbox
checked={filters.shellOnly}
onCheckedChange={(v) => {
filters.shellOnly = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
list="pam-rpp-{id}"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
<datalist id="pam-rpp-{id}">
{#each [10, 25, 50, 100, 200] as n (n)}
<option value={n}></option>
{/each}
</datalist>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.users_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.users_pam_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {users.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'username', label: m.username() }, { key: 'uid', label: 'UID' }, { key: 'shell', label: 'Shell' }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>{m.users_col_comment()}</Table.Head>
<Table.Head>{m.users_col_home()}</Table.Head>
<Table.Head>{m.users_col_type()}</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if users.loading && !users.current}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
>{m.users_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as u (u.username)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve(`/dashboard/${pageState.params.machineId}/users/${u.username}` as Pathname)}
class="hover:underline">{u.username}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{u.uid}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.shell}</Table.Cell>
<Table.Cell>{u.comment || '—'}</Table.Cell>
<Table.Cell class="text-muted-foreground font-mono text-xs">{u.home}</Table.Cell>
<Table.Cell>
{#if u.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => {
pwUser = u;
pwValue = '';
pwOpen = true;
}}>{m.users_action_set_password()}</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = u;
removeHome = false;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onclick={() => (page += 1)}
>
{m.users_next()}
</Button>
</div>
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_create_title()}</Dialog.Title>
<Dialog.Description>{m.users_pam_create_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="cu-username-{id}">{m.username()}</Label>
<Input
id="cu-username-{id}"
bind:value={createForm.username}
required
pattern="[a-z_][a-z0-9_-]*"
maxlength={32}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="cu-comment-{id}">{m.users_create_field_comment()}</Label>
<Input id="cu-comment-{id}" bind:value={createForm.comment} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="cu-shell-{id}">{m.users_create_field_shell()}</Label>
<Input id="cu-shell-{id}" bind:value={createForm.shell} />
</div>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.create_home}
onCheckedChange={(v) => (createForm.create_home = !!v)}
/>
{m.users_create_field_create_home()}
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.system}
onCheckedChange={(v) => (createForm.system = !!v)}
/>
{m.users_create_field_system()}
</label>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.users_create()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={pwOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_set_password_title({ username: pwUser?.username ?? '' })}</Dialog.Title>
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSetPassword();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="pw-{id}">{m.password()}</Label>
<Input id="pw-{id}" type="password" bind:value={pwValue} required minlength={1} />
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit" disabled={!pwValue}>{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.users_delete_title({ username: deleting?.username ?? '' })}</AlertDialog.Title>
<AlertDialog.Description
>{m.users_delete_description()}</AlertDialog.Description
>
</AlertDialog.Header>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={removeHome} />
{m.users_delete_field_remove_home()}
</label>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,265 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import KeyIcon from '@lucide/svelte/icons/key';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import {
deletePamUser,
getPamUser,
listPamGroups,
setPamUserGroups,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { toast } from 'svelte-sonner';
import { SvelteSet } from 'svelte/reactivity';
const id = $props.id();
const username = $derived(pageState.params.username!);
const machineId = $derived(pageState.params.machineId!);
const user = $derived(getPamUser({machineId,username,}));
const groups = $derived(listPamGroups(machineId));
const primary = $derived(
(groups.current ?? []).find((g) => g.gid === (user.current?.gid ?? -1))
);
const supplementary = $derived(
(groups.current ?? []).filter((g) => g.members?.includes(username)).map((g) => g.name)
);
let editing = $state(false);
const selected = new SvelteSet<string>()
$effect(() => {
if (!editing) {selected.clear(); selected.union(new Set(supplementary))}
});
const dirty = $derived.by(() => {
const cur = new Set(supplementary);
if (cur.size !== selected.size) return true;
for (const x of cur) if (!selected.has(x)) return true;
return false;
});
let pwOpen = $state(false);
let pwValue = $state('');
let deleteOpen = $state(false);
let removeHome = $state(false);
let saving = $state(false);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
function toggle(name: string, on: boolean) {
const next = new SvelteSet(selected);
if (on) next.add(name);
else next.delete(name);
selected.clear()
selected.union(next)
}
async function saveGroups() {
saving = true;
try {
await setPamUserGroups({ groups: [...selected], machineId, username });
toast.success(m.users_groups_updated());
editing = false;
} catch (e) {
handleError(e);
} finally {
saving = false;
}
}
async function doSetPassword() {
if (!pwValue) return;
try {
await setPamUserPassword({ machineId,password: pwValue, username });
toast.success(m.saved());
pwOpen = false;
pwValue = '';
} catch (e) {
handleError(e);
}
}
async function doDelete() {
try {
await deletePamUser({ machineId,remove_home: removeHome, username });
toast.success(m.users_deleted());
await goto(resolve(`/dashboard/${machineId}/users` as Pathname));
} catch (e) {
handleError(e);
}
}
</script>
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<svelte:boundary>
{#snippet failed(err)}
<Card.Root>
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
</Card.Root>
{/snippet}
{@const u = await user}
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
{u.username}
{#if u.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
>{m.users_type_user()}</Badge
>{/if}
</h1>
<p class="text-muted-foreground text-sm">{u.comment || m.users_no_gecos()}</p>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" onclick={() => (pwOpen = true)}>
<KeyIcon class="size-4" />
{m.users_action_set_password()}
</Button>
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
<Trash2Icon class="size-4" />
{m.users_delete()}
</Button>
</div>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.users_details()}</Card.Title>
</Card.Header>
<Card.Content>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-muted-foreground">UID</dt>
<dd class="font-mono">{u.uid}</dd>
<dt class="text-muted-foreground">{m.users_primary_gid()}</dt>
<dd class="font-mono">
{u.gid}{#if primary}
<span class="text-muted-foreground"> ({primary.name})</span>
{/if}
</dd>
<dt class="text-muted-foreground">{m.users_col_home()}</dt>
<dd class="font-mono">{u.home}</dd>
<dt class="text-muted-foreground">{m.users_create_field_shell()}</dt>
<dd class="font-mono">{u.shell}</dd>
</dl>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<div>
<Card.Title>{m.users_groups_title()}</Card.Title>
<Card.Description>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html m.users_pam_groups_description()}
</Card.Description>
</div>
{#if !editing}
<Button variant="outline" onclick={() => (editing = true)}>{m.edit()}</Button>
{:else}
<div class="flex items-center gap-2">
<Button
variant="outline"
onclick={() => {
editing = false;
selected.clear()
selected.union(new Set(supplementary))
}}>{m.cancel()}</Button
>
<Button onclick={saveGroups} disabled={saving || !dirty}>{m.save()}</Button>
</div>
{/if}
</Card.Header>
<Card.Content>
{#if !editing}
<div class="flex flex-wrap gap-1.5">
{#if primary}
<Badge variant="default" title="Primary group">{primary.name} {m.users_group_primary_badge()}</Badge>
{/if}
{#each supplementary as name (name)}
<Badge variant="outline">{name}</Badge>
{:else}
{#if !primary}
<span class="text-muted-foreground text-sm">{m.users_no_groups()}</span>
{/if}
{/each}
</div>
{:else}
<div class="max-h-80 overflow-auto rounded-md border">
<div class="grid grid-cols-2 gap-1 p-2 sm:grid-cols-3">
{#each (groups.current ?? []).filter((g) => g.gid !== u.gid) as g (g.name)}
<label class="flex items-center gap-2 rounded p-1.5 text-sm hover:bg-muted">
<Checkbox
checked={selected.has(g.name)}
onCheckedChange={(v) => toggle(g.name, !!v)}
/>
<span class="truncate" title={g.name}>{g.name}</span>
{#if g.system}
<span class="text-muted-foreground text-xs">{m.users_group_sys_badge()}</span>
{/if}
</label>
{/each}
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
</svelte:boundary>
</div>
<Dialog.Root bind:open={pwOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.users_set_password_title({ username })}</Dialog.Title>
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSetPassword();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="pw-{id}">{m.password()}</Label>
<Input id="pw-{id}" type="password" bind:value={pwValue} required minlength={1} />
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit" disabled={!pwValue}>{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.users_delete_title({ username })}</AlertDialog.Title>
<AlertDialog.Description>{m.users_delete_description()}</AlertDialog.Description>
</AlertDialog.Header>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={removeHome} />
{m.users_delete_field_remove_home()}
</label>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,400 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import PlusIcon from '@lucide/svelte/icons/plus';
import { resolve } from '$app/paths';
import {page as pageState} from '$app/state'
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { createPamGroup, deletePamGroup, listPamGroups } from '$lib/remotes/pam-users.remote';
import { PersistedState } from 'runed';
import { toast } from 'svelte-sonner';
type PamGroup = {
gid: number;
members: null | string[];
name: string;
system: boolean;
};
type SortBy = 'gid' | 'members' | 'name';
type Dir = 'asc' | 'desc';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!)
const groups = $derived(listPamGroups(machineId));
const pageSize = new PersistedState<number>('pam.groups.pageSize', 25);
let page = $state(1);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('pam.groups.sort', {
sortBy: 'gid',
sortDir: 'asc'
});
let sortBy = $state<SortBy>(sortStore.current.sortBy);
let sortDir = $state<Dir>(sortStore.current.sortDir);
$effect(() => {
sortStore.current = { sortBy, sortDir };
});
const filtersStore = new PersistedState('pam.groups.filters', { showSystem: false });
let filters = $state({ ...filtersStore.current });
$effect(() => {
filtersStore.current = filters;
});
const activeFilterCount = $derived(filters.showSystem ? 1 : 0);
function onSearchInput(e: Event) {
search = (e.target as HTMLInputElement).value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
page = 1;
}, 200);
}
function toggleSort(col: SortBy) {
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
else {
sortBy = col;
sortDir = 'asc';
}
page = 1;
}
const filtered = $derived.by(() => {
const all = (groups.current ?? []) as PamGroup[];
const q = debouncedSearch.trim().toLowerCase();
const out = all.filter((g) => {
if (!filters.showSystem && g.system) return false;
if (!q) return true;
return g.name.toLowerCase().includes(q) || String(g.gid).includes(q);
});
const dir = sortDir === 'asc' ? 1 : -1;
out.sort((a, b) => {
const av = sortBy === 'members' ? (a.members?.length ?? 0) : (a[sortBy] as number | string);
const bv = sortBy === 'members' ? (b.members?.length ?? 0) : (b[sortBy] as number | string);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
return out;
});
const totalPages = $derived(Math.max(1, Math.ceil(filtered.length / pageSize.current)));
const pageRows = $derived(
filtered.slice((page - 1) * pageSize.current, page * pageSize.current)
);
let createOpen = $state(false);
let createForm = $state({ gid: '', name: '', system: false });
let deleteOpen = $state(false);
let deleting = $state<null | PamGroup>(null);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function doCreate() {
try {
await createPamGroup({
gid: createForm.gid ? Number(createForm.gid) : undefined,
machineId,
name: createForm.name.trim(),
system: createForm.system
});
toast.success(m.groups_created());
createOpen = false;
createForm = { gid: '', name: '', system: false };
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
await deletePamGroup({group:deleting.name, machineId});
toast.success(m.groups_deleted());
deleteOpen = false;
deleting = null;
} catch (e) {
handleError(e);
}
}
</script>
<div class="mx-auto flex w-full flex-col gap-0 grow">
<div
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
>
<div class="flex flex-col gap-0.5 min-w-0">
<h1 class="text-2xl font-semibold tracking-tight truncate">{m.groups_nav_title()}</h1>
<p class="text-muted-foreground text-sm truncate">
{m.groups_nav_description()}
</p>
</div>
<div class="flex items-center gap-2">
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" class="relative">
<ListFilterIcon class="size-4" />
{m.users_filter()}
{#if activeFilterCount > 0}
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
{/if}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-72 p-0" align="end">
<div class="border-b p-2">
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<div class="flex flex-col gap-3 p-2">
<label class="flex items-center justify-between text-sm">
<div class="flex flex-col">
<span>{m.groups_filter_show_system()}</span>
<small>{m.groups_filter_system_gid_hint()}</small>
</div>
<Checkbox
checked={filters.showSystem}
onCheckedChange={(v) => {
filters.showSystem = !!v;
page = 1;
}}
/>
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
type="number"
min="1"
max="500"
class="h-9 w-24"
value={pageSize.current}
onchange={(e) => {
const n = Number((e.target as HTMLInputElement).value);
if (n >= 1) {
pageSize.current = n;
page = 1;
}
}}
/>
</div>
</div>
</Popover.Content>
</Popover.Root>
<Button onclick={() => (createOpen = true)}>
<PlusIcon class="size-4" />
{m.groups_add()}
</Button>
</div>
</div>
<div class="flex items-center gap-2 px-4">
<Input
placeholder={m.groups_search_placeholder()}
value={search}
oninput={onSearchInput}
class="max-w-sm"
/>
<span class="text-muted-foreground text-xs">
{filtered.length} / {groups.current?.length ?? 0}
</span>
</div>
<div class="p-4">
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each [{ key: 'name', label: m.name() }, { key: 'gid', label: 'GID' }, { key: 'members', label: 'Members' }] as col (col.key)}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={() => toggleSort(col.key as SortBy)}
>
{col.label}
{#if sortBy === col.key}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 opacity-50" />
{/if}
</button>
</Table.Head>
{/each}
<Table.Head>Type</Table.Head>
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if groups.loading && !groups.current}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"></Table.Cell>
</Table.Row>
{:else if !pageRows.length}
<Table.Row>
<Table.Cell colspan={5} class="text-muted-foreground py-8 text-center"
>{m.groups_no_results()}</Table.Cell
>
</Table.Row>
{:else}
{#each pageRows as g (g.name)}
<Table.Row>
<Table.Cell class="font-medium">
<a
href={resolve(`/dashboard/${pageState.params.machineId}/users/groups/${g.name}` as Pathname)}
class="hover:underline">{g.name}</a
>
</Table.Cell>
<Table.Cell class="text-muted-foreground">{g.gid}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs">
{#if g.members?.length}
<span title={g.members.join(', ')}>
{g.members.slice(0, 3).join(', ')}{g.members.length > 3
? ` +${g.members.length - 3}`
: ''}
</span>
{:else}
{/if}
</Table.Cell>
<Table.Cell>
{#if g.system}
<Badge variant="secondary">{m.users_type_system()}</Badge>
{:else}
<Badge variant="outline">{m.users_type_user()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="size-8">
<MoreHorizontalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
variant="destructive"
onclick={() => {
deleting = g;
deleteOpen = true;
}}>{m.users_delete()}</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
<div class="flex items-center justify-end gap-4 p-4 mt-auto">
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm"
>{m.users_page_of({ page, total: totalPages })}</span
>
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
{m.users_prev()}
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onclick={() => (page += 1)}
>
{m.users_next()}
</Button>
</div>
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.groups_create_title()}</Dialog.Title>
<Dialog.Description>{m.groups_create_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="cg-name-{id}">{m.name()}</Label>
<Input
id="cg-name-{id}"
bind:value={createForm.name}
required
pattern="[a-z_][a-z0-9_-]*"
maxlength={32}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="cg-gid-{id}">GID (optional)</Label>
<Input id="cg-gid-{id}" type="number" min="0" bind:value={createForm.gid} />
</div>
<label class="flex items-center gap-2 text-sm">
<Checkbox
checked={createForm.system}
onCheckedChange={(v) => (createForm.system = !!v)}
/>
{m.groups_create_field_system()}
</label>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.users_create()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.groups_delete_title({ name: deleting?.name ?? '' })}</AlertDialog.Title>
<AlertDialog.Description
>{m.groups_delete_description()}</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,244 @@
<script lang="ts">
import type { Pathname } from '$app/types';
import PlusIcon from '@lucide/svelte/icons/plus';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import XIcon from '@lucide/svelte/icons/x';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import {
deletePamGroup,
listPamGroups,
listPamUsers,
setPamUserGroups
} from '$lib/remotes/pam-users.remote';
import { toast } from 'svelte-sonner';
const id = $props.id();
const machineId = $derived(pageState.params.machineId!);
const groupName = $derived(pageState.params.group!);
const groups = $derived(listPamGroups(machineId));
const users = $derived(listPamUsers(machineId));
const group = $derived((groups.current ?? []).find((g) => g.name === groupName));
const members = $derived(group?.members ?? []);
const primaryMembers = $derived(
(users.current ?? []).filter((u) => u.gid === group?.gid).map((u) => u.username)
);
const candidates = $derived(
(users.current ?? [])
.map((u) => u.username)
.filter((n) => !members.includes(n) && !primaryMembers.includes(n))
);
function supplementaryOf(username: string): string[] {
return (groups.current ?? [])
.filter((g) => g.members?.includes(username))
.map((g) => g.name);
}
let addOpen = $state(false);
let addQuery = $state('');
let busy = $state<null | string>(null);
let deleteOpen = $state(false);
function handleError(e: unknown) {
console.error(e);
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
}
async function addMember(username: string) {
busy = username;
try {
const next = [...new Set([...supplementaryOf(username), groupName])];
await setPamUserGroups({ groups: next,machineId, username });
toast.success(m.groups_member_added({ username }));
addOpen = false;
addQuery = '';
} catch (e) {
handleError(e);
} finally {
busy = null;
}
}
async function removeMember(username: string) {
busy = username;
try {
const next = supplementaryOf(username).filter((g) => g !== groupName);
await setPamUserGroups({ groups: next,machineId, username });
toast.success(m.groups_member_removed({ username }));
} catch (e) {
handleError(e);
} finally {
busy = null;
}
}
async function doDelete() {
try {
await deletePamGroup({group:groupName,machineId,});
toast.success(m.groups_deleted());
await goto(resolve(`/dashboard/${machineId}/users/groups` as Pathname));
} catch (e) {
handleError(e);
}
}
const filteredCandidates = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
return q ? candidates.filter((n) => n.toLowerCase().includes(q)) : candidates;
});
</script>
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
{#if groups.loading && !groups.current}
<Card.Root>
<Card.Content class="text-muted-foreground py-6"></Card.Content>
</Card.Root>
{:else if !group}
<Card.Root>
<Card.Content class="text-destructive py-6">{m.groups_not_found({ name: groupName })}</Card.Content>
</Card.Root>
{:else}
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
{group.name}
{#if group.system}<Badge variant="secondary">{m.users_type_system()}</Badge>{:else}<Badge variant="outline"
>{m.users_type_user()}</Badge
>{/if}
</h1>
<p class="text-muted-foreground text-sm font-mono">gid {group.gid}</p>
</div>
<Button variant="destructive" onclick={() => (deleteOpen = true)}>
<Trash2Icon class="size-4" />
{m.users_delete()}
</Button>
</div>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<div>
<Card.Title>{m.groups_members_title()}</Card.Title>
<Card.Description>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html m.groups_members_description({ gid: group.gid })}
</Card.Description>
</div>
<Button variant="outline" onclick={() => (addOpen = true)}>
<PlusIcon class="size-4" />
{m.groups_add_member()}
</Button>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div>
<Label class="mb-1.5 block text-xs">{m.groups_supplementary_label({ count: members.length })}</Label>
{#if members.length}
<div class="flex flex-wrap gap-1.5">
{#each members as name (name)}
<Badge variant="outline" class="gap-1 pe-1">
<a
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
class="hover:underline">{name}</a
>
<button
type="button"
class="hover:bg-muted rounded-sm p-0.5 disabled:opacity-50"
disabled={busy === name}
onclick={() => removeMember(name)}
aria-label="Remove {name}"
>
<XIcon class="size-3" />
</button>
</Badge>
{/each}
</div>
{:else}
<span class="text-muted-foreground text-sm">{m.groups_no_supplementary_members()}</span>
{/if}
</div>
<div>
<Label class="mb-1.5 block text-xs">{m.groups_primary_label({ count: primaryMembers.length })}</Label>
{#if primaryMembers.length}
<div class="flex flex-wrap gap-1.5">
{#each primaryMembers as name (name)}
<Badge variant="default">
<a
href={resolve(`/dashboard/${machineId}/users/${name}` as Pathname)}
class="hover:underline">{name}</a
>
</Badge>
{/each}
</div>
{:else}
<span class="text-muted-foreground text-sm">{m.groups_primary_empty()}</span>
{/if}
</div>
</Card.Content>
</Card.Root>
{/if}
</div>
<Dialog.Root bind:open={addOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{m.groups_add_member_title({ name: groupName })}</Dialog.Title>
<Dialog.Description>{m.groups_add_member_description()}</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-2">
<Input
id="add-q-{id}"
placeholder={m.groups_add_member_search_placeholder()}
bind:value={addQuery}
autocomplete="off"
/>
<div class="max-h-72 overflow-auto rounded-md border">
{#if !filteredCandidates.length}
<div class="text-muted-foreground p-3 text-sm">{m.groups_add_member_no_results()}</div>
{:else}
{#each filteredCandidates as name (name)}
<button
type="button"
class="hover:bg-muted flex w-full items-center justify-between px-3 py-2 text-sm disabled:opacity-50"
disabled={busy === name}
onclick={() => addMember(name)}
>
<span>{name}</span>
<span class="text-muted-foreground text-xs">{m.groups_add_member_action()}</span>
</button>
{/each}
{/if}
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (addOpen = false)}>{m.cancel()}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.groups_delete_title({ name: groupName })}</AlertDialog.Title>
<AlertDialog.Description
>{m.groups_delete_description()}</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
<AlertDialog.Action onclick={doDelete}>{m.users_delete()}</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>