initial commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import type { Pathname } from '$app/types';
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import AppControls from '$lib/components/blocks/app-controls.svelte';
|
||||
import Breadcrumbs from '$lib/components/blocks/breadcrumbs/breadcrumbs.svelte';
|
||||
import AppSidebar from '$lib/components/blocks/sidebar/app-sidebar.svelte';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
import { Toaster } from '$lib/components/ui/sonner';
|
||||
import { locales, localizeHref } from '$lib/paraglide/runtime';
|
||||
import { getUser } from '$lib/remotes/auth.remote';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
|
||||
let { children } = $props();
|
||||
const user = $derived(getUser());
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
<Sidebar.Provider style="--sidebar-width: 350px;">
|
||||
{@const currentUser = await user}
|
||||
{#if currentUser}
|
||||
<AppSidebar user={currentUser} />
|
||||
{/if}
|
||||
<Sidebar.Inset>
|
||||
{#if await user}
|
||||
<header
|
||||
class="bg-background sticky top-0 flex shrink-0 items-center gap-2 border-b p-4 h-15 z-50"
|
||||
>
|
||||
<Sidebar.Trigger class="-ms-1" />
|
||||
<Separator orientation="vertical" class="me-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumbs />
|
||||
<div class="ms-auto">
|
||||
<AppControls />
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<div class="w-full flex flex-col h-full">
|
||||
{@render children()}
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<div style="display:none">
|
||||
{#each locales as locale (locale)}
|
||||
<a href={resolve(localizeHref(page.url.pathname, { locale }) as Pathname)}>{locale}</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<ModeWatcher />
|
||||
<Toaster />
|
||||
@@ -0,0 +1,2 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
// Forces hooks.server.ts to run on every /admin navigation (client-side router
|
||||
// skips the server otherwise when no server load is defined).
|
||||
export const load = () => {};
|
||||
@@ -0,0 +1,721 @@
|
||||
<script lang="ts">
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
|
||||
import CalendarIcon from '@lucide/svelte/icons/calendar';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import InfoIcon from '@lucide/svelte/icons/info';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { createUserSchema, inviteUserSchema, updateUserSchema } from '$lib/auth/schemas';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * 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';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
banUser,
|
||||
createUser,
|
||||
deleteUser,
|
||||
inviteUser,
|
||||
listUsers,
|
||||
resendInvite,
|
||||
unbanUser,
|
||||
updateUser
|
||||
} from '$lib/remotes/users.remote';
|
||||
import { PersistedState } from 'runed';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
type SortBy = 'createdAt' | 'email' | 'name' | 'username';
|
||||
type Dir = 'asc' | 'desc';
|
||||
type Within = '24h' | '30d' | '7d' | 'all';
|
||||
type UserRow = {
|
||||
banExpires?: Date | null | string;
|
||||
banned?: boolean | null;
|
||||
createdAt: Date | string;
|
||||
email: string;
|
||||
emailVerified?: boolean | null;
|
||||
id: string;
|
||||
inviteExpiresAt?: Date | null | string;
|
||||
name: string;
|
||||
role?: null | string;
|
||||
username?: null | string;
|
||||
};
|
||||
|
||||
const id = $props.id();
|
||||
const pageSize = new PersistedState<number>('admin.users.pageSize', 10);
|
||||
let page = $state(1);
|
||||
let search = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let debouncedSearch = $state('');
|
||||
|
||||
// Persisted sort
|
||||
const sortStore = new PersistedState<{ sortBy: SortBy; sortDir: Dir }>('admin.users.sort', {
|
||||
sortBy: 'createdAt',
|
||||
sortDir: 'desc'
|
||||
});
|
||||
let sortBy = $state<SortBy>(sortStore.current.sortBy);
|
||||
let sortDir = $state<Dir>(sortStore.current.sortDir);
|
||||
$effect(() => {
|
||||
sortStore.current = { sortBy, sortDir };
|
||||
});
|
||||
|
||||
// Persisted filters
|
||||
const defaults = {
|
||||
activeWithin: 'all' as Within,
|
||||
emailVerifiedOnly: false,
|
||||
joinedFrom: '',
|
||||
joinedTo: '',
|
||||
joinedWithin: 'all' as Within,
|
||||
onlineOnly: false,
|
||||
showBanned: true
|
||||
};
|
||||
const filtersStore = new PersistedState<typeof defaults>('admin.users.filters', defaults);
|
||||
let filters = $state({ ...defaults, ...filtersStore.current });
|
||||
$effect(() => {
|
||||
filtersStore.current = filters;
|
||||
});
|
||||
const activeFilterCount = $derived(
|
||||
(filters.activeWithin !== 'all' ? 1 : 0) +
|
||||
(filters.joinedWithin !== 'all' ? 1 : 0) +
|
||||
(filters.joinedFrom || filters.joinedTo ? 1 : 0) +
|
||||
(filters.emailVerifiedOnly ? 1 : 0) +
|
||||
(filters.onlineOnly ? 1 : 0) +
|
||||
(!filters.showBanned ? 1 : 0)
|
||||
);
|
||||
function resetFilters() {
|
||||
filters = { ...defaults };
|
||||
page = 1;
|
||||
}
|
||||
function bump() {
|
||||
page = 1;
|
||||
}
|
||||
|
||||
const offset = $derived((page - 1) * pageSize.current);
|
||||
const usersQuery = $derived(
|
||||
listUsers({
|
||||
...filters,
|
||||
limit: pageSize.current,
|
||||
offset,
|
||||
searchValue: debouncedSearch,
|
||||
sortBy,
|
||||
sortDirection: sortDir
|
||||
})
|
||||
);
|
||||
const data = $derived(
|
||||
(usersQuery.current ?? { total: 0, users: [] }) as { total: number; users: UserRow[] }
|
||||
);
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(data.total / pageSize.current)));
|
||||
|
||||
function onSearchInput(e: Event) {
|
||||
search = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
debouncedSearch = search;
|
||||
page = 1;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function toggleSort(col: SortBy) {
|
||||
if (sortBy === col) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
sortBy = col;
|
||||
sortDir = 'asc';
|
||||
}
|
||||
page = 1;
|
||||
}
|
||||
|
||||
// Dialog state
|
||||
let createOpen = $state(false);
|
||||
let inviteOpen = $state(false);
|
||||
let editOpen = $state(false);
|
||||
let editing = $state<null | UserRow>(null);
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state<null | UserRow>(null);
|
||||
let alsoBan = $state(false);
|
||||
let banReason = $state('');
|
||||
|
||||
function handleError(e: unknown) {
|
||||
console.error(e);
|
||||
toast.error((e as { body?: { message?: string } })?.body?.message || m.errors_generic());
|
||||
}
|
||||
|
||||
function openEdit(u: UserRow) {
|
||||
editing = u;
|
||||
editOpen = true;
|
||||
}
|
||||
function openDelete(u: UserRow) {
|
||||
deleting = u;
|
||||
alsoBan = false;
|
||||
banReason = '';
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
if (alsoBan && !deleting.banned) {
|
||||
await banUser({ banReason, id: deleting.id });
|
||||
}
|
||||
await deleteUser(deleting.id);
|
||||
toast.success(m.users_deleted());
|
||||
deleteOpen = false;
|
||||
deleting = null;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doResendInvite(u: UserRow) {
|
||||
try {
|
||||
await resendInvite(u.id);
|
||||
toast.success(m.users_invited());
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBan(u: UserRow) {
|
||||
try {
|
||||
if (u.banned) await unbanUser(u.id);
|
||||
else await banUser({ banReason: '', id: u.id });
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">{m.users_title()}</h1>
|
||||
<p class="text-muted-foreground text-sm">{m.users_description()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" class="relative">
|
||||
<ListFilterIcon class="size-4" />
|
||||
{m.users_filter()}
|
||||
{#if activeFilterCount > 0}
|
||||
<Badge variant="default" class="ms-1 h-5 px-1.5">{activeFilterCount}</Badge>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0" align="end">
|
||||
<div class="border-b p-4">
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 p-4">
|
||||
<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
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
<div class="border-t p-4">
|
||||
<Label class="mb-2 block text-xs">{m.users_filter_date_range()}</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
<CalendarIcon
|
||||
class="text-muted-foreground pointer-events-none absolute inset-s-2 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
class="ps-8"
|
||||
bind:value={filters.joinedFrom}
|
||||
onchange={bump}
|
||||
placeholder={m.users_filter_date_from()}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<CalendarIcon
|
||||
class="text-muted-foreground pointer-events-none absolute inset-s-2 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
class="ps-8"
|
||||
bind:value={filters.joinedTo}
|
||||
onchange={bump}
|
||||
placeholder={m.users_filter_date_to()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-t p-4">
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<span>{m.users_filter_email_verified()}</span>
|
||||
<Checkbox
|
||||
checked={filters.emailVerifiedOnly}
|
||||
onCheckedChange={(v) => {
|
||||
filters.emailVerifiedOnly = !!v;
|
||||
bump();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<span class="flex items-center gap-1"
|
||||
>{m.users_filter_online_only()} <InfoIcon class="size-3 opacity-60" /></span
|
||||
>
|
||||
<Checkbox
|
||||
checked={filters.onlineOnly}
|
||||
onCheckedChange={(v) => {
|
||||
filters.onlineOnly = !!v;
|
||||
bump();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between text-sm">
|
||||
<span>{m.users_filter_show_banned()}</span>
|
||||
<Checkbox
|
||||
checked={filters.showBanned}
|
||||
onCheckedChange={(v) => {
|
||||
filters.showBanned = !!v;
|
||||
bump();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t p-4">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
list="rpp-presets-{id}"
|
||||
class="h-9 w-24"
|
||||
value={pageSize.current}
|
||||
onchange={(e) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (n >= 1) {
|
||||
pageSize.current = n;
|
||||
page = 1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="rpp-presets-{id}">
|
||||
{#each [10, 20, 50, 100] as n (n)}
|
||||
<option value={n}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t p-3">
|
||||
<Button variant="ghost" size="sm" onclick={resetFilters}
|
||||
>{m.users_filter_reset()}</Button
|
||||
>
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>{m.users_filter_count({ n: activeFilterCount })}</span
|
||||
>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<div class="flex items-stretch">
|
||||
<Button class="rounded-e-none" onclick={() => (createOpen = true)}>
|
||||
<PlusIcon class="size-4" />
|
||||
{m.users_add()}
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} class="rounded-s-none border-s px-2" aria-label="more">
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => (createOpen = true)}
|
||||
>{m.users_create()}</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item onclick={() => (inviteOpen = true)}
|
||||
>{m.users_invite()}</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder={m.users_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onSearchInput}
|
||||
class="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each [{ key: 'name', label: m.name() }, { key: 'username', label: m.username() }, { key: 'email', label: m.email() }, { key: 'createdAt', label: m.users_created_at() }] as col (col.key)}
|
||||
<Table.Head>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-foreground inline-flex items-center gap-1"
|
||||
onclick={() => toggleSort(col.key as SortBy)}
|
||||
>
|
||||
{col.label}
|
||||
{#if sortBy === col.key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 opacity-50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/each}
|
||||
<Table.Head>{m.users_role()}</Table.Head>
|
||||
<Table.Head>{m.users_status()}</Table.Head>
|
||||
<Table.Head class="w-12 text-right">{m.users_actions()}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if usersQuery.loading && !data.users.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">…</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if !data.users.length}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center"
|
||||
>{m.users_no_results()}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each data.users as u (u.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{u.name}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{u.username ?? '—'}</Table.Cell>
|
||||
<Table.Cell>{u.email}</Table.Cell>
|
||||
<Table.Cell>{new Date(u.createdAt).toLocaleDateString()}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{u.role === 'admin' ? m.users_role_admin() : m.users_role_user()}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if u.banned}
|
||||
<Badge variant="destructive">{m.users_banned()}</Badge>
|
||||
{:else if !u.emailVerified}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
title={u.inviteExpiresAt
|
||||
? m.users_pending_expires({
|
||||
date: new Date(u.inviteExpiresAt).toLocaleString()
|
||||
})
|
||||
: m.users_pending_no_invite()}
|
||||
>
|
||||
{m.users_pending()}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline">{m.users_active()}</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="size-8">
|
||||
<MoreHorizontalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(u)}
|
||||
>{m.users_edit()}</DropdownMenu.Item
|
||||
>
|
||||
{#if !u.emailVerified}
|
||||
<DropdownMenu.Item onclick={() => doResendInvite(u)}
|
||||
>{m.users_resend_invite()}</DropdownMenu.Item
|
||||
>
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={() => toggleBan(u)}>
|
||||
{u.banned ? m.users_unban() : m.users_ban()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(u)}>
|
||||
{m.users_delete()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm"
|
||||
>{m.users_page_of({ page, total: totalPages })}</span
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onclick={() => (page -= 1)}>
|
||||
{m.users_prev()}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onclick={() => (page += 1)}>
|
||||
{m.users_next()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create dialog -->
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.users_create_title()}</Dialog.Title>
|
||||
<Dialog.Description>{m.users_create_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
oninput={() => createUser.validate()}
|
||||
{...createUser.preflight(createUserSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.users_created());
|
||||
createOpen = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="cu-name-{id}">{m.name()}</Field.Label>
|
||||
<Input id="cu-name-{id}" {...createUser.fields.name.as('text')} required />
|
||||
{#each createUser.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="cu-username-{id}">{m.username()}</Field.Label>
|
||||
<Input id="cu-username-{id}" {...createUser.fields.username.as('text')} required />
|
||||
{#each createUser.fields.username.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="cu-email-{id}">{m.email()}</Field.Label>
|
||||
<Input id="cu-email-{id}" {...createUser.fields.email.as('email')} required />
|
||||
{#each createUser.fields.email.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="cu-password-{id}">{m.password()}</Field.Label>
|
||||
<Input id="cu-password-{id}" {...createUser.fields._password.as('password')} required />
|
||||
{#each createUser.fields._password.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="cu-role-{id}">{m.users_role()}</Field.Label>
|
||||
<NativeSelect id="cu-role-{id}" {...createUser.fields.role.as('text')}>
|
||||
<option value="user">{m.users_role_user()}</option>
|
||||
<option value="admin">{m.users_role_admin()}</option>
|
||||
</NativeSelect>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit" disabled={!!createUser.pending}>{m.users_create()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Edit dialog -->
|
||||
<Dialog.Root bind:open={editOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.users_edit_title()}</Dialog.Title>
|
||||
<Dialog.Description>{m.users_edit_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if editing}
|
||||
<form
|
||||
oninput={() => updateUser.validate()}
|
||||
{...updateUser.preflight(updateUserSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.users_saved());
|
||||
editOpen = false;
|
||||
editing = null;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<input type="hidden" name="id" value={editing.id} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="eu-name-{id}">{m.name()}</Field.Label>
|
||||
<Input
|
||||
id="eu-name-{id}"
|
||||
{...updateUser.fields.name.as('text')}
|
||||
value={editing.name}
|
||||
required
|
||||
/>
|
||||
{#each updateUser.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="eu-username-{id}">{m.username()}</Field.Label>
|
||||
<Input
|
||||
id="eu-username-{id}"
|
||||
{...updateUser.fields.username.as('text')}
|
||||
value={editing.username ?? ''}
|
||||
required
|
||||
/>
|
||||
{#each updateUser.fields.username.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="eu-email-{id}">{m.email()}</Field.Label>
|
||||
<Input
|
||||
id="eu-email-{id}"
|
||||
{...updateUser.fields.email.as('email')}
|
||||
value={editing.email}
|
||||
required
|
||||
/>
|
||||
{#each updateUser.fields.email.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="eu-role-{id}">{m.users_role()}</Field.Label>
|
||||
<NativeSelect
|
||||
id="eu-role-{id}"
|
||||
{...updateUser.fields.role.as('text')}
|
||||
value={editing.role ?? 'user'}
|
||||
>
|
||||
<option value="user">{m.users_role_user()}</option>
|
||||
<option value="admin">{m.users_role_admin()}</option>
|
||||
</NativeSelect>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (editOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit" disabled={!!updateUser.pending}>{m.users_create()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<AlertDialog.Root bind:open={deleteOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>{m.users_delete_confirm_title()}</AlertDialog.Title>
|
||||
<AlertDialog.Description>{m.users_delete_confirm_description()}</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
{#if deleting && !deleting.banned}
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox bind:checked={alsoBan} />
|
||||
{m.users_delete_ban_email()}
|
||||
</label>
|
||||
{#if alsoBan}
|
||||
<Input placeholder={m.users_ban_reason()} bind:value={banReason} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmDelete}>{m.users_delete()}</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<!-- Invite dialog -->
|
||||
<Dialog.Root bind:open={inviteOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.users_invite_title()}</Dialog.Title>
|
||||
<Dialog.Description>{m.users_invite_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
oninput={() => inviteUser.validate()}
|
||||
{...inviteUser.preflight(inviteUserSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.users_invited());
|
||||
inviteOpen = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="iu-email-{id}">{m.email()}</Field.Label>
|
||||
<Input id="iu-email-{id}" {...inviteUser.fields.email.as('email')} required />
|
||||
{#each inviteUser.fields.email.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="iu-name-{id}">{m.name()}</Field.Label>
|
||||
<Input id="iu-name-{id}" {...inviteUser.fields.name.as('text')} />
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="iu-username-{id}">{m.username()}</Field.Label>
|
||||
<Input id="iu-username-{id}" {...inviteUser.fields.username.as('text')} />
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="iu-role-{id}">{m.users_role()}</Field.Label>
|
||||
<NativeSelect id="iu-role-{id}" {...inviteUser.fields.role.as('text')}>
|
||||
<option value="user">{m.users_role_user()}</option>
|
||||
<option value="admin">{m.users_role_admin()}</option>
|
||||
</NativeSelect>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (inviteOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit" disabled={!!inviteUser.pending}>{m.users_invite()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { v } from '$lib';
|
||||
import { MailSchema } from '$lib/server/emails/schemas';
|
||||
import { sendMail } from '$lib/server/emails/send';
|
||||
|
||||
export const POST = async ({ request }) => {
|
||||
let data: v.InferOutput<typeof MailSchema>;
|
||||
try {
|
||||
data = v.parse(MailSchema, await request.json());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return json({ ok: false });
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMail(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return json({ ok: false });
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { Orbit } from '@lucide/svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// 2FA enrollment needs room for its two-column layout; every other auth page stays narrow.
|
||||
const wide = $derived(page.route.id === '/auth/setup-2fa');
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10 relative z-10">
|
||||
<div class={['flex w-full flex-col gap-6', wide ? 'max-w-2xl' : 'max-w-sm']}>
|
||||
<a href={resolve('/')} class="flex items-center gap-2 self-center font-medium">
|
||||
<div class="text-primary flex size-6 items-center justify-center rounded-md">
|
||||
<Orbit class="size-6" />
|
||||
</div>
|
||||
<h3 class="font-bold text-2xl">{m.appname()}</h3>
|
||||
</a>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { getAuthClient } from '$lib/auth/client';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const authClient = getAuthClient();
|
||||
const id = $props.id();
|
||||
|
||||
let code = $state('');
|
||||
let backup = $state(false);
|
||||
let trust = $state(true);
|
||||
let loading = $state(false);
|
||||
|
||||
async function verify(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
loading = true;
|
||||
const { error } = backup
|
||||
? await authClient.twoFactor.verifyBackupCode({ code, trustDevice: trust })
|
||||
: await authClient.twoFactor.verifyTotp({ code, trustDevice: trust });
|
||||
loading = false;
|
||||
if (error) {
|
||||
toast.error(error.message || m.errors_invalid_code());
|
||||
return;
|
||||
}
|
||||
await goto(resolve('/'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="text-center">
|
||||
<Card.Title class="text-xl">{m.two_factor_title()}</Card.Title>
|
||||
<Card.Description class="text-balance">{m.two_factor_description()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form onsubmit={verify}>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="code-{id}">{backup ? m.backup_code() : m.code()}</Field.Label>
|
||||
{#if backup}
|
||||
<Input id="code-{id}" bind:value={code} autocomplete="one-time-code" required />
|
||||
{:else}
|
||||
<InputOTP.Root maxlength={6} id="code-{id}" bind:value={code} class="justify-center">
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(0, 3) as cell (cell)}
|
||||
<InputOTP.Slot class="size-12 text-lg" {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
<InputOTP.Separator />
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(3, 6) as cell (cell)}
|
||||
<InputOTP.Slot class="size-12 text-lg" {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
{/snippet}
|
||||
</InputOTP.Root>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Checkbox id="trust-{id}" bind:checked={trust} />
|
||||
<Field.Label for="trust-{id}" class="font-normal">{m.trust_device()}</Field.Label>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Button type="submit" disabled={loading || (!backup && code.length < 6)}>
|
||||
{m.verify()}
|
||||
</Button>
|
||||
<Field.Description class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
onclick={() => {
|
||||
backup = !backup;
|
||||
code = '';
|
||||
}}
|
||||
>
|
||||
{backup ? m.use_authenticator() : m.use_backup_code()}
|
||||
</button>
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { resetRequestSchema } from '$lib/auth/schemas';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { requestReset } from '$lib/remotes/auth.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="text-center">
|
||||
<Card.Title class="text-xl">{m.forgot_password_title()}</Card.Title>
|
||||
<Card.Description class="text-balance">
|
||||
{requestReset.result?.sent ? m.reset_link_sent() : m.forgot_password_description()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if requestReset.result?.sent}
|
||||
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
|
||||
{:else}
|
||||
<form
|
||||
oninput={() => requestReset.validate()}
|
||||
{...requestReset.preflight(resetRequestSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="email-{id}">{m.email()}</Field.Label>
|
||||
<Input
|
||||
id="email-{id}"
|
||||
placeholder={m.email_placeholder()}
|
||||
{...requestReset.fields.email.as('email')}
|
||||
required
|
||||
/>
|
||||
{#each requestReset.fields.email.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Button type="submit" disabled={!!requestReset.pending}>{m.send_reset_link()}</Button>
|
||||
<Field.Description class="text-center">
|
||||
<a href={resolve('/auth/sign-in')} class="link">{m.back_to_login()}</a>
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resetPasswordSchema } from '$lib/auth/schemas';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { resetPassword } from '$lib/remotes/auth.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
const token = page.url.searchParams.get('token') ?? '';
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="text-center">
|
||||
<Card.Title class="text-xl">{m.reset_password_title()}</Card.Title>
|
||||
<Card.Description class="text-balance">
|
||||
{token ? m.reset_password_description() : m.invalid_reset_link()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if token}
|
||||
<form
|
||||
oninput={() => resetPassword.validate()}
|
||||
{...resetPassword.preflight(resetPasswordSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Field.Group>
|
||||
<input {...resetPassword.fields.token.as('hidden', token)} />
|
||||
<Field.Field>
|
||||
<Field.Label for="password-{id}">{m.new_password()}</Field.Label>
|
||||
<Input
|
||||
id="password-{id}"
|
||||
{...resetPassword.fields.newPassword.as('password')}
|
||||
required
|
||||
/>
|
||||
{#each resetPassword.fields.newPassword.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="confirm-{id}">{m.confirm_password()}</Field.Label>
|
||||
<Input id="confirm-{id}" {...resetPassword.fields._confirm.as('password')} required />
|
||||
{#each resetPassword.fields._confirm.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
<Field.Description>{m.password_hint()}</Field.Description>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Button type="submit" disabled={!!resetPassword.pending}>
|
||||
{m.reset_password_action()}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
{:else}
|
||||
<Button href="/auth/forgot-password" variant="outline" class="w-full">
|
||||
{m.forgot_password_title()}
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { getAuthClient } from '$lib/auth/client';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { renderSVG } from 'uqr';
|
||||
|
||||
const authClient = getAuthClient();
|
||||
const id = $props.id();
|
||||
|
||||
let step = $state<'password' | 'verify'>('password');
|
||||
let password = $state('');
|
||||
let code = $state('');
|
||||
let totpURI = $state('');
|
||||
let secret = $state('');
|
||||
let backupCodes = $state<string[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
async function enable(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
loading = true;
|
||||
const { data, error } = await authClient.twoFactor.enable({ password });
|
||||
loading = false;
|
||||
if (error || !data) {
|
||||
toast.error(error?.message || m.errors_generic());
|
||||
return;
|
||||
}
|
||||
totpURI = data.totpURI;
|
||||
secret = new URL(data.totpURI).searchParams.get('secret') ?? '';
|
||||
backupCodes = data.backupCodes;
|
||||
step = 'verify';
|
||||
}
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const blob = new Blob([backupCodes.join('\n') + '\n'], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${m.appname()}-backup-codes.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function confirm(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
loading = true;
|
||||
// twoFactorEnabled flips to true only once the first code verifies.
|
||||
const { error } = await authClient.twoFactor.verifyTotp({ code, trustDevice: true });
|
||||
loading = false;
|
||||
if (error) {
|
||||
toast.error(error.message || m.errors_invalid_code());
|
||||
return;
|
||||
}
|
||||
await goto(resolve('/'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="text-center">
|
||||
<Card.Title class="text-xl">{m.setup_2fa_title()}</Card.Title>
|
||||
<Card.Description class="text-balance">
|
||||
{step === 'password' ? m.setup_2fa_description() : m.scan_qr()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if step === 'password'}
|
||||
<form onsubmit={enable} class="mx-auto w-full max-w-sm">
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="password-{id}">{m.enter_password_to_continue()}</Field.Label>
|
||||
<Input
|
||||
id="password-{id}"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Button type="submit" disabled={loading}>{m.continue_action()}</Button>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
{:else}
|
||||
<form onsubmit={confirm} class="grid gap-8 md:grid-cols-2">
|
||||
<!-- Left: scan / manual entry -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="[&>svg]:h-auto [&>svg]:w-full mx-auto w-full rounded-md bg-white p-3">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html renderSVG(totpURI)}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium">{m.manual_entry_key()}</span>
|
||||
<code class="bg-muted rounded-md p-2 font-mono text-xs break-all select-all">
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: backup codes + verification -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium">{m.backup_codes_title()}</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-sm">{m.backup_codes_notice()}</span>
|
||||
<div
|
||||
class="bg-muted grid grid-cols-2 gap-1 rounded-md p-3 text-center font-mono text-sm"
|
||||
>
|
||||
{#each backupCodes as backupCode (backupCode)}
|
||||
<span class="select-all">{backupCode}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={downloadBackupCodes}
|
||||
disabled={!backupCodes.length}
|
||||
>
|
||||
<DownloadIcon />
|
||||
{m.download()}
|
||||
</Button>
|
||||
<Field.Field>
|
||||
<Field.Label for="code-{id}">{m.code()}</Field.Label>
|
||||
<InputOTP.Root
|
||||
maxlength={6}
|
||||
id="code-{id}"
|
||||
bind:value={code}
|
||||
class="flex justify-center"
|
||||
>
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(0, 3) as cell (cell)}
|
||||
<InputOTP.Slot {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
<InputOTP.Separator />
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(3, 6) as cell (cell)}
|
||||
<InputOTP.Slot {cell} />
|
||||
{/each}
|
||||
</InputOTP.Group>
|
||||
{/snippet}
|
||||
</InputOTP.Root>
|
||||
</Field.Field>
|
||||
<Button type="submit" class="mt-auto" disabled={loading || code.length < 6}>
|
||||
{m.finish()}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getAuthClient } from '$lib/auth/client';
|
||||
import { loginSchema } from '$lib/auth/schemas';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getOAuthProviders, login } from '$lib/remotes/auth.remote';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
|
||||
const authClient = getAuthClient();
|
||||
const providers = $derived(await getOAuthProviders());
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-6')}>
|
||||
<Card.Root>
|
||||
<Card.Header class="text-center">
|
||||
<Card.Title class="text-xl">{m.welcome_back()}</Card.Title>
|
||||
<Card.Description class="text-balance">{m.login_social_description()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
oninput={() => login.validate()}
|
||||
{...login.preflight(loginSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Field.Group>
|
||||
{#each providers.socialProviders as provider (provider)}
|
||||
<Field.Field>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
const { error } = await authClient.signIn.social({
|
||||
callbackURL: new URL('/dashboard', env.PUBLIC_ORIGIN).href,
|
||||
provider
|
||||
});
|
||||
if (error) toast.error(error.message || m.errors_generic());
|
||||
}}
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html m.login_with({ social: provider })}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/each}
|
||||
{#each providers.oauthConfig as provider (provider.providerId)}
|
||||
<Field.Field>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
const { error } = await authClient.signIn.oauth2({
|
||||
callbackURL: new URL('/dashboard', env.PUBLIC_ORIGIN).href,
|
||||
|
||||
providerId: provider.providerId
|
||||
});
|
||||
if (error) toast.error(error.message || m.errors_generic());
|
||||
}}
|
||||
>
|
||||
{m.login_with({ social: provider.providerId })}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/each}
|
||||
{#if providers.oauthConfig.length || providers.socialProviders.length}
|
||||
<Field.Separator class="*:data-[slot=field-separator-content]:bg-card">
|
||||
{m.or()}
|
||||
</Field.Separator>
|
||||
{/if}
|
||||
<Field.Field>
|
||||
<Field.Label for="username-{id}">{m.username()}</Field.Label>
|
||||
<Input
|
||||
id="username-{id}"
|
||||
autocomplete="username"
|
||||
placeholder={m.username_placeholder()}
|
||||
{...login.fields.username.as('text')}
|
||||
required
|
||||
/>
|
||||
{#each login.fields.username.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<div class="flex items-center">
|
||||
<Field.Label for="password-{id}">{m.password()}</Field.Label>
|
||||
<a href={resolve('/auth/forgot-password')} class="ms-auto link text-sm underline">
|
||||
{m.forgot_password()}
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="password-{id}"
|
||||
autocomplete="current-password"
|
||||
{...login.fields._password.as('password')}
|
||||
required
|
||||
/>
|
||||
{#each login.fields._password.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Checkbox id="remember-{id}" name="rememberMe" value="yes" />
|
||||
<Field.Label for="remember-{id}" class="font-normal">{m.remember_me()}</Field.Label>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Button type="submit" disabled={!!login.pending}>{m.login()}</Button>
|
||||
<Field.Description class="text-center">
|
||||
{m.no_account()} <a href={resolve('/auth/sign-up')} class="link">{m.sign_up()}</a>
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Field.Description class="px-6 text-center text-balance">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html m.terms_notice({ privacy: '/privacy-policy', terms: '/terms-of-service' })}
|
||||
</Field.Description>
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { registerSchema } from '$lib/auth/schemas';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { register } from '$lib/remotes/auth.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const id = $props.id();
|
||||
</script>
|
||||
|
||||
{#if register.result?.email}
|
||||
<Card.Root>
|
||||
<Card.Header class="text-center">
|
||||
<Card.Title class="text-xl">{m.check_your_email()}</Card.Title>
|
||||
<Card.Description class="text-balance"
|
||||
>{m.verification_sent({ email: register.result.email })}</Card.Description
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Footer>
|
||||
<Button href="/auth/sign-in" variant="outline" class="w-full">{m.back_to_login()}</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header class="text-center">
|
||||
<Card.Title class="text-xl">{m.sign_up_title()}</Card.Title>
|
||||
<Card.Description class="text-balance">{m.sign_up_description()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
oninput={() => register.validate()}
|
||||
{...register.preflight(registerSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="username-{id}">{m.username()}</Field.Label>
|
||||
<Input
|
||||
id="username-{id}"
|
||||
placeholder={m.username_placeholder()}
|
||||
{...register.fields.username.as('text')}
|
||||
required
|
||||
/>
|
||||
{#each register.fields.username.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="email-{id}">{m.email()}</Field.Label>
|
||||
<Input
|
||||
id="email-{id}"
|
||||
placeholder={m.email_placeholder()}
|
||||
{...register.fields.email.as('email')}
|
||||
required
|
||||
/>
|
||||
{#each register.fields.email.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="password-{id}">{m.password()}</Field.Label>
|
||||
<Input id="password-{id}" {...register.fields._password.as('password')} required />
|
||||
{#each register.fields._password.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="confirm-{id}">{m.confirm_password()}</Field.Label>
|
||||
<Input id="confirm-{id}" {...register.fields._confirm.as('password')} required />
|
||||
{#each register.fields._confirm.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
<Field.Description>{m.password_hint()}</Field.Description>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Button type="submit" disabled={!!register.pending}>{m.create_account()}</Button>
|
||||
<Field.Description class="text-center">
|
||||
{m.already_have_account()}
|
||||
<a href={resolve('/auth/sign-in')} class="link">{m.login()}</a>
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,380 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
Cpu,
|
||||
Ellipsis,
|
||||
MemoryStick,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Trash2
|
||||
} from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import ActivityPanel from '$lib/components/dashboard/activity-panel.svelte';
|
||||
import CpuHeatmap from '$lib/components/dashboard/cpu-heatmap.svelte';
|
||||
import {
|
||||
fmtDateTime,
|
||||
gb,
|
||||
pct,
|
||||
uptime,
|
||||
usageBar,
|
||||
usageText
|
||||
} from '$lib/components/dashboard/format';
|
||||
import KpiCard from '$lib/components/dashboard/kpi-card.svelte';
|
||||
import NetworkPanel from '$lib/components/dashboard/network-panel.svelte';
|
||||
import StoragePanel from '$lib/components/dashboard/storage-panel.svelte';
|
||||
import SystemPanel from '$lib/components/dashboard/system-panel.svelte';
|
||||
import TemperaturePanel from '$lib/components/dashboard/temperature-panel.svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
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 { Progress } from '$lib/components/ui/progress';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { machineDeleteSchema, machineEditSchema } from '$lib/machines/schema';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { deleteMachine, listMachines, updateMachine } from '$lib/remotes/machines.remote';
|
||||
import {
|
||||
auditLog,
|
||||
latestAgentRelease,
|
||||
serverInfo,
|
||||
systemDetails
|
||||
} from '$lib/remotes/server.remote';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const info = $derived(serverInfo());
|
||||
const audit = $derived(auditLog());
|
||||
const details = $derived(systemDetails());
|
||||
const latest = $derived(latestAgentRelease());
|
||||
const intervals = $derived([
|
||||
{ label: m.dashboard_interval_second(), value: '1' },
|
||||
{ label: m.dashboard_interval_5s(), value: '5' },
|
||||
{ label: m.dashboard_interval_10s(), value: '10' },
|
||||
{ label: m.dashboard_interval_30s(), value: '30' },
|
||||
{ label: m.dashboard_interval_custom(), value: 'custom' }
|
||||
]);
|
||||
let syncInterval = $state('5');
|
||||
let customSec = $state(15);
|
||||
const intervalMs = $derived(
|
||||
(syncInterval === 'custom' ? customSec : Number(syncInterval)) * 1000
|
||||
);
|
||||
const intervalLabel = $derived(intervals.find((i) => i.value === syncInterval)?.label);
|
||||
|
||||
const poll = async () => {
|
||||
console.log('refreshed poll');
|
||||
await serverInfo().refresh();
|
||||
await auditLog().refresh();
|
||||
};
|
||||
const refreshAll = async () => {
|
||||
console.log('refreshed all');
|
||||
await poll();
|
||||
await systemDetails().refresh();
|
||||
};
|
||||
|
||||
let polling = $state(true);
|
||||
// $effect re-runs when rate or polling toggle, so changing the selector takes
|
||||
// effect immediately and pausing actually stops the timer.
|
||||
$effect(() => {
|
||||
if (!polling || intervalMs <= 0) return;
|
||||
const id = setInterval(poll, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
let editOpen = $state(false);
|
||||
let deleteOpen = $state(false);
|
||||
const formId = $props.id();
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full flex-col gap-0 grow">
|
||||
<svelte:boundary>
|
||||
{#snippet failed(err)}
|
||||
{console.log('errore', err)}
|
||||
{/snippet}
|
||||
|
||||
{@const sys = await info}
|
||||
{@const log = await audit}
|
||||
{@const d = await details}
|
||||
{@const latestTag = await latest}
|
||||
{@const agentVersion = (sys as unknown as { agent_version?: string }).agent_version}
|
||||
{@const outdated = !!(agentVersion && latestTag && agentVersion !== latestTag)}
|
||||
{@const memPct = pct(sys.memory.used_bytes, sys.memory.total_bytes)}
|
||||
{@const loadPct = Math.round((sys.load.load1 / sys.cpu.logical_cpus) * 100)}
|
||||
{@const swapPct = pct(
|
||||
sys.memory.swap_total_bytes - sys.memory.swap_free_bytes,
|
||||
sys.memory.swap_total_bytes
|
||||
)}
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sticky top-15 bg-background p-4 py-2 z-20"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<h1 class="text-2xl font-semibold tracking-tight truncate">{sys.$db.name}</h1>
|
||||
<p class="text-muted-foreground text-sm truncate">
|
||||
{sys.os.hostname} | {sys.os.pretty_name} | {sys.$db.address}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center lg:justify-end gap-2 py-1">
|
||||
<Select.Root type="single" bind:value={syncInterval}>
|
||||
<Select.Trigger class="h-8 w-auto grow lg:w-35">{intervalLabel}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each intervals as opt (opt.value)}
|
||||
<Select.Item value={opt.value}>{opt.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if syncInterval === 'custom'}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Input type="number" min="1" bind:value={customSec} class="h-8 w-16" />
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => (polling = !polling)}
|
||||
aria-label={polling ? m.dashboard_pause() : m.dashboard_resume()}
|
||||
>
|
||||
{#if polling}
|
||||
<Pause class="size-4" />
|
||||
{:else}
|
||||
<Play class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 px-2 sm:px-3"
|
||||
onclick={refreshAll}
|
||||
aria-label={m.dashboard_refresh()}
|
||||
>
|
||||
<RefreshCw class="size-4" />
|
||||
<span class="hidden sm:inline">{m.dashboard_refresh()}</span>
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8 relative"
|
||||
aria-label={m.machine_actions()}
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
{#if outdated}
|
||||
<span
|
||||
class="absolute top-1 right-1 size-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
title={`nadir-agent ${agentVersion} → ${latestTag}`}
|
||||
></span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onSelect={() => (editOpen = true)}>
|
||||
<Pencil class="size-4" />
|
||||
{m.edit()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item variant="destructive" onSelect={() => (deleteOpen = true)}>
|
||||
<Trash2 class="size-4" />
|
||||
{m.delete()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 px-4 py-2">
|
||||
<!-- CPU Heatmap (replaces the old CPU KpiCard) -->
|
||||
<KpiCard
|
||||
label={m.dashboard_cpu()}
|
||||
icon={Cpu}
|
||||
value={sys.cpu.model}
|
||||
detail={m.dashboard_logical_cores({ cores: sys.cpu.logical_cpus })}
|
||||
/>
|
||||
|
||||
<KpiCard
|
||||
label={m.dashboard_memory()}
|
||||
icon={MemoryStick}
|
||||
value="{memPct}%"
|
||||
valueClass="tabular-nums {usageText(memPct)}"
|
||||
>
|
||||
{#snippet extra()}
|
||||
<div
|
||||
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
|
||||
>
|
||||
<span>{gb(sys.memory.used_bytes)} / {gb(sys.memory.total_bytes)}</span>
|
||||
</div>
|
||||
<Progress value={memPct} class={usageBar(memPct)} />
|
||||
{#if sys.memory.swap_total_bytes > 0}
|
||||
<div
|
||||
class="text-muted-foreground flex items-baseline justify-between text-xs tabular-nums"
|
||||
>
|
||||
<span
|
||||
>{gb(sys.memory.swap_total_bytes - sys.memory.swap_free_bytes)} / {gb(
|
||||
sys.memory.swap_total_bytes
|
||||
)}</span
|
||||
>
|
||||
<span>{m.dashboard_swap({ swapPct })}</span>
|
||||
</div>
|
||||
<Progress value={swapPct} class={usageBar(swapPct)} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</KpiCard>
|
||||
|
||||
<KpiCard
|
||||
label={m.dashboard_load_average()}
|
||||
icon={Activity}
|
||||
value={sys.load.load1.toFixed(2)}
|
||||
valueClass="tabular-nums {usageText(loadPct)}"
|
||||
detail={m.dashboard_load_detail({
|
||||
cores: sys.cpu.logical_cpus,
|
||||
load15: sys.load.load15.toFixed(2),
|
||||
load5: sys.load.load5.toFixed(2),
|
||||
loadPct
|
||||
})}
|
||||
/>
|
||||
|
||||
<KpiCard
|
||||
label={m.dashboard_uptime()}
|
||||
icon={Clock}
|
||||
value={uptime(sys.uptime_seconds)}
|
||||
detail={m.dashboard_since({ bootTime: fmtDateTime(sys.boot_time) })}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid items-center gap-4 justify-center lg:grid-cols-3 p-4 py-2">
|
||||
<div class="col-span-2">
|
||||
<StoragePanel items={sys.disks ?? []} />
|
||||
</div>
|
||||
|
||||
<CpuHeatmap
|
||||
cpuUsage={sys.load.cpu_usage}
|
||||
cpuModel={sys.cpu.model}
|
||||
logicalCpus={sys.cpu.logical_cpus}
|
||||
minMhz={sys.cpu.min_mhz}
|
||||
maxMhz={sys.cpu.max_mhz}
|
||||
currentMhz={sys.cpu.current_mhz}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-4 lg:grid-cols-3 p-4 py-2">
|
||||
<SystemPanel os={sys.os} cpu={sys.cpu} details={d} />
|
||||
<NetworkPanel items={sys.network_interfaces ?? []} />
|
||||
<TemperaturePanel items={sys.temperatures ?? []} />
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-2 pb-4">
|
||||
<ActivityPanel items={log} />
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={editOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{m.machine_edit()}</Dialog.Title>
|
||||
<Dialog.Description>{m.machine_edit_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
oninput={() => updateMachine.validate()}
|
||||
{...updateMachine.preflight(machineEditSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
await Promise.all([
|
||||
listMachines({ page: 1, search: '' }).refresh(),
|
||||
info.refresh(),
|
||||
details.refresh()
|
||||
]);
|
||||
editOpen = false;
|
||||
toast.success(m.machine_edit());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<input type="hidden" name="id" value={sys.$db.id} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="edit-name-{formId}">{m.machine_name()}</Field.Label>
|
||||
<Input
|
||||
id="edit-name-{formId}"
|
||||
placeholder={m.machine_name_placeholder()}
|
||||
{...updateMachine.fields.name.as('text')}
|
||||
value={sys.$db.name}
|
||||
required
|
||||
/>
|
||||
{#each updateMachine.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="edit-address-{formId}">{m.machine_address()}</Field.Label>
|
||||
<Input
|
||||
id="edit-address-{formId}"
|
||||
placeholder={m.machine_address_placeholder()}
|
||||
{...updateMachine.fields.address.as('text')}
|
||||
value={sys.$db.address}
|
||||
/>
|
||||
{#each updateMachine.fields.address.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="edit-token-{formId}">{m.machine_token()}</Field.Label>
|
||||
<Input
|
||||
id="edit-token-{formId}"
|
||||
placeholder={m.machine_token_placeholder()}
|
||||
{...updateMachine.fields.token.as('password')}
|
||||
/>
|
||||
<Field.Description>{m.machine_token_keep()}</Field.Description>
|
||||
{#each updateMachine.fields.token.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Button type="submit" disabled={!!updateMachine.pending}>{m.machine_save_edit()}</Button
|
||||
>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={deleteOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>{m.machine_delete_title()}</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
{m.machine_delete_confirm({ name: sys.$db.name ?? '' })}
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>{m.cancel()}</AlertDialog.Cancel>
|
||||
<form
|
||||
{...deleteMachine.preflight(machineDeleteSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
await listMachines({ page: 1, search: '' }).refresh();
|
||||
deleteOpen = false;
|
||||
goto(resolve('/dashboard'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
(error as { body?: { message?: string } })?.body?.message || m.errors_generic()
|
||||
);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<input type="hidden" name="id" value={sys.$db.id} />
|
||||
<AlertDialog.Action type="submit" disabled={!!deleteMachine.pending}>
|
||||
{m.delete()}
|
||||
</AlertDialog.Action>
|
||||
</form>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn-svelte/tailwind.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--theme-light: oklch(0.985 0 0);
|
||||
--theme-dark: oklch(0.145 0 0);
|
||||
--theme-primary: oklch(75% 0.15 250);
|
||||
--theme-secondary: oklch(0.68 0.14 35);
|
||||
--theme-tertiary: oklch(75% 0.1 255);
|
||||
|
||||
--background: var(--theme-light);
|
||||
--foreground: var(--theme-dark);
|
||||
--card: oklch(from var(--theme-light) l c h / 100%);
|
||||
--card-foreground: var(--theme-dark);
|
||||
--popover: oklch(from var(--theme-light) l c h / 100%);
|
||||
--popover-foreground: var(--theme-dark);
|
||||
--primary: var(--theme-primary);
|
||||
--primary-foreground: var(--theme-dark);
|
||||
--secondary: var(--theme-secondary);
|
||||
--secondary-foreground: var(--theme-dark);
|
||||
--tertiary: var(--theme-tertiary);
|
||||
--tertiary-foreground: var(--theme-dark);
|
||||
--accent: var(--theme-tertiary);
|
||||
--accent-foreground: var(--theme-dark);
|
||||
--muted: oklch(from var(--theme-light) calc(l - 0.03) c h);
|
||||
--muted-foreground: oklch(from var(--theme-dark) calc(l + 0.4) c h);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(from var(--theme-light) calc(l - 0.07) c h);
|
||||
--input: oklch(from var(--theme-light) calc(l - 0.07) c h);
|
||||
--ring: var(--theme-primary);
|
||||
--chart-1: var(--theme-primary);
|
||||
--chart-2: var(--theme-secondary);
|
||||
--chart-3: var(--theme-tertiary);
|
||||
--chart-4: oklch(from var(--theme-primary) calc(l - 0.15) c h);
|
||||
--chart-5: oklch(from var(--theme-secondary) calc(l - 0.15) c h);
|
||||
--radius: 0.25rem;
|
||||
--sidebar: oklch(from var(--theme-light) calc(l - 0.01) c h);
|
||||
--sidebar-foreground: var(--theme-dark);
|
||||
--sidebar-primary: var(--theme-primary);
|
||||
--sidebar-primary-foreground: var(--theme-light);
|
||||
--sidebar-accent: var(--theme-secondary);
|
||||
--sidebar-accent-foreground: var(--theme-dark);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: var(--theme-dark);
|
||||
--foreground: var(--theme-light);
|
||||
--card: oklch(from var(--theme-dark) calc(l + 0.06) c h);
|
||||
--card-foreground: var(--theme-light);
|
||||
--popover: oklch(from var(--theme-dark) calc(l + 0.06) c h);
|
||||
--popover-foreground: var(--theme-light);
|
||||
--primary: var(--theme-primary);
|
||||
--primary-foreground: var(--theme-dark);
|
||||
--secondary: var(--theme-secondary);
|
||||
--secondary-foreground: var(--theme-dark);
|
||||
--accent: var(--theme-tertiary);
|
||||
--accent-foreground: var(--theme-dark);
|
||||
--muted: oklch(from var(--theme-dark) calc(l + 0.12) c h);
|
||||
--muted-foreground: oklch(from var(--theme-light) calc(l - 0.3) c h);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(from var(--theme-dark) calc(l + 0.125) c h / 30%);
|
||||
--input: oklch(from var(--theme-dark) calc(l + 0.25) c h / 100%);
|
||||
--ring: var(--theme-primary);
|
||||
--chart-1: var(--theme-primary);
|
||||
--chart-2: var(--theme-secondary);
|
||||
--chart-3: var(--theme-tertiary);
|
||||
--chart-4: oklch(from var(--theme-primary) calc(l + 0.1) c h);
|
||||
--chart-5: oklch(from var(--theme-secondary) calc(l + 0.1) c h);
|
||||
--sidebar: oklch(from var(--theme-dark) calc(l + 0.04) c h);
|
||||
--sidebar-foreground: var(--theme-light);
|
||||
--sidebar-primary: var(--theme-primary);
|
||||
--sidebar-primary-foreground: var(--theme-light);
|
||||
--sidebar-accent: var(--theme-secondary);
|
||||
--sidebar-accent-foreground: var(--theme-dark);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Work Sans', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-tertiary: var(--tertiary);
|
||||
--color-tertiary-foreground: var(--tertiary-foreground);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply font-medium decoration-inherit decoration-2 underline-offset-1!;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-primary text-background;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply h-2 w-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply border-s border-border bg-background;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-muted;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
textarea:-webkit-autofill,
|
||||
select:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px hsl(var(--background)) inset !important;
|
||||
box-shadow: 0 0 0 1000px hsl(var(--background)) inset !important;
|
||||
-webkit-text-fill-color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
input:-webkit-autofill,
|
||||
textarea:-webkit-autofill,
|
||||
select:-webkit-autofill {
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user