feat: refined ui
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+33
-35
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user