feat: implement drag-and-drop machine reordering with swapy and add new system administration management routes
This commit is contained in:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
@@ -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,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>
|
||||
Reference in New Issue
Block a user