feat: refined ui

This commit is contained in:
2026-06-26 00:31:29 +02:00
parent 8414c26bd4
commit c86726ec9a
48 changed files with 1720 additions and 872 deletions
+1 -1
View File
@@ -47,7 +47,7 @@
</div>
</header>
{/if}
<div class="w-full flex flex-col h-full">
<div class="w-full flex flex-col h-full text-balance">
{@render children()}
</div>
</Sidebar.Inset>
+14 -10
View File
@@ -79,15 +79,19 @@
<span class="font-bold text-lg tracking-tight">{m.appname()}</span>
</a>
<div class="ms-auto flex items-center gap-3">
<a href={resolve('/auth/sign-in')} class={buttonVariants({ size: 'sm', variant: 'ghost' })}>
<a href={resolve('/dashboard')} class={buttonVariants({ size: 'sm', variant: 'ghost' })}>
{m.login()}
</a>
<a href={resolve('/auth/sign-up')} class={buttonVariants({ size: 'sm', variant: 'default' })}>
<a href='#get-start' class={buttonVariants({ size: 'sm', variant: 'default' })}>
{m.landing_hero_cta_start()}
</a>
</div>
</header>
<style>
:global(html,body){
scroll-behavior: smooth;
}
</style>
<main class="min-h-screen pt-16 text-foreground antialiased selection:bg-primary/20">
<!-- Hero Section -->
<section class="border-b border-border bg-linear-to-b from-background to-muted/20">
@@ -118,7 +122,7 @@
rel="noreferrer"
class={buttonVariants({ size: 'lg', variant: 'outline' })}
>
{m.landing_hero_cta_github()}
{m.landing_footer_gitea()}
</a>
</div>
</div>
@@ -141,7 +145,7 @@
>
<!-- Card.Root 4 (Uptime) -->
<div
class="absolute w-[280px] scale-90 translate-x-[-75px] -translate-y-3 opacity-40 origin-center pointer-events-none z-10"
class="absolute w-70 scale-90 -translate-x-18.75 -translate-y-3 opacity-40 origin-center pointer-events-none z-10"
>
<KpiCard
label={m.dashboard_uptime()}
@@ -152,7 +156,7 @@
</div>
<!-- Card.Root 3 (Load Average) -->
<div
class="absolute w-[280px] scale-90 translate-x-[-25px] -translate-y-1 opacity-95 origin-center pointer-events-none z-20"
class="absolute w-70 scale-90 -translate-x-6.25 -translate-y-1 opacity-95 origin-center pointer-events-none z-20"
>
<KpiCard
label={m.dashboard_load_average()}
@@ -163,7 +167,7 @@
</div>
<!-- Card.Root 2 (Memory) -->
<div
class="absolute w-[280px] scale-90 translate-x-[25px] translate-y-1 opacity-97 origin-center pointer-events-none z-30"
class="absolute w-70 scale-90 translate-x-6.25 translate-y-1 opacity-97 origin-center pointer-events-none z-30"
>
<KpiCard
label={m.dashboard_memory()}
@@ -174,7 +178,7 @@
</div>
<!-- Card.Root 1 (CPU) -->
<div
class="absolute w-[280px] scale-90 translate-x-[75px] translate-y-3 opacity-100 origin-center shadow-2xl z-40"
class="absolute w-70 scale-90 translate-x-18.75 translate-y-3 opacity-100 origin-center shadow-2xl z-40"
>
<KpiCard
label={m.dashboard_cpu()}
@@ -354,8 +358,8 @@
>
<ShieldAlertIcon class="size-4 shrink-0 text-amber-500 mt-0.5" />
<div>
<strong>Security note:</strong>
{m.landing_security_note().replace('Security note:', '').trim()}
<strong>{m.landing_security_note()}</strong>
{m.landing_security_note_text()}
</div>
</div>
</div>
+303
View File
@@ -1,6 +1,309 @@
<script lang="ts">
import MailIcon from '@lucide/svelte/icons/mail';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch';
import { m } from '$lib/paraglide/messages';
import { getAppConfig, saveAppConfig, sendTestEmail } from '$lib/remotes/config.remote';
import { configFormSchema } from '$lib/schemas/config';
import { extractErrorMessage } from '$lib/utils';
import { toast } from 'svelte-sonner';
const cfg = $derived(getAppConfig());
const id = $props.id();
let testing = $state(false);
let testTo = $state('');
const c = $derived(cfg.current);
async function test() {
if (!testTo) return;
testing = true;
try {
await sendTestEmail({ to: testTo });
toast.success(m.admin_config_smtp_test_sent());
} catch (err) {
toast.error(extractErrorMessage(err) ?? m.errors_generic());
} finally {
testing = false;
}
}
const socials = [
{ idKey: 'FACEBOOK_CLIENT_ID', label: 'Facebook', secretKey: 'FACEBOOK_CLIENT_SECRET' },
{ idKey: 'GITHUB_CLIENT_ID', label: 'GitHub', secretKey: 'GITHUB_CLIENT_SECRET' },
{ idKey: 'GOOGLE_CLIENT_ID', label: 'Google', secretKey: 'GOOGLE_CLIENT_SECRET' }
] as const;
</script>
<PageMeta title={m.seo_title_admin_config()} description={m.seo_desc_admin_config()} />
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5">
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_admin_config()}</h1>
<p class="text-muted-foreground text-sm">{m.admin_config_description()}</p>
</div>
<Button
variant="outline"
size="icon"
title={m.dashboard_refresh()}
onclick={() => cfg.refresh()}
>
<RefreshCwIcon class="size-4" />
</Button>
</div>
<form
oninput={() => saveAppConfig.validate()}
{...saveAppConfig.preflight(configFormSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.saved());
} catch (err) {
toast.error(extractErrorMessage(err) ?? m.errors_generic());
}
})}
>
<Field.Group>
<Card.Root>
<Card.Header>
<Card.Title>{m.admin_config_auth_title()}</Card.Title>
<Card.Description>{m.admin_config_auth_description()} · {m.admin_config_restart_required()}</Card.Description>
</Card.Header>
<Card.Content>
<Field.Group>
<Field.Label for="{id}-disable-signup">
<Field.Field orientation="horizontal">
<Field.Content>
<span>
{m.admin_config_disable_signup()}
</span>
<Field.Description>
{m.admin_config_disable_signup_hint()}
</Field.Description>
</Field.Content>
<Switch
id="{id}-disable-signup"
name="DISABLE_SIGNUP"
value="yes"
checked={c?.boot.DISABLE_SIGNUP ?? false}
/>
</Field.Field>
</Field.Label>
<Field.Label for="{id}-enable-emailpw">
<Field.Field orientation="horizontal">
<Field.Content>
<span>
{m.admin_config_enable_email_password()}
</span>
<Field.Description>
{m.admin_config_enable_email_password_hint()}
</Field.Description>
</Field.Content>
<Switch
id="{id}-enable-emailpw"
name="ENABLE_EMAIL_AND_PASSWORD"
value="yes"
checked={c?.boot.ENABLE_EMAIL_AND_PASSWORD ?? true}
/>
</Field.Field>
</Field.Label>
<Field.Label for="{id}-enable-2fa">
<Field.Field orientation="horizontal">
<Field.Content>
<span>
{m.admin_config_enable_2fa()}
</span>
<Field.Description>{m.admin_config_enable_2fa_hint()}</Field.Description>
</Field.Content>
<Switch
id="{id}-enable-2fa"
name="ENABLE_2FA"
value="yes"
checked={c?.ENABLE_2FA ?? false}
/>
</Field.Field>
</Field.Label>
<Field.Field>
<Field.Label for="{id}-origin">
{m.admin_config_origin()}
</Field.Label>
<Input
id="{id}-origin"
placeholder="https://nadir.example.com"
{...saveAppConfig.fields.ORIGIN.as('url', c?.boot.ORIGIN ?? '')}
/>
<Field.Description>
{m.admin_config_origin_hint()}
</Field.Description>
{#each saveAppConfig.fields.ORIGIN.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</Field.Group>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.admin_config_smtp_title()}</Card.Title>
<Card.Description>{m.admin_config_smtp_description()}</Card.Description>
</Card.Header>
<Card.Content>
<Field.Group>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field.Field class="sm:col-span-2">
<Field.Label for="{id}-smtp-host">{m.admin_config_smtp_host()}</Field.Label>
<Input
id="{id}-smtp-host"
placeholder="smtp.example.com"
{...saveAppConfig.fields.SMTP_HOST.as('text', c?.SMTP_HOST ?? '')}
/>
</Field.Field>
<Field.Field>
<Field.Label for="{id}-smtp-port">{m.admin_config_smtp_port()}</Field.Label>
<Input
id="{id}-smtp-port"
type="number"
min={1}
max={65535}
placeholder="587"
{...saveAppConfig.fields.SMTP_PORT.as('text', String(c?.SMTP_PORT ?? ''))}
/>
{#each saveAppConfig.fields.SMTP_PORT.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field.Field>
<Field.Label for="{id}-smtp-user">{m.admin_config_smtp_user()}</Field.Label>
<Input
id="{id}-smtp-user"
autocomplete="off"
{...saveAppConfig.fields.SMTP_USER.as('text', c?.SMTP_USER ?? '')}
/>
</Field.Field>
<Field.Field>
<Field.Label for="{id}-smtp-pass">{m.admin_config_smtp_pass()}</Field.Label>
<Input
id="{id}-smtp-pass"
autocomplete="new-password"
placeholder={c?.SMTP_PASS_SET ? '••••••••' : ''}
{...saveAppConfig.fields.SMTP_PASS.as('password', '')}
/>
<Field.Description>{m.admin_config_smtp_pass_hint()}</Field.Description>
</Field.Field>
</div>
<Field.Field>
<Field.Label for="{id}-smtp-from">{m.admin_config_smtp_from()}</Field.Label>
<Input
id="{id}-smtp-from"
placeholder="Nadir <noreply@example.com>"
{...saveAppConfig.fields.SMTP_FROM.as('text', c?.SMTP_FROM ?? '')}
/>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Content>
<Field.Label for="{id}-smtp-ssl">{m.admin_config_smtp_ssl()}</Field.Label>
<Field.Description>{m.admin_config_smtp_ssl_hint()}</Field.Description>
</Field.Content>
<Switch
id="{id}-smtp-ssl"
name="SMTP_SSL"
value="yes"
checked={c?.SMTP_SSL ?? false}
/>
</Field.Field>
</Field.Group>
</Card.Content>
<Card.Footer class="border-t pt-4">
<Field.Field orientation="responsive" class="w-full">
<Field.Content>
<Field.Label for="{id}-smtp-test-to">
{m.admin_config_smtp_test_to()}
</Field.Label>
<Input
id="{id}-smtp-test-to"
type="email"
placeholder="you@example.com"
bind:value={testTo}
/>
</Field.Content>
<Button
type="button"
variant="outline"
class="mt-auto"
disabled={testing || !testTo || !c?.SMTP_HOST}
onclick={test}
>
<MailIcon class="size-4" />
{m.admin_config_smtp_test()}
</Button>
</Field.Field>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.admin_config_social_title()}</Card.Title>
<Card.Description>
{m.admin_config_social_description()} · {m.admin_config_restart_required()}
</Card.Description>
</Card.Header>
<Card.Content>
<div class="flex flex-col gap-4">
<div
class="text-muted-foreground hidden text-xs font-medium sm:grid sm:grid-cols-[7rem_1fr_1fr] sm:gap-4 sm:px-1"
>
<span></span>
<span>{m.admin_config_social_client_id()}</span>
<span>{m.admin_config_social_client_secret()}</span>
</div>
{#each socials as p (p.label)}
<div
class="grid grid-cols-1 gap-3 sm:grid-cols-[7rem_1fr_1fr] sm:items-center sm:gap-4"
>
<span class="text-sm font-medium">{p.label}</span>
<Field.Field class="gap-1.5">
<Field.Label class="sm:hidden" for="{id}-{p.idKey}">
{p.label} — {m.admin_config_social_client_id()}
</Field.Label>
<Input
id="{id}-{p.idKey}"
autocomplete="off"
aria-label={`${p.label} ${m.admin_config_social_client_id()}`}
{...saveAppConfig.fields[p.idKey].as('text', c?.[p.idKey] ?? '')}
/>
</Field.Field>
<Field.Field class="gap-1.5">
<Field.Label class="sm:hidden" for="{id}-{p.secretKey}">
{p.label} — {m.admin_config_social_client_secret()}
</Field.Label>
<Input
id="{id}-{p.secretKey}"
autocomplete="new-password"
aria-label={`${p.label} ${m.admin_config_social_client_secret()}`}
placeholder={c?.[`${p.secretKey}_SET`] ? '••••••••' : ''}
{...saveAppConfig.fields[p.secretKey].as('password', '')}
/>
</Field.Field>
</div>
{/each}
<p class="text-muted-foreground text-xs">{m.admin_config_social_secret_hint()}</p>
</div>
</Card.Content>
</Card.Root>
<div class="flex justify-end">
<Button type="submit" disabled={!!saveAppConfig.pending || !c}>{m.save()}</Button>
</div>
</Field.Group>
</form>
</div>
+27 -12
View File
@@ -18,7 +18,6 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { NativeSelect } from '$lib/components/ui/native-select';
import * as Popover from '$lib/components/ui/popover';
import * as Table from '$lib/components/ui/table';
@@ -227,29 +226,43 @@
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
</div>
<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
<Field.Field>
<Field.Label for="flt-active-{id}" class="flex items-center gap-1 text-xs">
{m.users_filter_active()} <InfoIcon class="size-3 opacity-60" />
</Field.Label>
<NativeSelect
id="flt-active-{id}"
bind:value={filters.activeWithin}
onchange={bump}
class="h-9"
>
<NativeSelect bind:value={filters.activeWithin} onchange={bump} class="h-9">
<option value="all">{m.users_filter_any_time()}</option>
<option value="24h">{m.users_filter_24h()}</option>
<option value="7d">{m.users_filter_7d()}</option>
<option value="30d">{m.users_filter_30d()}</option>
</NativeSelect>
</div>
<div class="flex flex-col gap-1.5">
<Label class="text-xs">{m.users_filter_joined()}</Label>
<NativeSelect bind:value={filters.joinedWithin} onchange={bump} class="h-9">
</Field.Field>
<Field.Field>
<Field.Label for="flt-joined-{id}" class="text-xs">
{m.users_filter_joined()}
</Field.Label>
<NativeSelect
id="flt-joined-{id}"
bind:value={filters.joinedWithin}
onchange={bump}
class="h-9"
>
<option value="all">{m.users_filter_any_time()}</option>
<option value="24h">{m.users_filter_24h()}</option>
<option value="7d">{m.users_filter_7d()}</option>
<option value="30d">{m.users_filter_30d()}</option>
</NativeSelect>
</div>
</Field.Field>
</div>
<div class="border-t p-2">
<Label class="mb-2 block text-xs">{m.users_filter_date_range()}</Label>
<p class="text-muted-foreground mb-2 block text-xs font-medium">
{m.users_filter_date_range()}
</p>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
<CalendarIcon
@@ -312,7 +325,9 @@
</label>
</div>
<div class="flex flex-col gap-2 border-t p-2">
<Label class="text-xs">{m.users_filter_display()}</Label>
<span class="text-muted-foreground text-xs font-medium">
{m.users_filter_display()}
</span>
<div class="flex items-center justify-between">
<span class="text-sm">{m.users_rows_per_page()}</span>
<Input
@@ -12,12 +12,13 @@
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { upsertHostSchema } from '$lib/schemas/networking-host';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
@@ -31,8 +32,9 @@
let search = $state('');
let editOpen = $state(false);
let editForm = $state({ hostnames: '', ip: '' });
let editingExisting = $state(false);
let editingIp = $state('');
let editingHostnames = $state('');
let deleteOpen = $state(false);
let deleting = $state<Host | null>(null);
@@ -69,29 +71,18 @@
function openAdd() {
editingExisting = false;
editForm = { hostnames: '', ip: '' };
editingIp = '';
editingHostnames = '';
editOpen = true;
}
function openEdit(h: Host) {
editingExisting = true;
editForm = { hostnames: (h.hostnames ?? []).join(' '), ip: h.ip };
editingIp = h.ip;
editingHostnames = (h.hostnames ?? []).join(' ');
editOpen = true;
}
async function doSave() {
const ip = editForm.ip.trim();
const hostnames = editForm.hostnames.split(/\s+/).filter(Boolean);
if (!ip || !hostnames.length) return;
try {
await upsertHost({ hostnames, ip, machineId });
toast.success(m.networking_host_saved());
editOpen = false;
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
@@ -203,36 +194,50 @@
<Dialog.Description>{m.networking_host_add_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSave();
}}
class="flex flex-col gap-3"
oninput={() => upsertHost.validate()}
{...upsertHost.preflight(upsertHostSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.networking_host_saved());
editOpen = false;
} catch (e) {
handleError(e);
}
})}
>
<div class="flex flex-col gap-1.5">
<Label for="h-ip-{id}">{m.networking_col_ip()}</Label>
<Input
id="h-ip-{id}"
bind:value={editForm.ip}
placeholder="192.168.1.10"
required
readonly={editingExisting}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="h-names-{id}">{m.networking_col_hostnames()}</Label>
<Input
id="h-names-{id}"
bind:value={editForm.hostnames}
placeholder="server server.local"
required
/>
</div>
<Dialog.Footer class="mt-2">
<input {...upsertHost.fields.machineId.as('hidden', machineId)} />
<Field.Group>
<Field.Field>
<Field.Label for="h-ip-{id}">{m.networking_col_ip()}</Field.Label>
<Input
id="h-ip-{id}"
placeholder="192.168.1.10"
required
readonly={editingExisting}
{...upsertHost.fields.ip.as('text', editingIp)}
/>
{#each upsertHost.fields.ip.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="h-names-{id}">{m.networking_col_hostnames()}</Field.Label>
<Input
id="h-names-{id}"
placeholder="server server.local"
required
{...upsertHost.fields.hostnames.as('text', editingHostnames)}
/>
{#each upsertHost.fields.hostnames.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (editOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit">{m.save()}</Button>
<Button type="submit" disabled={!!upsertHost.pending}>{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
@@ -1,5 +1,4 @@
<script lang="ts">
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import PlusIcon from '@lucide/svelte/icons/plus';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import { goto } from '$app/navigation';
@@ -8,11 +7,14 @@
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
// ponytail: nested arrays (dns, routes) + conditional sections fight FormData serialization;
// stays on command() with the in-memory valid derivation. Field primitives applied for a11y.
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
@@ -114,14 +116,6 @@
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
<div class="flex items-start gap-3">
<Button
variant="ghost"
size="icon"
href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })}
title={m.error_action_back()}
>
<ArrowLeftIcon class="size-4" />
</Button>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="font-mono text-2xl font-semibold tracking-tight">{name}</h1>
<p class="text-muted-foreground text-sm">{m.networking_configure_description()}</p>
@@ -161,17 +155,17 @@
{#if v4Method === 'static'}
<Separator />
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem]">
<div class="flex flex-col gap-1.5">
<Label for="v4-addr-{id}">{m.machine_address()}</Label>
<Field.Field>
<Field.Label for="v4-addr-{id}">{m.machine_address()}</Field.Label>
<Input
id="v4-addr-{id}"
bind:value={v4.address}
placeholder="192.168.1.10"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v4-prefix-{id}">{m.prefix()}</Label>
</Field.Field>
<Field.Field>
<Field.Label for="v4-prefix-{id}">{m.prefix()}</Field.Label>
<Input
id="v4-prefix-{id}"
type="number"
@@ -180,12 +174,14 @@
bind:value={v4.prefix}
required
/>
</div>
</Field.Field>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v4-gw-{id}">{m.networking_col_gateway()} {m.optional()}</Label>
<Field.Field>
<Field.Label for="v4-gw-{id}">
{m.networking_col_gateway()} {m.optional()}
</Field.Label>
<Input id="v4-gw-{id}" bind:value={v4.gateway} placeholder="192.168.1.1" />
</div>
</Field.Field>
{/if}
</Card.Content>
</Card.Root>
@@ -221,17 +217,17 @@
{#if v6Method === 'static'}
<Separator />
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem]">
<div class="flex flex-col gap-1.5">
<Label for="v6-addr-{id}">{m.machine_address()}</Label>
<Field.Field>
<Field.Label for="v6-addr-{id}">{m.machine_address()}</Field.Label>
<Input
id="v6-addr-{id}"
bind:value={v6.address}
placeholder="2001:db8::10"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v6-prefix-{id}">{m.prefix()}</Label>
</Field.Field>
<Field.Field>
<Field.Label for="v6-prefix-{id}">{m.prefix()}</Field.Label>
<Input
id="v6-prefix-{id}"
type="number"
@@ -240,12 +236,14 @@
bind:value={v6.prefix}
required
/>
</div>
</Field.Field>
</div>
<div class="flex flex-col gap-1.5">
<Label for="v6-gw-{id}">{m.networking_col_gateway()} {m.optional()}</Label>
<Field.Field>
<Field.Label for="v6-gw-{id}">
{m.networking_col_gateway()} {m.optional()}
</Field.Label>
<Input id="v6-gw-{id}" bind:value={v6.gateway} placeholder="2001:db8::1" />
</div>
</Field.Field>
{/if}
</Card.Content>
</Card.Root>
@@ -323,9 +321,9 @@
<!-- Rollback + submit -->
<Card.Root>
<Card.Content class="flex flex-col gap-3 py-4 sm:flex-row sm:items-end sm:justify-between">
<div class="flex flex-col gap-1.5">
<Label for="rollback-{id}">{m.networking_rollback_seconds()}</Label>
<Card.Content class="flex flex-col gap-3 py-4 sm:items-end sm:justify-between">
<Field.Field>
<Field.Label for="rollback-{id}">{m.networking_rollback_seconds()}</Field.Label>
<Input
id="rollback-{id}"
type="number"
@@ -334,11 +332,11 @@
class="w-32"
bind:value={rollbackSeconds}
/>
<p class="text-muted-foreground text-xs">{m.networking_rollback_seconds_hint()}</p>
</div>
<Button type="submit" disabled={!valid || submitting || !canWrite}
>{m.networking_apply()}</Button
>
<Field.Description>{m.networking_rollback_seconds_hint()}</Field.Description>
</Field.Field>
<Button type="submit" disabled={!valid || submitting || !canWrite}>
{m.networking_apply()}
</Button>
</Card.Content>
</Card.Root>
</form>
@@ -16,7 +16,7 @@
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 Field from '$lib/components/ui/field';
import * as Table from '$lib/components/ui/table';
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
@@ -266,16 +266,17 @@
e.preventDefault();
doInstall();
}}
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1.5">
<Label for="pkg-name-{id}">{m.packages_col_name()}</Label>
<Input id="pkg-name-{id}" bind:value={installName} placeholder="e.g. htop" required />
</div>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Field.Group>
<Field.Field>
<Field.Label for="pkg-name-{id}">{m.packages_col_name()}</Field.Label>
<Input id="pkg-name-{id}" bind:value={installName} placeholder="e.g. htop" required />
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit">{m.packages_install_button()}</Button>
</Dialog.Footer>
</form>
@@ -8,6 +8,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Field from '$lib/components/ui/field';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
@@ -161,10 +162,10 @@
{/if}
</div>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<Field.Set class="gap-2">
<Field.Legend variant="label" class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{m.services_active_filter()}
</Label>
</Field.Legend>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.active} />
@@ -183,11 +184,11 @@
{m.services_filter_other()}
</Label>
</div>
</div>
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
</Field.Set>
<Field.Set class="gap-2 border-t pt-3">
<Field.Legend variant="label" class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{m.services_load_filter()}
</Label>
</Field.Legend>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.loaded} />
@@ -206,11 +207,11 @@
{m.services_filter_error()}
</Label>
</div>
</div>
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
</Field.Set>
<Field.Set class="gap-2 border-t pt-3">
<Field.Legend variant="label" class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{m.services_sub_filter()}
</Label>
</Field.Legend>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.running} />
@@ -229,7 +230,7 @@
{m.services_filter_other()}
</Label>
</div>
</div>
</Field.Set>
</div>
{/snippet}
{#snippet columns()}
@@ -22,6 +22,7 @@
import * as Card from '$lib/components/ui/card';
import * as Empty from '$lib/components/ui/empty';
import { Input } from '$lib/components/ui/input';
import * as Field from '$lib/components/ui/field';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import { Switch } from '$lib/components/ui/switch';
@@ -419,9 +420,11 @@
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-80 p-4" align="end">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<Label for="logs-lines-{id}" class="text-xs">{m.services_logs_lines()}</Label>
<Field.Group>
<Field.Field>
<Field.Label for="logs-lines-{id}" class="text-xs">
{m.services_logs_lines()}
</Field.Label>
<Input
id="logs-lines-{id}"
type="number"
@@ -434,11 +437,11 @@
if (n >= 1 && n <= 10000) lines.current = n;
}}
/>
</div>
<div class="flex flex-col gap-1">
<Label for="logs-priority-{id}" class="text-xs"
>{m.services_logs_priority()}</Label
>
</Field.Field>
<Field.Field>
<Field.Label for="logs-priority-{id}" class="text-xs">
{m.services_logs_priority()}
</Field.Label>
<select
id="logs-priority-{id}"
class="border-input bg-background h-9 w-full rounded-md border px-2 text-sm"
@@ -455,9 +458,11 @@
<option value={6}>{m.syslog_info()}</option>
<option value={7}>{m.syslog_debug()}</option>
</select>
</div>
<div class="flex flex-col gap-1">
<Label for="logs-since-{id}" class="text-xs">{m.services_logs_since()}</Label>
</Field.Field>
<Field.Field>
<Field.Label for="logs-since-{id}" class="text-xs">
{m.services_logs_since()}
</Field.Label>
<select
id="logs-since-{id}"
class="border-input bg-background h-9 w-full rounded-md border px-2 text-sm"
@@ -471,19 +476,19 @@
<option value="today">{m.services_logs_since_today()}</option>
<option value="yesterday">{m.services_logs_since_yesterday()}</option>
</select>
</div>
<div class="flex flex-col gap-1">
<Label for="logs-search-{id}" class="text-xs"
>{m.services_logs_search_placeholder()}</Label
>
</Field.Field>
<Field.Field>
<Field.Label for="logs-search-{id}" class="text-xs">
{m.services_logs_search_placeholder()}
</Field.Label>
<Input
id="logs-search-{id}"
placeholder={m.services_logs_search_placeholder()}
value={search}
oninput={onLogSearch}
/>
</div>
</div>
</Field.Field>
</Field.Group>
</Popover.Content>
</Popover.Root>
<Label class="flex items-center gap-2 text-sm font-normal">
@@ -10,11 +10,12 @@
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 * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { addMount, listFstab, listMounts, removeMount } from '$lib/remotes/storage.remote';
import { addMountSchema } from '$lib/schemas/storage-mount';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { PersistedState } from 'runed';
@@ -78,7 +79,6 @@
});
let createOpen = $state(false);
let createForm = $state({ device: '', fstype: '', mountpoint: '', options: 'defaults' });
let deleteOpen = $state(false);
let deleting = $state<Mount | null>(null);
@@ -87,23 +87,6 @@
toast.error(extractErrorMessage(e) ?? m.errors_generic());
}
async function doCreate() {
try {
await addMount({
device: createForm.device.trim(),
fstype: createForm.fstype.trim(),
machineId,
mountpoint: createForm.mountpoint.trim(),
options: createForm.options.trim() || undefined
});
toast.success(m.storage_mount_added());
createOpen = false;
createForm = { device: '', fstype: '', mountpoint: '', options: 'defaults' };
} catch (e) {
handleError(e);
}
}
async function doDelete() {
if (!deleting) return;
try {
@@ -214,43 +197,69 @@
<Dialog.Description>{m.storage_mount_add_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
oninput={() => addMount.validate()}
{...addMount.preflight(addMountSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.storage_mount_added());
createOpen = false;
} catch (e) {
handleError(e);
}
})}
>
<div class="flex flex-col gap-1.5">
<Label for="sm-device-{id}">{m.storage_col_device()}</Label>
<Input
id="sm-device-{id}"
bind:value={createForm.device}
placeholder="/dev/sdb1"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
<Input
id="sm-mountpoint-{id}"
bind:value={createForm.mountpoint}
placeholder="/mnt/data"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-fstype-{id}">{m.storage_col_fstype()}</Label>
<Input id="sm-fstype-{id}" bind:value={createForm.fstype} placeholder="ext4" required />
</div>
<div class="flex flex-col gap-1.5">
<Label for="sm-options-{id}">{m.storage_col_options()}</Label>
<Input id="sm-options-{id}" bind:value={createForm.options} placeholder="defaults" />
</div>
<Dialog.Footer class="mt-2">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
>{m.cancel()}</Button
>
<Button type="submit">{m.storage_mount_add()}</Button>
<input {...addMount.fields.machineId.as('hidden', machineId)} />
<Field.Group>
<Field.Field>
<Field.Label for="sm-device-{id}">{m.storage_col_device()}</Field.Label>
<Input
id="sm-device-{id}"
placeholder="/dev/sdb1"
required
{...addMount.fields.device.as('text', '')}
/>
{#each addMount.fields.device.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Field.Label>
<Input
id="sm-mountpoint-{id}"
placeholder="/mnt/data"
required
{...addMount.fields.mountpoint.as('text', '')}
/>
{#each addMount.fields.mountpoint.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="sm-fstype-{id}">{m.storage_col_fstype()}</Field.Label>
<Input
id="sm-fstype-{id}"
placeholder="ext4"
required
{...addMount.fields.fstype.as('text', '')}
/>
{#each addMount.fields.fstype.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="sm-options-{id}">{m.storage_col_options()}</Field.Label>
<Input
id="sm-options-{id}"
placeholder="defaults"
{...addMount.fields.options.as('text', 'defaults')}
/>
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit" disabled={!!addMount.pending}>{m.storage_mount_add()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
@@ -6,46 +6,42 @@
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Command from '$lib/components/ui/command';
import * as Field from '$lib/components/ui/field';
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 {
getWhoami,
listTimezones,
setNtp,
setTime,
setTimezone,
systemTime
} from '$lib/remotes/system.remote';
import { getWhoami } from '$lib/remotes/system.remote';
import { setTimeSchema } from '$lib/schemas/system-time';
import { extractErrorMessage, hasPermission } from '$lib/utils';
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();
const id = $props.id();
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
let tzOpen = $state(false);
let saving = $state(false);
let busy = $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;
async function withBusy<T>(fn: () => Promise<T>) {
busy = true;
try {
await fn();
toast.success(m.saved());
} catch (e) {
toast.error(extractErrorMessage(e) ?? m.errors_generic());
} finally {
saving = false;
busy = false;
}
}
</script>
@@ -67,73 +63,83 @@
<Card.Header>
<Card.Title>{m.system_time_current()}</Card.Title>
</Card.Header>
<Card.Content class="flex flex-wrap items-center gap-x-8 gap-y-2 text-sm">
<div class="flex items-baseline gap-2">
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
>{m.system_time_timezone()}</span
>
<span class="font-mono font-medium">{t.timezone}</span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
>{m.system_time_clock()}</span
>
<span class="font-mono tabular-nums">{t.time}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider">NTP</span
>
<Switch
checked={t.ntp}
disabled={saving || !t.can_ntp || !canWrite}
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
/>
</div>
<div class="text-muted-foreground text-xs">
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
</div>
</Card.Content>
<Card.Content class="border-t pt-4">
<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"
disabled={!canWrite}
{...props}
<Card.Content>
<Field.Group>
<div class="flex flex-wrap items-center gap-x-8 gap-y-2 text-sm">
<div class="flex items-baseline gap-2">
<span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
{m.system_time_timezone()}
</span>
<span class="font-mono font-medium">{t.timezone}</span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
{m.system_time_clock()}</span
>
{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 }));
}}
<span class="font-mono tabular-nums">{t.time}</span>
</div>
</div>
<Field.Field orientation="horizontal">
<Field.Content>
<Field.Label for="{id}-ntp">NTP</Field.Label>
<Field.Description>
{t.ntp_synchronized
? m.system_time_ntp_synced()
: m.system_time_ntp_not_synced()}
</Field.Description>
</Field.Content>
<Switch
id="{id}-ntp"
checked={t.ntp}
disabled={busy || !t.can_ntp || !canWrite}
onCheckedChange={(v) => withBusy(() => setNtp({ enabled: v, machineId }))}
/>
</Field.Field>
<Field.Field>
<Field.Label for="{id}-tz">{m.system_time_timezone()}</Field.Label>
<Popover.Root bind:open={tzOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button
id="{id}-tz"
variant="outline"
role="combobox"
aria-expanded={tzOpen}
class="w-full justify-between sm:w-80"
disabled={!canWrite}
{...props}
>
<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>
{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 withBusy(() => 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>
</Field.Field>
</Field.Group>
</Card.Content>
</Card.Root>
@@ -144,26 +150,35 @@
</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) }));
}}
oninput={() => setTime.validate()}
{...setTime.preflight(setTimeSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.saved());
} catch (err) {
toast.error(extractErrorMessage(err) ?? m.errors_generic());
}
})}
>
<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 || !canWrite}>{m.save()}</Button>
<input {...setTime.fields.machineId.as('hidden', machineId)} />
<Field.Group class="sm:flex-row sm:items-end">
<Field.Field class="grow">
<Field.Label for="{id}-time">{m.system_time_current()}</Field.Label>
<Input
id="{id}-time"
type="datetime-local"
step="1"
disabled={t.ntp}
{...setTime.fields.time.as('text', '')}
/>
{#each setTime.fields.time.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={t.ntp || !!setTime.pending || !canWrite}>
{m.save()}
</Button>
</Field.Group>
</form>
</Card.Content>
</Card.Root>
@@ -3,25 +3,23 @@
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Field from '$lib/components/ui/field';
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 { getWhoami } from '$lib/remotes/system.remote';
import {
getWhoami,
setHostname,
systemHostname
} from '$lib/remotes/system.remote';
import { hostnameSchema } from '$lib/schemas/hostname';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
const machineId = $derived(page.params.machineId!);
const host = $derived(systemHostname(machineId));
const formId = $props.id();
let saving = $state(false);
const id = $props.id();
const whoami = $derived(getWhoami(machineId));
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
// 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>
<PageMeta title={m.seo_title_system_hostname()} description={m.seo_desc_system_hostname()} />
@@ -43,40 +41,34 @@
</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;
oninput={() => setHostname.validate()}
{...setHostname.preflight(hostnameSchema).enhance(async ({ submit }) => {
try {
await setHostname({ hostname: v, machineId });
await submit();
toast.success(m.saved());
} catch (err) {
console.log(err);
toast.error(extractErrorMessage(err) ?? m.errors_generic());
} 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 || !canWrite}>{m.save()}</Button>
<input {...setHostname.fields.machineId.as('hidden', machineId)} />
<Field.Group class="sm:flex-row sm:items-end">
<Field.Field class="grow">
<Field.Label for="{id}-hn">{m.nav_system_hostname()}</Field.Label>
<Input
id="{id}-hn"
required
maxlength={253}
{...setHostname.fields.hostname.as('text', h.hostname)}
/>
{#each setHostname.fields.hostname.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button type="submit" disabled={!!setHostname.pending || !canWrite}>
{m.save()}
</Button>
</Field.Group>
</form>
</Card.Content>
</Card.Root>
@@ -14,8 +14,8 @@
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 * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {
@@ -24,6 +24,7 @@
listPamUsers,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { createPamUserSchema, setPamUserPasswordSchema } from '$lib/schemas/pam';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { PersistedState } from 'runed';
@@ -117,16 +118,8 @@
});
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);
@@ -136,30 +129,6 @@
toast.error(extractErrorMessage(e) ?? 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 {
@@ -177,22 +146,6 @@
}
}
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>
<PageMeta title={m.seo_title_users()} description={m.seo_desc_users()} />
@@ -312,7 +265,6 @@
disabled={!canRoot}
onclick={() => {
pwUser = u;
pwValue = '';
pwOpen = true;
}}>{m.users_action_set_password()}</DropdownMenu.Item
>
@@ -339,46 +291,58 @@
<Dialog.Description>{m.users_pam_create_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
oninput={() => createPamUser.validate()}
{...createPamUser.preflight(createPamUserSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.users_created());
createOpen = false;
} catch (e) {
handleError(e);
}
})}
>
<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>
<input {...createPamUser.fields.machineId.as('hidden', machineId)} />
<Field.Group>
<Field.Field>
<Field.Label for="cu-username-{id}">{m.username()}</Field.Label>
<Input
id="cu-username-{id}"
required
pattern="[a-z_][a-z0-9_\-]*"
maxlength={32}
{...createPamUser.fields.username.as('text', '')}
/>
{#each createPamUser.fields.username.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="cu-comment-{id}">{m.users_create_field_comment()}</Field.Label>
<Input id="cu-comment-{id}" {...createPamUser.fields.comment.as('text', '')} />
</Field.Field>
<Field.Field>
<Field.Label for="cu-shell-{id}">{m.users_create_field_shell()}</Field.Label>
<Input id="cu-shell-{id}" {...createPamUser.fields.shell.as('text', '/bin/bash')} />
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="cu-create-home-{id}" name="create_home" value="yes" checked />
<Field.Label for="cu-create-home-{id}" class="font-normal">
{m.users_create_field_create_home()}
</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="cu-system-{id}" name="system" value="yes" />
<Field.Label for="cu-system-{id}" class="font-normal">
{m.users_create_field_system()}
</Field.Label>
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit" disabled={!!createPamUser.pending}>{m.users_create()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
@@ -392,21 +356,39 @@
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSetPassword();
}}
class="flex flex-col gap-3"
oninput={() => setPamUserPassword.validate()}
{...setPamUserPassword.preflight(setPamUserPasswordSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.saved());
pwOpen = false;
pwUser = null;
} catch (e) {
handleError(e);
}
})}
>
<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>
<input {...setPamUserPassword.fields.machineId.as('hidden', machineId)} />
<input {...setPamUserPassword.fields.username.as('hidden', pwUser?.username ?? '')} />
<Field.Group>
<Field.Field>
<Field.Label for="pw-{id}">{m.password()}</Field.Label>
<Input
id="pw-{id}"
required
minlength={1}
{...setPamUserPassword.fields.password.as('password', '')}
/>
{#each setPamUserPassword.fields.password.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit" disabled={!!setPamUserPassword.pending}>{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
@@ -11,8 +11,8 @@
import * as Card from '$lib/components/ui/card';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import {
deletePamUser,
@@ -21,6 +21,7 @@
setPamUserGroups,
setPamUserPassword
} from '$lib/remotes/pam-users.remote';
import { setPamUserPasswordSchema } from '$lib/schemas/pam';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { toast } from 'svelte-sonner';
@@ -55,7 +56,6 @@
});
let pwOpen = $state(false);
let pwValue = $state('');
let deleteOpen = $state(false);
let removeHome = $state(false);
let saving = $state(false);
@@ -86,18 +86,6 @@
}
}
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 });
@@ -236,21 +224,38 @@
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doSetPassword();
}}
class="flex flex-col gap-3"
oninput={() => setPamUserPassword.validate()}
{...setPamUserPassword.preflight(setPamUserPasswordSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.saved());
pwOpen = false;
} catch (e) {
handleError(e);
}
})}
>
<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>
<input {...setPamUserPassword.fields.machineId.as('hidden', machineId)} />
<input {...setPamUserPassword.fields.username.as('hidden', username)} />
<Field.Group>
<Field.Field>
<Field.Label for="pw-{id}">{m.password()}</Field.Label>
<Input
id="pw-{id}"
required
minlength={1}
{...setPamUserPassword.fields.password.as('password', '')}
/>
{#each setPamUserPassword.fields.password.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit" disabled={!!setPamUserPassword.pending}>{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
@@ -14,11 +14,12 @@
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 * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
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 { createPamGroupSchema } from '$lib/schemas/pam';
import { getWhoami } from '$lib/remotes/system.remote';
import { extractErrorMessage, hasPermission } from '$lib/utils';
import { PersistedState } from 'runed';
@@ -100,7 +101,6 @@
});
let createOpen = $state(false);
let createForm = $state({ gid: '', name: '', system: false });
let deleteOpen = $state(false);
let deleting = $state<null | PamGroup>(null);
@@ -109,22 +109,6 @@
toast.error(extractErrorMessage(e) ?? 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 {
@@ -268,35 +252,53 @@
<Dialog.Description>{m.groups_create_description()}</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
doCreate();
}}
class="flex flex-col gap-3"
oninput={() => createPamGroup.validate()}
{...createPamGroup.preflight(createPamGroupSchema).enhance(async ({ submit }) => {
try {
await submit();
toast.success(m.groups_created());
createOpen = false;
} catch (e) {
handleError(e);
}
})}
>
<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}">{m.groups_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>
<input {...createPamGroup.fields.machineId.as('hidden', machineId)} />
<Field.Group>
<Field.Field>
<Field.Label for="cg-name-{id}">{m.name()}</Field.Label>
<Input
id="cg-name-{id}"
required
pattern="[a-z_][a-z0-9_\-]*"
maxlength={32}
{...createPamGroup.fields.name.as('text', '')}
/>
{#each createPamGroup.fields.name.issues() as issue, i (`${issue}-${i}`)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Field.Field>
<Field.Label for="cg-gid-{id}">{m.groups_gid_optional()}</Field.Label>
<Input
id="cg-gid-{id}"
type="number"
min="0"
{...createPamGroup.fields.gid.as('text', '')}
/>
</Field.Field>
<Field.Field orientation="horizontal">
<Checkbox id="cg-system-{id}" name="system" value="yes" />
<Field.Label for="cg-system-{id}" class="font-normal">
{m.groups_create_field_system()}
</Field.Label>
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>
{m.cancel()}
</Button>
<Button type="submit" disabled={!!createPamGroup.pending}>{m.users_create()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
@@ -12,7 +12,6 @@
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,
@@ -151,9 +150,9 @@
</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
>
<p class="text-muted-foreground mb-1.5 block text-xs font-medium">
{m.groups_supplementary_label({ count: members.length })}
</p>
{#if members.length}
<div class="flex flex-wrap gap-1.5">
{#each members as name (name)}
@@ -183,9 +182,9 @@
</div>
<div>
<Label class="mb-1.5 block text-xs"
>{m.groups_primary_label({ count: primaryMembers.length })}</Label
>
<p class="text-muted-foreground mb-1.5 block text-xs font-medium">
{m.groups_primary_label({ count: primaryMembers.length })}
</p>
{#if primaryMembers.length}
<div class="flex flex-wrap gap-1.5">
{#each primaryMembers as name (name)}