deleted old dashboard
This commit is contained in:
parent
2ac2650f99
commit
5e3fb972e8
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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}
|
||||
@ -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>
|
||||
@ -1 +0,0 @@
|
||||
<div class="w-full h-px bg-neutral-200 dark:bg-neutral-700 my-4"></div>
|
||||
@ -27,7 +27,7 @@ export class Guard {
|
||||
}
|
||||
|
||||
if (this.adminRequired && !this.user?.admin) {
|
||||
redirect(302, '/dashboard');
|
||||
redirect(302, '/dash');
|
||||
}
|
||||
|
||||
return this;
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals: { guard } }) => {
|
||||
const user = guard.requiresAuth().orRedirects().getUser();
|
||||
|
||||
return {
|
||||
user,
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
};
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
@ -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>,
|
||||
),
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
@ -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;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user