deleted old dashboard

This commit is contained in:
axel 2025-04-17 02:41:03 +02:00
parent 2ac2650f99
commit 5e3fb972e8
27 changed files with 4 additions and 983 deletions

View File

@ -1,74 +0,0 @@
<script lang="ts">
import { browser } from "$app/environment";
import { slide } from "svelte/transition";
import IconListDetails from "~icons/tabler/list-details";
import IconMinus from "~icons/tabler/minus";
import IconPlus from "~icons/tabler/plus";
import IconSearch from "~icons/tabler/search";
import IconX from "~icons/tabler/x";
import Button from "../ui/Button.svelte";
let {
label,
sublabel = "",
name,
data
}: {
label: string,
sublabel?: string,
name: string,
data: {
value: string,
name: string,
selected: boolean
}[]
} = $props();
let selectData = $state(data);
let expanded = $state(false);
let search = $state("");
let focused = $state(false);
if (browser) {
document.addEventListener("click", () => {
if (!focused) expanded = false;
});
}
</script>
<div class="flex gap-x-10 gap-y-3 flex-wrap mb-5">
<label for={name} class="block w-1/3 min-w-[300px]">
<p>{label}</p>
{#if sublabel}
<p class="text-sm text-neutral-400 dark:text-neutral-500">{sublabel}</p>
{/if}
</label>
<select multiple name={name} class="hidden">
{#each selectData.filter(d => d.selected) as el}
<option value={el.value} selected></option>
{/each}
</select>
<div class="relative flex gap-10 items-center h-fit rounded border border-gray-300 dark:border-neutral-700 shadow-sm dark:shadow-neutral-800 pl-4 pr-2 py-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
{#if selectData.length == 0}
<p>None available</p>
{:else}
<p>{selectData.filter(d => d.selected).length} selected</p>
<Button Icon={expanded ? IconX : IconListDetails} type="button" extra="!px-2 !py-1 !text-xs !rounded-sm" onclick={() => {expanded = !expanded; focused = true}} onmouseleave={() => focused = false}>{expanded ? "Close" : "Select"}</Button>
{/if}
{#if expanded}
<ul transition:slide onmouseleave={() => focused = false} onmouseenter={() => focused = true} class="absolute flex flex-col gap-2 top-[calc(100%+10px)] z-1 left-0 w-full max-h-40 overflow-y-scroll bg-white dark:bg-neutral-950 rounded border border-gray-300 dark:border-neutral-700 shadow-sm p-2 text-xs">
<div class="flex gap-2 items-center">
<IconSearch class="text-neutral-400"/>
<input type="text" placeholder="Search..." bind:value={search} class="block w-full text-sm !outline-none transition-all duration-300 ease-in-out border-transparent focus:border-b-2 focus:border-indigo-500">
</div>
<div class="w-full h-px bg-neutral-200 dark:bg-neutral-700"></div>
{#each search ? selectData.filter(d => d.name.toLowerCase().includes(search.toLowerCase())) : selectData as el}
<Button Icon={el.selected ? IconMinus : IconPlus} type="button" extra="!p-1 !text-xs !rounded-sm w-full !border-none" onclick={() => {el.selected = !el.selected}} inverted={!el.selected}>{el.name}</Button>
{/each}
</ul>
{/if}
</div>
</div>

View File

@ -1,20 +0,0 @@
<script lang="ts">
let {
label,
sublabel = "",
name,
defaultValue,
...others
} = $props();
</script>
<div class="flex gap-x-10 gap-y-3 flex-wrap mb-5 items-center">
<label for={name} class="block w-1/3 min-w-[300px]">
<p>{label}</p>
{#if sublabel}
<p class="text-sm text-neutral-400 dark:text-neutral-500">{sublabel}</p>
{/if}
</label>
<input id={name} name={name} type="text" defaultValue={defaultValue} {...others}
class="block h-fit rounded border border-gray-300 dark:border-neutral-700 shadow-sm dark:shadow-neutral-800 px-4 py-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>

View File

@ -1,26 +0,0 @@
<script lang="ts">
import IconCheck from "~icons/tabler/check";
import IconX from "~icons/tabler/x";
import Button from "../ui/Button.svelte";
let {
label,
sublabel = "",
name,
checked,
options = ["Yes", "No"],
...others
} = $props();
</script>
<div class="flex gap-x-10 gap-y-3 flex-wrap mb-5 items-center">
<label for={name} class="block w-1/3 min-w-[300px]">
<p>{label}</p>
{#if sublabel.length > 0}
<p class="text-sm text-neutral-400 dark:text-neutral-500">{sublabel}</p>
{/if}
</label>
<input class="hidden" type="checkbox" id={name} name={name} defaultChecked={checked}/>
<Button Icon={checked ? IconCheck : IconX} type="button" inverted={!checked} {...others} onclick={() => checked = !checked}>{checked ? options[0] : options[1]}</Button>
</div>

View File

@ -1,19 +0,0 @@
<script lang="ts">
let {
title,
subtitle,
actionsSnippet
} = $props();
</script>
<div class="flex gap-10 justify-between items-center rounded border border-neutral-200 dark:border-neutral-700 py-4 px-6 shadow dark:shadow-neutral-800">
<div>
<p class="font-medium">{title}</p>
<p class="text-neutral-400">{subtitle}</p>
</div>
<div class="flex gap-2">
{#if actionsSnippet}
{@render actionsSnippet()}
{/if}
</div>
</div>

View File

@ -1,38 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
import IconChevronLeft from "~icons/tabler/chevron-left";
import IconEclamationCircle from "~icons/tabler/exclamation-circle";
import Button from "../ui/Button.svelte";
import HorizontalSpacer from "../ui/HorizontalSpacer.svelte";
import ResourcePageHeader from "./ResourcePageHeader.svelte";
let {
heading,
children,
error = null,
} = $props();
$effect(() => {
if (error) {
setTimeout(() => error = null, 5000)
}
});
</script>
<div class="flex flex-col h-full text-neutral-700 dark:text-neutral-300">
<ResourcePageHeader title={heading}>
<Button Icon={IconChevronLeft} onclick={() => history.back()} inverted>Back</Button>
</ResourcePageHeader>
<HorizontalSpacer/>
<div class="text-sm flex-1 overflow-scroll">
{@render children()}
</div>
{#if error}
<div transition:fade>
<Button Icon={IconEclamationCircle} type="button" color="danger" onclick={() => error = null}>{error} (Click to dismiss)</Button>
</div>
{/if}
</div>

View File

@ -1,22 +0,0 @@
<script lang="ts">
import HorizontalSpacer from "../ui/HorizontalSpacer.svelte";
import ResourcePageHeader from "./ResourcePageHeader.svelte";
let {
heading,
actionSnippet,
contentSnippet
} = $props();
</script>
<div class="flex flex-col h-full text-neutral-700 dark:text-neutral-300">
<ResourcePageHeader title={heading}>
{@render actionSnippet()}
</ResourcePageHeader>
<HorizontalSpacer/>
<div class="text-sm flex-1 overflow-scroll">
{@render contentSnippet()}
</div>
</div>

View File

@ -1,8 +0,0 @@
<script lang="ts">
let { title, children } = $props();
</script>
<div class="flex justify-between items-center">
<h1 class="text-xl font-semibold">{title}</h1>
{@render children()}
</div>

View File

@ -1,58 +0,0 @@
<script lang="ts">
import type { ClassValue, HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
let {
a,
color,
inverted,
Icon,
children,
extra,
...others
}: {
a?: boolean,
color?: "primary" | "danger" | "success",
inverted?: boolean,
Icon?: any,
extra?: ClassValue,
children?: any
} & HTMLButtonAttributes & HTMLAnchorAttributes = $props();
const baseClasses = "block flex items-center text-sm px-4 py-2 cursor-pointer rounded transition-all duration-300 ease-in-out";
const colors = {
"normal": {
"danger": "bg-red-500 hover:bg-red-600 text-white border border-transparent",
"success": "bg-emerald-500 hover:bg-emerald-600 text-white border border-transparent",
"primary": "bg-neutral-800 dark:bg-neutral-200 hover:bg-neutral-600 dark:hover:bg-neutral-400 text-white dark:text-black border border-transparent",
},
"inverted": {
"danger": "bg-white dark:bg-black hover:bg-red-100 dark:hover:bg-red-950 text-red-500 border border-red-500",
"success": "bg-white hover:bg-emerald-100 text-emerald-500 border border-emerald-500",
"primary": "bg-white dark:bg-neutral-950 hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-800 dark:text-neutral-200 border border-neutral-800 dark:border-neutral-700",
},
};
let colorClasses = $derived(colors[inverted ? "inverted" : "normal"][color ?? "primary"]);
let fullClasses = $derived(baseClasses + " " + extra + " " + colorClasses);
</script>
{#if a}
<a class={fullClasses} {...others}>
{#if Icon}
<Icon class={children ? "mr-2" : ""}></Icon>
{/if}
{@render children?.()}
</a>
{:else}
<button class={fullClasses} {...others}>
{#if Icon}
<Icon class={children ? "mr-2" : ""}></Icon>
{/if}
{@render children?.()}
</button>
{/if}

View File

@ -1,19 +0,0 @@
<script lang="ts">
import IconChevronRight from "~icons/tabler/chevron-right";
import { slide } from "svelte/transition";
let { children, label } = $props();
let expanded = $state(false);
</script>
<div class="mb-4">
<button type="button" class="flex items-center gap-2 cursor-pointer" onclick={() => expanded = !expanded}>
<IconChevronRight class="{expanded ? "rotate-90" : ""} transition-all duration-300"/>
<h1 class="text-xs uppercase font-light">{label}</h1>
</button>
{#if expanded}
<div transition:slide={{duration: 300}} class="mt-4">
{@render children()}
</div>
{/if}
</div>

View File

@ -1 +0,0 @@
<div class="w-full h-px bg-neutral-200 dark:bg-neutral-700 my-4"></div>

View File

@ -27,7 +27,7 @@ export class Guard {
}
if (this.adminRequired && !this.user?.admin) {
redirect(302, '/dashboard');
redirect(302, '/dash');
}
return this;

View File

@ -1,9 +0,0 @@
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals: { guard } }) => {
const user = guard.requiresAuth().orRedirects().getUser();
return {
user,
}
};

View File

@ -1,60 +0,0 @@
<script lang="ts">
import IconHome from "~icons/tabler/home";
import IconLogout2 from "~icons/tabler/logout2";
import IconUsers from "~icons/tabler/users";
import IconUsersGroup from "~icons/tabler/users-group";
let { children, data } = $props();
const links = [
{
name: 'Devices',
href: '/dashboard/devices',
icon: IconHome,
admin: false,
},
{
name: 'Users',
href: '/dashboard/users',
icon: IconUsers,
admin: true,
},
{
name: 'Groups',
href: '/dashboard/groups',
icon: IconUsersGroup,
admin: true,
},
];
</script>
<div class="flex min-h-screen overflow-hidden">
<!-- Sidebar -->
<div id="sidebar" class="h-screen w-64 bg-neutral-100 dark:bg-neutral-900 transform translate-x-0 transition-transform duration-300 ease-in-out z-40 text-neutral-700 dark:text-neutral-300 text-sm border-r border-neutral-200 dark:border-neutral-800">
<div class="p-4 flex flex-col h-full">
<ul class="flex-1">
{#each links as link}
{#if data.user.admin || !link.admin}
<li class="mb-2">
<a class="block flex items-center p-2 bg-transparent hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded" href={link.href}>
<link.icon class="mr-2"/>
{link.name}
</a>
</li>
{/if}
{/each}
</ul>
<div class="p-2 flex w-full justify-between items-center">
<p>Signed in as <span class="underline underline-offset-4">{data.user.name}</span></p>
<a data-sveltekit-preload-data="tap" href="/logout" class="block p-2 bg-transparent hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded"><IconLogout2/></a>
</div>
</div>
</div>
<!-- Main Content -->
<div class="flex-1 p-6 bg-white dark:bg-neutral-950 w-full h-screen">
{@render children()}
</div>
</div>

View File

@ -1,6 +0,0 @@
import { redirect, type ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ locals: { guard }}) => {
guard.requiresAuth().orRedirects();
redirect(302, '/dashboard/devices');
};

View File

@ -1,9 +0,0 @@
import { getUsersDevices } from '$lib/server/db';
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals: { guard } }) => {
const user = guard.requiresAuth().orRedirects().getUser();
return {
devices: getUsersDevices(user.id),
};
};

View File

@ -1,40 +0,0 @@
<script lang="ts">
import { enhance } from "$app/forms";
import ResourceCard from "$lib/components/resources/ResourceCard.svelte";
import ResourceListPage from "$lib/components/resources/ResourceListPage.svelte";
import Button from "$lib/components/ui/Button.svelte";
import IconEdit from "~icons/tabler/edit";
import IconPlayerPlay from "~icons/tabler/player-play";
import IconPlus from "~icons/tabler/plus";
let { data } = $props();
</script>
<ResourceListPage heading="Devices">
{#snippet actionSnippet()}
{#if data.user.admin}
<Button Icon={IconPlus} a href="/dashboard/devices/new">Add Device</Button>
{/if}
{/snippet}
{#snippet contentSnippet()}
{#if data.devices.length === 0}
<p>No devices found</p>
{:else}
<div class="flex gap-4 flex-wrap">
{#each data.devices as device}
<ResourceCard title={device.name} subtitle={device.mac}>
{#snippet actionsSnippet()}
{#if data.user.admin}
<Button Icon={IconEdit} a href="/dashboard/devices/{device.id}" extra="!p-2"/>
{/if}
<form method="POST" action="/dashboard/devices/{device.id}?/wake" use:enhance>
<Button Icon={IconPlayerPlay} type="submit" color="success" extra="!p-2"></Button>
</form>
{/snippet}
</ResourceCard>
{/each}
</div>
{/if}
{/snippet}
</ResourceListPage>

View File

@ -1,132 +0,0 @@
import { db, getUsersDevices } from '$lib/server/db/index.js';
import { fail, redirect, type ServerLoad } from '@sveltejs/kit';
import { nanoid } from 'nanoid';
import validator from 'validator';
import { wake } from 'wake_on_lan';
import { z } from 'zod';
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
guard.requiresAdmin().orRedirects();
const device = db.data.devices.find((d) => d.id === params.slug);
if (!device && params.slug !== 'new') {
redirect(302, '/dashboard/devices');
}
return {
device,
};
};
export const actions = {
update: async ({ request, cookies, params, locals: { guard } }) => {
if (guard.requiresAdmin().isFailed()) return fail(403);
const form = await request.formData();
const schema = z.object({
name: z
.string({ message: 'Name is required.' })
.min(3, { message: 'Name must be at least 3 characters.' })
.max(24, { message: 'Name must be at most 24 characters.' }),
mac: z
.string({ message: 'MAC address is required.' })
.refine((v) => validator.isMACAddress(v), { message: 'Invalid MAC address.' }),
broadcast: z
.string()
.refine((v) => validator.isIP(v), { message: 'Invalid broadcast IP address.' })
.optional(),
port: z.coerce
.number({ message: 'Port is invalid.' })
.min(1, { message: 'Port must be at least 1.' })
.max(65535, { message: 'Port must be at most 65535.' })
.optional(),
packets: z.coerce
.number({ message: 'Packets quantity is invalid.' })
.min(1, { message: 'Packets quantity must be at least 1.' })
.max(50, { message: 'Packets quantity must be at most 50.' })
.optional(),
});
const parsed = schema.safeParse({
name: form.get('name')?.toString(),
mac: form.get('mac')?.toString(),
broadcast: form.get('broadcast')?.toString(),
port: form.get('port')?.toString(),
packets: form.get('packets')?.toString(),
});
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0].message });
}
if (params.slug === 'new') {
await db.update(({ devices }) => {
devices.push({
id: nanoid(),
name: parsed.data.name,
mac: parsed.data.mac,
broadcast: parsed.data.broadcast ?? '255.255.255.255',
port: parsed.data.port ?? 9,
packets: parsed.data.packets ?? 3,
});
});
redirect(302, '/dashboard/devices');
}
await db.update(({ devices }) => {
let dev = devices.find((d) => d.id === params.slug);
if (!dev) return;
dev.name = parsed.data.name;
dev.mac = parsed.data.mac;
dev.broadcast = parsed.data.broadcast ?? dev.broadcast;
dev.port = parsed.data.port ?? dev.port;
dev.packets = parsed.data.packets ?? dev.packets;
});
redirect(302, '/dashboard/devices');
},
delete: async ({ locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) {
return fail(403);
}
db.data.devices = db.data.devices.filter((d) => d.id !== params.slug);
db.data.users.forEach((u) => {
u.devices = u.devices.filter((d) => d !== params.slug);
});
db.data.groups.forEach((g) => {
g.devices = g.devices.filter((d) => d !== params.slug);
});
db.write();
redirect(302, '/dashboard/devices');
},
wake: async ({ params, locals: { guard } }) => {
console.log('Trying to wake ' + params.slug);
guard = guard.requiresAuth();
if (guard.isFailed()) {
console.log('Failed guard');
return fail(403);
}
const device = getUsersDevices(guard.getUser().id).find((d) => d.id === params.slug);
if (!device) {
return fail(404);
}
console.log('Trying to wake ' + device.name);
wake(device.mac, {
address: device.broadcast,
port: device.port,
num_packets: device.packets,
});
},
};

View File

@ -1,51 +0,0 @@
<script lang="ts">
import { enhance } from "$app/forms";
import InputText from "$lib/components/forms/InputText.svelte";
import ResourceEditPage from "$lib/components/resources/ResourceEditPage.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Collapsible from "$lib/components/ui/Collapsible.svelte";
import IconDeviceFloppy from "~icons/tabler/device-floppy";
import IconTrash from "~icons/tabler/trash";
let { form, data } = $props();
</script>
<ResourceEditPage heading={data.device ? "Editing Device - " + data.device.name : "New Device"} error={form?.error}>
<form method="POST" action="?/update" use:enhance>
<InputText
label="Device Name"
sublabel="How the device will appear in the dashboard"
name="name"
defaultValue={data.device ? data.device.name : "New Device"}/>
<InputText
label="MAC Address"
sublabel="Address used for the Wake-On-Lan packets"
name="mac"
defaultValue={data.device ? data.device.mac : "00:00:00:00:00:00"}/>
<Collapsible label="Advanced">
<InputText
label="Broadcast IP Address"
name="broadcast"
defaultValue={data.device ? data.device.broadcast : "255.255.255.255"}/>
<InputText
label="Broadcast Port"
name="port"
defaultValue={data.device ? data.device.port : "9"}/>
<InputText
label="Packets Quantity"
name="packets"
defaultValue={data.device ? data.device.packets : "3"}/>
</Collapsible>
<div class="flex gap-5 items-center">
<Button Icon={IconDeviceFloppy}>{data.device ? "Update" : "Create"}</Button>
{#if data.device}
<Button Icon={IconTrash} color="danger" inverted formaction="?/delete">Delete</Button>
{/if}
</div>
</form>
</ResourceEditPage>

View File

@ -1,16 +0,0 @@
import { db } from '$lib/server/db';
import { type ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals: { guard } }) => {
guard.requiresAdmin().orRedirects();
return {
groups: db.data.groups,
userCounts: db.data.groups.reduce(
(acc, group) => {
acc[group.id] = db.data.users.filter((u) => u.groups.includes(group.id)).length;
return acc;
},
{} as Record<string, number>,
),
};
};

View File

@ -1,33 +0,0 @@
<script lang="ts">
import ResourceCard from "$lib/components/resources/ResourceCard.svelte";
import ResourceListPage from "$lib/components/resources/ResourceListPage.svelte";
import Button from "$lib/components/ui/Button.svelte";
import IconEdit from "~icons/tabler/edit";
import IconPlus from "~icons/tabler/plus";
let { data } = $props();
</script>
<ResourceListPage heading="Groups">
{#snippet actionSnippet()}
<Button Icon={IconPlus} a href="/dashboard/groups/new">Add Group</Button>
{/snippet}
{#snippet contentSnippet()}
{#if data.groups.length === 0}
<p>No groups found</p>
{:else}
<div class="flex gap-4 flex-wrap">
{#each data.groups as group}
<ResourceCard title={group.name} subtitle="{data.userCounts[group.id]} users, {group.devices.length} devices">
{#snippet actionsSnippet()}
{#if data.user.admin}
<Button Icon={IconEdit} a href="/dashboard/groups/{group.id}" extra="!p-2"/>
{/if}
{/snippet}
</ResourceCard>
{/each}
</div>
{/if}
{/snippet}
</ResourceListPage>

View File

@ -1,85 +0,0 @@
import { db } from '$lib/server/db';
import { fail, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
import { nanoid } from 'nanoid';
import { z } from 'zod';
export const load: ServerLoad = async ({ locals: { guard }, params }) => {
guard.requiresAdmin().orRedirects();
const group = db.data.groups.find((g) => g.id === params.slug);
if (!group && params.slug !== 'new') {
redirect(302, '/dashboard/groups');
}
return {
devices: db.data.devices,
group,
};
};
export const actions: Actions = {
update: async ({ request, locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) return fail(403);
const form = await request.formData();
const schema = z.object({
name: z
.string({ message: 'Name is required.' })
.min(3, { message: 'Name must be at least 3 characters.' })
.max(24, { message: 'Name must be at most 24 characters.' }),
devices: z.array(
z.string().refine((v) => db.data.devices.find((d) => d.id === v), {
message: 'Invalid device ID.',
}),
),
});
const parsed = schema.safeParse({
name: form.get('name'),
devices: form.getAll('devices'),
});
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0].message });
}
if (params.slug === 'new') {
await db.update(({ groups }) => {
groups.push({
id: nanoid(),
name: parsed.data.name,
devices: parsed.data.devices,
});
});
redirect(302, '/dashboard/groups');
}
await db.update(({ groups }) => {
let group = groups.find((g) => g.id === params.slug);
if (!group) return;
group.name = parsed.data.name;
group.devices = parsed.data.devices;
});
redirect(302, '/dashboard/groups');
},
delete: async ({ locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) {
return fail(403);
}
db.data.groups = db.data.groups.filter((g) => g.id !== params.slug);
db.data.users.forEach((u) => {
u.groups = u.groups.filter((g) => g !== params.slug);
});
db.write();
redirect(302, '/dashboard/groups');
},
};

View File

@ -1,37 +0,0 @@
<script lang="ts">
import { enhance } from "$app/forms";
import InputSelect from "$lib/components/forms/InputSelect.svelte";
import InputText from "$lib/components/forms/InputText.svelte";
import ResourceEditPage from "$lib/components/resources/ResourceEditPage.svelte";
import Button from "$lib/components/ui/Button.svelte";
import IconDeviceFloppy from "~icons/tabler/device-floppy";
import IconTrash from "~icons/tabler/trash";
let { form, data } = $props();
</script>
<ResourceEditPage heading={data.group ? "Editing Group - " + data.group.name : "New Group"} error={form?.error}>
<form method="POST" action="?/update" use:enhance>
<InputText
label="Group Name"
sublabel="How the group will appear in the dashboard"
name="name"
defaultValue={data.group ? data.group.name : "New Group"}/>
<InputSelect
label="Devices"
sublabel="Devices that users who are part of this group will be ble to wake"
name="devices"
data={data.devices.map(d => ({
value: d.id.toString(),
name: d.name,
selected: data.group?.devices.find(gd => gd === d.id) ? true : false }))}/>
<div class="flex gap-5 items-center">
<Button Icon={IconDeviceFloppy}>{data.group ? "Update" : "Create"}</Button>
{#if data.group}
<Button Icon={IconTrash} color="danger" inverted formaction="?/delete">Delete</Button>
{/if}
</div>
</form>
</ResourceEditPage>

View File

@ -1,16 +0,0 @@
import { db } from '$lib/server/db';
import type { User } from '$lib/server/db/types/user';
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals: { guard } }) => {
guard.requiresAdmin().orRedirects();
return {
users: db.data.users.map((u) => {
let safeUser = structuredClone(u) as Partial<User>;
delete safeUser['password'];
return safeUser;
}),
};
};

View File

@ -1,27 +0,0 @@
<script lang="ts">
import ResourceCard from "$lib/components/resources/ResourceCard.svelte";
import ResourceListPage from "$lib/components/resources/ResourceListPage.svelte";
import Button from "$lib/components/ui/Button.svelte";
import IconEdit from "~icons/tabler/edit";
import IconPlus from "~icons/tabler/plus";
let { data } = $props();
</script>
<ResourceListPage heading="Users">
{#snippet actionSnippet()}
<Button Icon={IconPlus} a href="/dashboard/users/new">Add User</Button>
{/snippet}
{#snippet contentSnippet()}
<div class="flex gap-4 flex-wrap">
{#each data.users as user}
<ResourceCard title={user.name} subtitle="{user.groups!.length} groups, {user.devices!.length} devices">
{#snippet actionsSnippet()}
<Button Icon={IconEdit} a href="/dashboard/users/{user.id}" extra="!p-2"/>
{/snippet}
</ResourceCard>
{/each}
</div>
{/snippet}
</ResourceListPage>

View File

@ -1,113 +0,0 @@
import { db } from '$lib/server/db';
import type { User } from '$lib/server/db/types/user.js';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import { z } from 'zod';
export const load = async ({ locals: { guard }, params }) => {
guard.requiresAdmin().orRedirects();
let user = db.data.users.find((u) => u.id === params.slug) as Partial<User>;
if (!user && params.slug !== 'new') {
redirect(302, '/dashboard/users');
}
if (user) {
user = structuredClone(user);
delete user['password'];
}
return {
user,
groups: db.data.groups,
devices: db.data.devices,
};
};
export const actions: Actions = {
update: async ({ request, locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) return fail(403);
const form = await request.formData();
const schema = z.object({
name: z
.string({ message: 'Name is required.' })
.min(3, { message: 'Name must be at least 3 characters.' })
.max(24, { message: 'Name must be at most 24 characters.' }),
admin: z.boolean(),
password: z
.string()
.optional()
.refine((v) => (params.slug === 'new' ? v && v.length > 0 : true), {
message: 'Password is required at user creation.',
})
.refine((v) => !v || v.length >= 8, {
message: 'Password must be at least 8 characters.',
}),
groups: z.array(
z.string().refine((v) => db.data.groups.find((g) => g.id === v), {
message: 'Invalid group ID.',
}),
),
devices: z.array(
z.string().refine((v) => db.data.devices.find((d) => d.id === v), {
message: 'Invalid device ID.',
}),
),
});
const parsed = schema.safeParse({
name: form.get('name'),
admin: form.get('admin') === 'on',
password: form.get('password'),
groups: form.getAll('groups'),
devices: form.getAll('devices'),
});
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0].message });
}
if (params.slug === 'new') {
await db.update(({ users }) => {
users.push({
id: nanoid(),
name: parsed.data.name,
admin: parsed.data.admin,
groups: parsed.data.groups,
devices: parsed.data.devices,
password: bcrypt.hashSync(parsed.data.password!, 10),
});
});
redirect(302, '/dashboard/users');
}
await db.update(({ users }) => {
let user = users.find((u) => u.id === params.slug);
if (!user) return;
user.name = parsed.data.name;
user.admin = parsed.data.admin;
user.groups = parsed.data.groups;
user.devices = parsed.data.devices;
if (parsed.data.password && parsed.data.password.length > 0) {
user.password = bcrypt.hashSync(parsed.data.password, 10);
}
});
redirect(302, '/dashboard/users');
},
delete: async ({ locals: { guard }, params }) => {
if (guard.requiresAdmin().isFailed()) {
return fail(403);
}
db.data.users = db.data.users.filter((u) => u.id !== params.slug);
db.write();
},
};

View File

@ -1,60 +0,0 @@
<script lang="ts">
import { enhance } from "$app/forms";
import InputSelect from "$lib/components/forms/InputSelect.svelte";
import InputText from "$lib/components/forms/InputText.svelte";
import InputToggle from "$lib/components/forms/InputToggle.svelte";
import ResourceEditPage from "$lib/components/resources/ResourceEditPage.svelte";
import Button from "$lib/components/ui/Button.svelte";
import IconDeviceFloppy from "~icons/tabler/device-floppy";
import IconTrash from "~icons/tabler/trash";
let { data, form } = $props();
</script>
<ResourceEditPage heading={data.user ? "Editing User - " + data.user.name : "New User"} error={form?.error}>
<form method="POST" action="?/update" use:enhance>
<InputText
label="User Name"
sublabel="How the user will appear in the dashboard"
name="name"
defaultValue={data.user ? data.user.name : "New User"}/>
<InputText
label="Password"
sublabel="Leave empty for no change"
name="password"
type="password"
defaultValue=""/>
<InputToggle
label="Admin"
name="admin"
sublabel="Can manage and use all devices, groups and users"
checked={data.user?.admin ?? false}/>
<InputSelect
label="Groups"
name="groups"
sublabel="Groups that the user will be a part of and inherit devices access from"
data={data.groups.map(g => ({
value: g.id.toString(),
name: g.name,
selected: data.user?.groups?.find(ug => ug === g.id) ? true : false }))}/>
<InputSelect
label="Devices"
name="devices"
sublabel="Devices that the user can wake"
data={data.devices.map(d => ({
value: d.id.toString(),
name: d.name,
selected: data.user?.devices?.find(ud => ud === d.id) ? true : false }))}/>
<div class="flex gap-5 items-center">
<Button Icon={IconDeviceFloppy}>{data.user ? "Update" : "Create"}</Button>
{#if data.user}
<Button Icon={IconTrash} color="danger" inverted formaction="?/delete">Delete</Button>
{/if}
</div>
</form>
</ResourceEditPage>

View File

@ -6,9 +6,9 @@ import type { Actions } from './$types';
import { dev } from '$app/environment';
export const actions = {
default: async ({ cookies, request }) => {
if (getUserFromSession(cookies.get('session'))) {
redirect(302, '/dashboard');
default: async ({ cookies, request, locals: { guard } }) => {
if (!guard.requiresAuth().isFailed()) {
redirect(302, '/dash');
}
const data = await request.formData();