diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index aacf7b5..3c19668 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -7,7 +7,12 @@
"Bash(go run:*)",
"Bash(go build:*)",
"Bash(go doc:*)",
- "Bash(go test:*)"
+ "Bash(go test:*)",
+ "WebFetch(domain:lucia-auth.com)",
+ "WebFetch(domain:v3.lucia-auth.com)",
+ "Bash(bun install:*)",
+ "Bash(bunx svelte-kit sync:*)",
+ "Bash(bun run check:*)"
]
}
}
diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md
index 113e845..2e3c66b 100644
--- a/DOCUMENTATION.md
+++ b/DOCUMENTATION.md
@@ -700,6 +700,12 @@ A web dashboard (`dashboard/` directory) for browsing, triaging, and downloading
- Individual file download and bulk ZIP download (all files + report metadata)
- Report deletion with confirmation dialog
- Dark mode UI matching EMLy's aesthetic
+- **Authentication**: Session-based auth with Lucia v3 + Drizzle ORM adapter
+ - Default admin account: username `admin`, password `admin` (seeded on first migration)
+ - Password hashing with argon2 via `@node-rs/argon2`
+ - Session cookies with automatic refresh
+ - Role-based access: `admin` and `user` roles
+- **User Management**: Admin-only `/users` page for creating/deleting dashboard users
- **Development**: `cd dashboard && bun install && bun dev` (localhost:3001)
#### Configuration (config.ini)
diff --git a/config.ini b/config.ini
index 839e7a9..2d8dec6 100644
--- a/config.ini
+++ b/config.ini
@@ -1,11 +1,11 @@
[EMLy]
-SDK_DECODER_SEMVER = 1.3.2
-SDK_DECODER_RELEASE_CHANNEL = stable
-GUI_SEMVER = 1.5.4
+SDK_DECODER_SEMVER = 1.4.1
+SDK_DECODER_RELEASE_CHANNEL = beta
+GUI_SEMVER = 1.5.5
GUI_RELEASE_CHANNEL = beta
LANGUAGE = it
UPDATE_CHECK_ENABLED = false
UPDATE_PATH =
-UPDATE_AUTO_CHECK = true
+UPDATE_AUTO_CHECK = false
BUGREPORT_API_URL = "https://api.whiskr.it"
BUGREPORT_API_KEY = "emly_1BaQdBknsMGcY5DynSby71JnWOKXtJvnuUprkgWT0pujpLFxj5HaTXP9vtJAMk63"
\ No newline at end of file
diff --git a/server/dashboard/components.json b/server/dashboard/components.json
new file mode 100644
index 0000000..e44a4eb
--- /dev/null
+++ b/server/dashboard/components.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://shadcn-svelte.com/schema.json",
+ "tailwind": {
+ "css": "src\\app.css",
+ "baseColor": "neutral"
+ },
+ "aliases": {
+ "components": "$lib/components",
+ "utils": "$lib/utils",
+ "ui": "$lib/components/ui",
+ "hooks": "$lib/hooks",
+ "lib": "$lib"
+ },
+ "typescript": true,
+ "registry": "https://shadcn-svelte.com/registry"
+}
diff --git a/server/dashboard/package.json b/server/dashboard/package.json
index 1b67641..eafdd10 100644
--- a/server/dashboard/package.json
+++ b/server/dashboard/package.json
@@ -9,24 +9,31 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
+ "@internationalized/date": "^3.10.0",
+ "@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/kit": "^2.51.0",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.2.3",
+ "drizzle-kit": "^0.31.9",
"svelte": "^5.51.1",
"svelte-check": "^4.4.0",
"tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^6.4.1"
},
"dependencies": {
+ "@lucia-auth/adapter-drizzle": "^1.1.0",
+ "@node-rs/argon2": "^2.0.2",
"drizzle-orm": "^0.38.4",
+ "lucia": "^3.2.2",
"mysql2": "^3.17.1",
- "bits-ui": "^1.8.0",
+ "bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"tailwind-merge": "^3.4.0",
- "tailwind-variants": "^0.3.1",
+ "tailwind-variants": "^3.2.2",
"jszip": "^3.10.1",
"lucide-svelte": "^0.469.0"
},
diff --git a/server/dashboard/src/app.css b/server/dashboard/src/app.css
index 2ac6589..ac256d3 100644
--- a/server/dashboard/src/app.css
+++ b/server/dashboard/src/app.css
@@ -1,42 +1,121 @@
@import "tailwindcss";
+@import "tw-animate-css";
+
@custom-variant dark (&:is(.dark *));
-@theme {
- --color-background: hsl(222.2 84% 4.9%);
- --color-foreground: hsl(210 40% 98%);
- --color-card: hsl(222.2 84% 4.9%);
- --color-card-foreground: hsl(210 40% 98%);
- --color-popover: hsl(222.2 84% 4.9%);
- --color-popover-foreground: hsl(210 40% 98%);
- --color-primary: hsl(217.2 91.2% 59.8%);
- --color-primary-foreground: hsl(222.2 47.4% 11.2%);
- --color-secondary: hsl(217.2 32.6% 17.5%);
- --color-secondary-foreground: hsl(210 40% 98%);
- --color-muted: hsl(217.2 32.6% 17.5%);
- --color-muted-foreground: hsl(215 20.2% 65.1%);
- --color-accent: hsl(217.2 32.6% 17.5%);
- --color-accent-foreground: hsl(210 40% 98%);
- --color-destructive: hsl(0 62.8% 30.6%);
- --color-destructive-foreground: hsl(210 40% 98%);
- --color-border: hsl(217.2 32.6% 17.5%);
- --color-input: hsl(217.2 32.6% 17.5%);
- --color-ring: hsl(224.3 76.3% 48%);
- --radius: 0.5rem;
-
- --color-sidebar: hsl(222.2 84% 3.5%);
- --color-sidebar-foreground: hsl(210 40% 98%);
- --color-sidebar-border: hsl(217.2 32.6% 12%);
-
- --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
}
-* {
- border-color: var(--color-border);
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
}
-body {
- background-color: var(--color-background);
- color: var(--color-foreground);
- font-family: var(--font-sans);
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
\ No newline at end of file
diff --git a/server/dashboard/src/app.d.ts b/server/dashboard/src/app.d.ts
new file mode 100644
index 0000000..c0087e0
--- /dev/null
+++ b/server/dashboard/src/app.d.ts
@@ -0,0 +1,10 @@
+declare global {
+ namespace App {
+ interface Locals {
+ user: import('lucia').User | null;
+ session: import('lucia').Session | null;
+ }
+ }
+}
+
+export {};
diff --git a/server/dashboard/src/hooks.server.ts b/server/dashboard/src/hooks.server.ts
new file mode 100644
index 0000000..474ba4a
--- /dev/null
+++ b/server/dashboard/src/hooks.server.ts
@@ -0,0 +1,34 @@
+import type { Handle } from '@sveltejs/kit';
+import { lucia } from '$lib/server/auth';
+
+export const handle: Handle = async ({ event, resolve }) => {
+ const sessionId = event.cookies.get(lucia.sessionCookieName);
+
+ if (!sessionId) {
+ event.locals.user = null;
+ event.locals.session = null;
+ return resolve(event);
+ }
+
+ const { session, user } = await lucia.validateSession(sessionId);
+
+ if (session && session.fresh) {
+ const sessionCookie = lucia.createSessionCookie(session.id);
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+ }
+
+ if (!session) {
+ const sessionCookie = lucia.createBlankSessionCookie();
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+ }
+
+ event.locals.user = user;
+ event.locals.session = session;
+ return resolve(event);
+};
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
new file mode 100644
index 0000000..a005691
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
new file mode 100644
index 0000000..a7b0cf7
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
new file mode 100644
index 0000000..236bcad
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
new file mode 100644
index 0000000..2ec67dc
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
new file mode 100644
index 0000000..f78b97a
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
new file mode 100644
index 0000000..1835d91
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
new file mode 100644
index 0000000..a64ee76
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte
new file mode 100644
index 0000000..f0a19a8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
new file mode 100644
index 0000000..7ef2b5f
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
new file mode 100644
index 0000000..b22d1d5
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog.svelte
new file mode 100644
index 0000000..7ea78bb
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/index.ts b/server/dashboard/src/lib/components/ui/alert-dialog/index.ts
new file mode 100644
index 0000000..269538e
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/index.ts
@@ -0,0 +1,37 @@
+import Root from "./alert-dialog.svelte";
+import Portal from "./alert-dialog-portal.svelte";
+import Trigger from "./alert-dialog-trigger.svelte";
+import Title from "./alert-dialog-title.svelte";
+import Action from "./alert-dialog-action.svelte";
+import Cancel from "./alert-dialog-cancel.svelte";
+import Footer from "./alert-dialog-footer.svelte";
+import Header from "./alert-dialog-header.svelte";
+import Overlay from "./alert-dialog-overlay.svelte";
+import Content from "./alert-dialog-content.svelte";
+import Description from "./alert-dialog-description.svelte";
+
+export {
+ Root,
+ Title,
+ Action,
+ Cancel,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ //
+ Root as AlertDialog,
+ Title as AlertDialogTitle,
+ Action as AlertDialogAction,
+ Cancel as AlertDialogCancel,
+ Portal as AlertDialogPortal,
+ Footer as AlertDialogFooter,
+ Header as AlertDialogHeader,
+ Trigger as AlertDialogTrigger,
+ Overlay as AlertDialogOverlay,
+ Content as AlertDialogContent,
+ Description as AlertDialogDescription,
+};
diff --git a/server/dashboard/src/lib/components/ui/button/button.svelte b/server/dashboard/src/lib/components/ui/button/button.svelte
new file mode 100644
index 0000000..a8296ae
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/button/button.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+{#if href}
+
+ {@render children?.()}
+
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/button/index.ts b/server/dashboard/src/lib/components/ui/button/index.ts
new file mode 100644
index 0000000..fb585d7
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/button/index.ts
@@ -0,0 +1,17 @@
+import Root, {
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants,
+} from "./button.svelte";
+
+export {
+ Root,
+ type ButtonProps as Props,
+ //
+ Root as Button,
+ buttonVariants,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+};
diff --git a/server/dashboard/src/lib/components/ui/card/card-action.svelte b/server/dashboard/src/lib/components/ui/card/card-action.svelte
new file mode 100644
index 0000000..cc36c56
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-action.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-content.svelte b/server/dashboard/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..bc90b83
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,15 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-description.svelte b/server/dashboard/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..9b20ac7
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-footer.svelte b/server/dashboard/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..2d4d0f2
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-header.svelte b/server/dashboard/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..2501788
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-title.svelte b/server/dashboard/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..7447231
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card.svelte b/server/dashboard/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..99448cc
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/index.ts b/server/dashboard/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..4d3fce4
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/index.ts
@@ -0,0 +1,25 @@
+import Root from "./card.svelte";
+import Content from "./card-content.svelte";
+import Description from "./card-description.svelte";
+import Footer from "./card-footer.svelte";
+import Header from "./card-header.svelte";
+import Title from "./card-title.svelte";
+import Action from "./card-action.svelte";
+
+export {
+ Root,
+ Content,
+ Description,
+ Footer,
+ Header,
+ Title,
+ Action,
+ //
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle,
+ Action as CardAction,
+};
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte
new file mode 100644
index 0000000..840b2f6
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte
new file mode 100644
index 0000000..5c6ee6d
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+ {@render children?.()}
+ {#if showCloseButton}
+
+
+ Close
+
+ {/if}
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte
new file mode 100644
index 0000000..3845023
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte
new file mode 100644
index 0000000..e7ff446
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte
new file mode 100644
index 0000000..4e5c447
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte
new file mode 100644
index 0000000..f81ad83
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte
new file mode 100644
index 0000000..ccfa79c
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte
new file mode 100644
index 0000000..e4d4b34
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte
new file mode 100644
index 0000000..9d1e801
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog.svelte
new file mode 100644
index 0000000..211672c
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/index.ts b/server/dashboard/src/lib/components/ui/dialog/index.ts
new file mode 100644
index 0000000..076cef5
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/index.ts
@@ -0,0 +1,34 @@
+import Root from "./dialog.svelte";
+import Portal from "./dialog-portal.svelte";
+import Title from "./dialog-title.svelte";
+import Footer from "./dialog-footer.svelte";
+import Header from "./dialog-header.svelte";
+import Overlay from "./dialog-overlay.svelte";
+import Content from "./dialog-content.svelte";
+import Description from "./dialog-description.svelte";
+import Trigger from "./dialog-trigger.svelte";
+import Close from "./dialog-close.svelte";
+
+export {
+ Root,
+ Title,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ Close,
+ //
+ Root as Dialog,
+ Title as DialogTitle,
+ Portal as DialogPortal,
+ Footer as DialogFooter,
+ Header as DialogHeader,
+ Trigger as DialogTrigger,
+ Overlay as DialogOverlay,
+ Content as DialogContent,
+ Description as DialogDescription,
+ Close as DialogClose,
+};
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-content.svelte b/server/dashboard/src/lib/components/ui/empty/empty-content.svelte
new file mode 100644
index 0000000..f5a9c68
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-content.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-description.svelte b/server/dashboard/src/lib/components/ui/empty/empty-description.svelte
new file mode 100644
index 0000000..85a866c
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-description.svelte
@@ -0,0 +1,23 @@
+
+
+a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-header.svelte b/server/dashboard/src/lib/components/ui/empty/empty-header.svelte
new file mode 100644
index 0000000..296eaf8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-media.svelte b/server/dashboard/src/lib/components/ui/empty/empty-media.svelte
new file mode 100644
index 0000000..0b4e45d
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-media.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-title.svelte b/server/dashboard/src/lib/components/ui/empty/empty-title.svelte
new file mode 100644
index 0000000..8c237aa
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-title.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty.svelte b/server/dashboard/src/lib/components/ui/empty/empty.svelte
new file mode 100644
index 0000000..4ccf060
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/index.ts b/server/dashboard/src/lib/components/ui/empty/index.ts
new file mode 100644
index 0000000..ae4c106
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/index.ts
@@ -0,0 +1,22 @@
+import Root from "./empty.svelte";
+import Header from "./empty-header.svelte";
+import Media from "./empty-media.svelte";
+import Title from "./empty-title.svelte";
+import Description from "./empty-description.svelte";
+import Content from "./empty-content.svelte";
+
+export {
+ Root,
+ Header,
+ Media,
+ Title,
+ Description,
+ Content,
+ //
+ Root as Empty,
+ Header as EmptyHeader,
+ Media as EmptyMedia,
+ Title as EmptyTitle,
+ Description as EmptyDescription,
+ Content as EmptyContent,
+};
diff --git a/server/dashboard/src/lib/components/ui/input/index.ts b/server/dashboard/src/lib/components/ui/input/index.ts
new file mode 100644
index 0000000..f47b6d3
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/input/index.ts
@@ -0,0 +1,7 @@
+import Root from "./input.svelte";
+
+export {
+ Root,
+ //
+ Root as Input,
+};
diff --git a/server/dashboard/src/lib/components/ui/input/input.svelte b/server/dashboard/src/lib/components/ui/input/input.svelte
new file mode 100644
index 0000000..ff1a4c8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/input/input.svelte
@@ -0,0 +1,52 @@
+
+
+{#if type === "file"}
+
+{:else}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/label/index.ts b/server/dashboard/src/lib/components/ui/label/index.ts
new file mode 100644
index 0000000..8bfca0b
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/label/index.ts
@@ -0,0 +1,7 @@
+import Root from "./label.svelte";
+
+export {
+ Root,
+ //
+ Root as Label,
+};
diff --git a/server/dashboard/src/lib/components/ui/label/label.svelte b/server/dashboard/src/lib/components/ui/label/label.svelte
new file mode 100644
index 0000000..d71afbc
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/label/label.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/index.ts b/server/dashboard/src/lib/components/ui/select/index.ts
new file mode 100644
index 0000000..4dec358
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/index.ts
@@ -0,0 +1,37 @@
+import Root from "./select.svelte";
+import Group from "./select-group.svelte";
+import Label from "./select-label.svelte";
+import Item from "./select-item.svelte";
+import Content from "./select-content.svelte";
+import Trigger from "./select-trigger.svelte";
+import Separator from "./select-separator.svelte";
+import ScrollDownButton from "./select-scroll-down-button.svelte";
+import ScrollUpButton from "./select-scroll-up-button.svelte";
+import GroupHeading from "./select-group-heading.svelte";
+import Portal from "./select-portal.svelte";
+
+export {
+ Root,
+ Group,
+ Label,
+ Item,
+ Content,
+ Trigger,
+ Separator,
+ ScrollDownButton,
+ ScrollUpButton,
+ GroupHeading,
+ Portal,
+ //
+ Root as Select,
+ Group as SelectGroup,
+ Label as SelectLabel,
+ Item as SelectItem,
+ Content as SelectContent,
+ Trigger as SelectTrigger,
+ Separator as SelectSeparator,
+ ScrollDownButton as SelectScrollDownButton,
+ ScrollUpButton as SelectScrollUpButton,
+ GroupHeading as SelectGroupHeading,
+ Portal as SelectPortal,
+};
diff --git a/server/dashboard/src/lib/components/ui/select/select-content.svelte b/server/dashboard/src/lib/components/ui/select/select-content.svelte
new file mode 100644
index 0000000..4b9ca43
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-content.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte b/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte
new file mode 100644
index 0000000..1fab5f0
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-group.svelte b/server/dashboard/src/lib/components/ui/select/select-group.svelte
new file mode 100644
index 0000000..a1f43bf
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-group.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-item.svelte b/server/dashboard/src/lib/components/ui/select/select-item.svelte
new file mode 100644
index 0000000..b85eef6
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-item.svelte
@@ -0,0 +1,38 @@
+
+
+
+ {#snippet children({ selected, highlighted })}
+
+ {#if selected}
+
+ {/if}
+
+ {#if childrenProp}
+ {@render childrenProp({ selected, highlighted })}
+ {:else}
+ {label || value}
+ {/if}
+ {/snippet}
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-label.svelte b/server/dashboard/src/lib/components/ui/select/select-label.svelte
new file mode 100644
index 0000000..4696025
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-label.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-portal.svelte b/server/dashboard/src/lib/components/ui/select/select-portal.svelte
new file mode 100644
index 0000000..424bcdd
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte b/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte
new file mode 100644
index 0000000..3629205
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte b/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte
new file mode 100644
index 0000000..1aa2300
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-separator.svelte b/server/dashboard/src/lib/components/ui/select/select-separator.svelte
new file mode 100644
index 0000000..0eac3eb
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-separator.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-trigger.svelte b/server/dashboard/src/lib/components/ui/select/select-trigger.svelte
new file mode 100644
index 0000000..dbb81df
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-trigger.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select.svelte b/server/dashboard/src/lib/components/ui/select/select.svelte
new file mode 100644
index 0000000..05eb663
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select.svelte
@@ -0,0 +1,11 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/separator/index.ts b/server/dashboard/src/lib/components/ui/separator/index.ts
new file mode 100644
index 0000000..82442d2
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/separator/index.ts
@@ -0,0 +1,7 @@
+import Root from "./separator.svelte";
+
+export {
+ Root,
+ //
+ Root as Separator,
+};
diff --git a/server/dashboard/src/lib/components/ui/separator/separator.svelte b/server/dashboard/src/lib/components/ui/separator/separator.svelte
new file mode 100644
index 0000000..f40999f
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/separator/separator.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/index.ts b/server/dashboard/src/lib/components/ui/sheet/index.ts
new file mode 100644
index 0000000..28d7da1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/index.ts
@@ -0,0 +1,34 @@
+import Root from "./sheet.svelte";
+import Portal from "./sheet-portal.svelte";
+import Trigger from "./sheet-trigger.svelte";
+import Close from "./sheet-close.svelte";
+import Overlay from "./sheet-overlay.svelte";
+import Content from "./sheet-content.svelte";
+import Header from "./sheet-header.svelte";
+import Footer from "./sheet-footer.svelte";
+import Title from "./sheet-title.svelte";
+import Description from "./sheet-description.svelte";
+
+export {
+ Root,
+ Close,
+ Trigger,
+ Portal,
+ Overlay,
+ Content,
+ Header,
+ Footer,
+ Title,
+ Description,
+ //
+ Root as Sheet,
+ Close as SheetClose,
+ Trigger as SheetTrigger,
+ Portal as SheetPortal,
+ Overlay as SheetOverlay,
+ Content as SheetContent,
+ Header as SheetHeader,
+ Footer as SheetFooter,
+ Title as SheetTitle,
+ Description as SheetDescription,
+};
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte
new file mode 100644
index 0000000..ae382c1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte
new file mode 100644
index 0000000..065fe04
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+ Close
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte
new file mode 100644
index 0000000..333b17a
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte
new file mode 100644
index 0000000..dd9ed84
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte
new file mode 100644
index 0000000..757a6a5
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte
new file mode 100644
index 0000000..345e197
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte
new file mode 100644
index 0000000..f3085a3
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte
new file mode 100644
index 0000000..9fda327
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte
new file mode 100644
index 0000000..e266975
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet.svelte
new file mode 100644
index 0000000..5bf9783
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/constants.ts b/server/dashboard/src/lib/components/ui/sidebar/constants.ts
new file mode 100644
index 0000000..4de4435
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/constants.ts
@@ -0,0 +1,6 @@
+export const SIDEBAR_COOKIE_NAME = "sidebar:state";
+export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+export const SIDEBAR_WIDTH = "16rem";
+export const SIDEBAR_WIDTH_MOBILE = "18rem";
+export const SIDEBAR_WIDTH_ICON = "3rem";
+export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
diff --git a/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts b/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts
new file mode 100644
index 0000000..15248ad
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts
@@ -0,0 +1,81 @@
+import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
+import { getContext, setContext } from "svelte";
+import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
+
+type Getter = () => T;
+
+export type SidebarStateProps = {
+ /**
+ * A getter function that returns the current open state of the sidebar.
+ * We use a getter function here to support `bind:open` on the `Sidebar.Provider`
+ * component.
+ */
+ open: Getter;
+
+ /**
+ * A function that sets the open state of the sidebar. To support `bind:open`, we need
+ * a source of truth for changing the open state to ensure it will be synced throughout
+ * the sub-components and any `bind:` references.
+ */
+ setOpen: (open: boolean) => void;
+};
+
+class SidebarState {
+ readonly props: SidebarStateProps;
+ open = $derived.by(() => this.props.open());
+ openMobile = $state(false);
+ setOpen: SidebarStateProps["setOpen"];
+ #isMobile: IsMobile;
+ state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
+
+ constructor(props: SidebarStateProps) {
+ this.setOpen = props.setOpen;
+ this.#isMobile = new IsMobile();
+ this.props = props;
+ }
+
+ // Convenience getter for checking if the sidebar is mobile
+ // without this, we would need to use `sidebar.isMobile.current` everywhere
+ get isMobile() {
+ return this.#isMobile.current;
+ }
+
+ // Event handler to apply to the ``
+ handleShortcutKeydown = (e: KeyboardEvent) => {
+ if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ this.toggle();
+ }
+ };
+
+ setOpenMobile = (value: boolean) => {
+ this.openMobile = value;
+ };
+
+ toggle = () => {
+ return this.#isMobile.current
+ ? (this.openMobile = !this.openMobile)
+ : this.setOpen(!this.open);
+ };
+}
+
+const SYMBOL_KEY = "scn-sidebar";
+
+/**
+ * Instantiates a new `SidebarState` instance and sets it in the context.
+ *
+ * @param props The constructor props for the `SidebarState` class.
+ * @returns The `SidebarState` instance.
+ */
+export function setSidebar(props: SidebarStateProps): SidebarState {
+ return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
+}
+
+/**
+ * Retrieves the `SidebarState` instance from the context. This is a class instance,
+ * so you cannot destructure it.
+ * @returns The `SidebarState` instance.
+ */
+export function useSidebar(): SidebarState {
+ return getContext(Symbol.for(SYMBOL_KEY));
+}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/index.ts b/server/dashboard/src/lib/components/ui/sidebar/index.ts
new file mode 100644
index 0000000..318a341
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/index.ts
@@ -0,0 +1,75 @@
+import { useSidebar } from "./context.svelte.js";
+import Content from "./sidebar-content.svelte";
+import Footer from "./sidebar-footer.svelte";
+import GroupAction from "./sidebar-group-action.svelte";
+import GroupContent from "./sidebar-group-content.svelte";
+import GroupLabel from "./sidebar-group-label.svelte";
+import Group from "./sidebar-group.svelte";
+import Header from "./sidebar-header.svelte";
+import Input from "./sidebar-input.svelte";
+import Inset from "./sidebar-inset.svelte";
+import MenuAction from "./sidebar-menu-action.svelte";
+import MenuBadge from "./sidebar-menu-badge.svelte";
+import MenuButton from "./sidebar-menu-button.svelte";
+import MenuItem from "./sidebar-menu-item.svelte";
+import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
+import MenuSubButton from "./sidebar-menu-sub-button.svelte";
+import MenuSubItem from "./sidebar-menu-sub-item.svelte";
+import MenuSub from "./sidebar-menu-sub.svelte";
+import Menu from "./sidebar-menu.svelte";
+import Provider from "./sidebar-provider.svelte";
+import Rail from "./sidebar-rail.svelte";
+import Separator from "./sidebar-separator.svelte";
+import Trigger from "./sidebar-trigger.svelte";
+import Root from "./sidebar.svelte";
+
+export {
+ Content,
+ Footer,
+ Group,
+ GroupAction,
+ GroupContent,
+ GroupLabel,
+ Header,
+ Input,
+ Inset,
+ Menu,
+ MenuAction,
+ MenuBadge,
+ MenuButton,
+ MenuItem,
+ MenuSkeleton,
+ MenuSub,
+ MenuSubButton,
+ MenuSubItem,
+ Provider,
+ Rail,
+ Root,
+ Separator,
+ //
+ Root as Sidebar,
+ Content as SidebarContent,
+ Footer as SidebarFooter,
+ Group as SidebarGroup,
+ GroupAction as SidebarGroupAction,
+ GroupContent as SidebarGroupContent,
+ GroupLabel as SidebarGroupLabel,
+ Header as SidebarHeader,
+ Input as SidebarInput,
+ Inset as SidebarInset,
+ Menu as SidebarMenu,
+ MenuAction as SidebarMenuAction,
+ MenuBadge as SidebarMenuBadge,
+ MenuButton as SidebarMenuButton,
+ MenuItem as SidebarMenuItem,
+ MenuSkeleton as SidebarMenuSkeleton,
+ MenuSub as SidebarMenuSub,
+ MenuSubButton as SidebarMenuSubButton,
+ MenuSubItem as SidebarMenuSubItem,
+ Provider as SidebarProvider,
+ Rail as SidebarRail,
+ Separator as SidebarSeparator,
+ Trigger as SidebarTrigger,
+ Trigger,
+ useSidebar,
+};
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte
new file mode 100644
index 0000000..f121800
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte
new file mode 100644
index 0000000..6259cb9
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte
new file mode 100644
index 0000000..a76dfe1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte
@@ -0,0 +1,36 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte
new file mode 100644
index 0000000..415255f
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte
new file mode 100644
index 0000000..b2e72b6
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte
@@ -0,0 +1,34 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte
new file mode 100644
index 0000000..ec18a69
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte
new file mode 100644
index 0000000..a1b2db1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte
new file mode 100644
index 0000000..19b3666
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte
new file mode 100644
index 0000000..7d6d459
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
new file mode 100644
index 0000000..d3fe295
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
new file mode 100644
index 0000000..e8ecdb4
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
new file mode 100644
index 0000000..0acd1ec
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
@@ -0,0 +1,103 @@
+
+
+
+
+{#snippet Button({ props }: { props?: Record })}
+ {@const mergedProps = mergeProps(buttonProps, props)}
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+
+ {@render children?.()}
+
+ {/if}
+{/snippet}
+
+{#if !tooltipContent}
+ {@render Button({})}
+{:else}
+
+
+ {#snippet child({ props })}
+ {@render Button({ props })}
+ {/snippet}
+
+
+ {#if typeof tooltipContent === "string"}
+ {tooltipContent}
+ {:else if tooltipContent}
+ {@render tooltipContent()}
+ {/if}
+
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
new file mode 100644
index 0000000..4db4453
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
new file mode 100644
index 0000000..68604e2
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
@@ -0,0 +1,36 @@
+
+
+
+ {#if showIcon}
+
+ {/if}
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
new file mode 100644
index 0000000..c8cd4ff
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
new file mode 100644
index 0000000..681d0f1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
new file mode 100644
index 0000000..76bd1d9
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte
new file mode 100644
index 0000000..946ccce
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte
new file mode 100644
index 0000000..5b0d0aa
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte
new file mode 100644
index 0000000..704d54f
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte
@@ -0,0 +1,36 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte
new file mode 100644
index 0000000..5a7deda
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte
new file mode 100644
index 0000000..1825182
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte
@@ -0,0 +1,35 @@
+
+
+ {
+ onclick?.(e);
+ sidebar.toggle();
+ }}
+ {...restProps}
+>
+
+ Toggle Sidebar
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte
new file mode 100644
index 0000000..bac55d8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte
@@ -0,0 +1,104 @@
+
+
+{#if collapsible === "none"}
+
+ {@render children?.()}
+
+{:else if sidebar.isMobile}
+ sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
+ {...restProps}
+ >
+
+
+{:else}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/skeleton/index.ts b/server/dashboard/src/lib/components/ui/skeleton/index.ts
new file mode 100644
index 0000000..186db21
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/skeleton/index.ts
@@ -0,0 +1,7 @@
+import Root from "./skeleton.svelte";
+
+export {
+ Root,
+ //
+ Root as Skeleton,
+};
diff --git a/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte b/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte
new file mode 100644
index 0000000..c7e3d26
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/table/index.ts b/server/dashboard/src/lib/components/ui/table/index.ts
new file mode 100644
index 0000000..14695c8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/index.ts
@@ -0,0 +1,28 @@
+import Root from "./table.svelte";
+import Body from "./table-body.svelte";
+import Caption from "./table-caption.svelte";
+import Cell from "./table-cell.svelte";
+import Footer from "./table-footer.svelte";
+import Head from "./table-head.svelte";
+import Header from "./table-header.svelte";
+import Row from "./table-row.svelte";
+
+export {
+ Root,
+ Body,
+ Caption,
+ Cell,
+ Footer,
+ Head,
+ Header,
+ Row,
+ //
+ Root as Table,
+ Body as TableBody,
+ Caption as TableCaption,
+ Cell as TableCell,
+ Footer as TableFooter,
+ Head as TableHead,
+ Header as TableHeader,
+ Row as TableRow,
+};
diff --git a/server/dashboard/src/lib/components/ui/table/table-body.svelte b/server/dashboard/src/lib/components/ui/table/table-body.svelte
new file mode 100644
index 0000000..29e9687
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-body.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-caption.svelte b/server/dashboard/src/lib/components/ui/table/table-caption.svelte
new file mode 100644
index 0000000..4696cff
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-caption.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-cell.svelte b/server/dashboard/src/lib/components/ui/table/table-cell.svelte
new file mode 100644
index 0000000..2c0c26a
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-cell.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-footer.svelte b/server/dashboard/src/lib/components/ui/table/table-footer.svelte
new file mode 100644
index 0000000..b9b14eb
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-footer.svelte
@@ -0,0 +1,20 @@
+
+
+tr]:last:border-b-0", className)}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-head.svelte b/server/dashboard/src/lib/components/ui/table/table-head.svelte
new file mode 100644
index 0000000..b67a6f9
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-head.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-header.svelte b/server/dashboard/src/lib/components/ui/table/table-header.svelte
new file mode 100644
index 0000000..f47d259
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-row.svelte b/server/dashboard/src/lib/components/ui/table/table-row.svelte
new file mode 100644
index 0000000..0df769e
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-row.svelte
@@ -0,0 +1,23 @@
+
+
+svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table.svelte b/server/dashboard/src/lib/components/ui/table/table.svelte
new file mode 100644
index 0000000..a334956
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/server/dashboard/src/lib/components/ui/textarea/index.ts b/server/dashboard/src/lib/components/ui/textarea/index.ts
new file mode 100644
index 0000000..ace797a
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/textarea/index.ts
@@ -0,0 +1,7 @@
+import Root from "./textarea.svelte";
+
+export {
+ Root,
+ //
+ Root as Textarea,
+};
diff --git a/server/dashboard/src/lib/components/ui/textarea/textarea.svelte b/server/dashboard/src/lib/components/ui/textarea/textarea.svelte
new file mode 100644
index 0000000..124e9d0
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/textarea/textarea.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/index.ts b/server/dashboard/src/lib/components/ui/tooltip/index.ts
new file mode 100644
index 0000000..1718604
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/index.ts
@@ -0,0 +1,19 @@
+import Root from "./tooltip.svelte";
+import Trigger from "./tooltip-trigger.svelte";
+import Content from "./tooltip-content.svelte";
+import Provider from "./tooltip-provider.svelte";
+import Portal from "./tooltip-portal.svelte";
+
+export {
+ Root,
+ Trigger,
+ Content,
+ Provider,
+ Portal,
+ //
+ Root as Tooltip,
+ Content as TooltipContent,
+ Trigger as TooltipTrigger,
+ Provider as TooltipProvider,
+ Portal as TooltipPortal,
+};
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte
new file mode 100644
index 0000000..2662522
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+ {@render children?.()}
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte
new file mode 100644
index 0000000..d234f7d
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte
new file mode 100644
index 0000000..8150bef
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte
new file mode 100644
index 0000000..1acdaa4
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte
new file mode 100644
index 0000000..0b0f9ce
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/hooks/is-mobile.svelte.ts b/server/dashboard/src/lib/hooks/is-mobile.svelte.ts
new file mode 100644
index 0000000..4829c00
--- /dev/null
+++ b/server/dashboard/src/lib/hooks/is-mobile.svelte.ts
@@ -0,0 +1,9 @@
+import { MediaQuery } from "svelte/reactivity";
+
+const DEFAULT_MOBILE_BREAKPOINT = 768;
+
+export class IsMobile extends MediaQuery {
+ constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
+ super(`max-width: ${breakpoint - 1}px`);
+ }
+}
diff --git a/server/dashboard/src/lib/schema.ts b/server/dashboard/src/lib/schema.ts
index c6557b5..9d946eb 100644
--- a/server/dashboard/src/lib/schema.ts
+++ b/server/dashboard/src/lib/schema.ts
@@ -6,6 +6,7 @@ import {
json,
mysqlEnum,
timestamp,
+ datetime,
customType
} from 'drizzle-orm/mysql-core';
@@ -49,6 +50,23 @@ export const bugReportFiles = mysqlTable('bug_report_files', {
created_at: timestamp('created_at').notNull().defaultNow()
});
+export const userTable = mysqlTable('user', {
+ id: varchar('id', { length: 255 }).primaryKey(),
+ username: varchar('username', { length: 255 }).notNull().unique(),
+ displayname: varchar('displayname', { length: 255 }).notNull().default(''),
+ passwordHash: varchar('password_hash', { length: 255 }).notNull(),
+ role: mysqlEnum('role', ['admin', 'user']).notNull().default('user'),
+ createdAt: timestamp('created_at').notNull().defaultNow()
+});
+
+export const sessionTable = mysqlTable('session', {
+ id: varchar('id', { length: 255 }).primaryKey(),
+ userId: varchar('user_id', { length: 255 })
+ .notNull()
+ .references(() => userTable.id),
+ expiresAt: datetime('expires_at').notNull()
+});
+
export type BugReport = typeof bugReports.$inferSelect;
export type BugReportFile = typeof bugReportFiles.$inferSelect;
export type BugReportStatus = BugReport['status'];
diff --git a/server/dashboard/src/lib/server/auth.ts b/server/dashboard/src/lib/server/auth.ts
new file mode 100644
index 0000000..590e6b0
--- /dev/null
+++ b/server/dashboard/src/lib/server/auth.ts
@@ -0,0 +1,36 @@
+import { Lucia } from 'lucia';
+import { DrizzleMySQLAdapter } from '@lucia-auth/adapter-drizzle';
+import { db } from './db';
+import { sessionTable, userTable } from '$lib/schema';
+import { dev } from '$app/environment';
+
+const adapter = new DrizzleMySQLAdapter(db, sessionTable, userTable);
+
+export const lucia = new Lucia(adapter, {
+ sessionCookie: {
+ attributes: {
+ secure: !dev
+ }
+ },
+ getUserAttributes: (attributes) => {
+ return {
+ username: attributes.username,
+ role: attributes.role,
+ displayname: attributes.displayname
+ };
+ }
+});
+
+declare module 'lucia' {
+ interface Register {
+ Lucia: typeof lucia;
+ DatabaseUserAttributes: DatabaseUserAttributes;
+ }
+}
+
+interface DatabaseUserAttributes {
+ username: string;
+ role: 'admin' | 'user';
+ displayname: string;
+}
+// End of file
diff --git a/server/dashboard/src/lib/utils.ts b/server/dashboard/src/lib/utils.ts
index 10803a7..3cebf9a 100644
--- a/server/dashboard/src/lib/utils.ts
+++ b/server/dashboard/src/lib/utils.ts
@@ -1,5 +1,5 @@
-import { type ClassValue, clsx } from 'clsx';
-import { twMerge } from 'tailwind-merge';
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -37,3 +37,10 @@ export const statusLabels: Record = {
resolved: 'Resolved',
closed: 'Closed'
};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChild = T extends { child?: any } ? Omit : T;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChildren = T extends { children?: any } ? Omit : T;
+export type WithoutChildrenOrChild = WithoutChildren>;
+export type WithElementRef = T & { ref?: U | null };
diff --git a/server/dashboard/src/routes/+layout.server.ts b/server/dashboard/src/routes/+layout.server.ts
index c3760d9..d95b760 100644
--- a/server/dashboard/src/routes/+layout.server.ts
+++ b/server/dashboard/src/routes/+layout.server.ts
@@ -1,15 +1,25 @@
import type { LayoutServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { bugReports } from '$lib/schema';
import { eq, count } from 'drizzle-orm';
-export const load: LayoutServerLoad = async () => {
+export const load: LayoutServerLoad = async ({ locals, url }) => {
+ if (url.pathname === '/login') {
+ return { newCount: 0, user: null };
+ }
+
+ if (!locals.user) {
+ redirect(302, '/login');
+ }
+
const [result] = await db
.select({ count: count() })
.from(bugReports)
.where(eq(bugReports.status, 'new'));
return {
- newCount: result.count
+ newCount: result.count,
+ user: locals.user
};
};
diff --git a/server/dashboard/src/routes/+layout.svelte b/server/dashboard/src/routes/+layout.svelte
index a97862d..56ada1d 100644
--- a/server/dashboard/src/routes/+layout.svelte
+++ b/server/dashboard/src/routes/+layout.svelte
@@ -1,61 +1,121 @@
-
-
-
-
-
-
+
+
+
+ Menu
+
+
+
+
+ {#snippet child({ props })}
+
+
+ Reports
+
+ {/snippet}
+
+
+ {#if data.user.role === 'admin'}
+
+
+ {#snippet child({ props })}
+
+
+ Users
+
+ {/snippet}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+{/if}
diff --git a/server/dashboard/src/routes/+page.svelte b/server/dashboard/src/routes/+page.svelte
index 44d914b..84b8900 100644
--- a/server/dashboard/src/routes/+page.svelte
+++ b/server/dashboard/src/routes/+page.svelte
@@ -2,13 +2,25 @@
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { statusColors, statusLabels, formatDate } from '$lib/utils';
- import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw } from 'lucide-svelte';
+ import { Search, ChevronLeft, ChevronRight, Filter, Paperclip, RefreshCcw, Inbox } from 'lucide-svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import * as Table from '$lib/components/ui/table';
+ import * as Select from '$lib/components/ui/select';
+ import * as Empty from '$lib/components/ui/empty';
let { data } = $props();
let searchInput = $state('');
let statusFilter = $state('');
+ const statusOptions = [
+ { value: 'new', label: 'New' },
+ { value: 'in_review', label: 'In Review' },
+ { value: 'resolved', label: 'Resolved' },
+ { value: 'closed', label: 'Closed' }
+ ];
+
$effect(() => {
searchInput = data.filters.search;
statusFilter = data.filters.status;
@@ -48,7 +60,7 @@
-
-
- All statuses
- New
- In Review
- Resolved
- Closed
-
-
+ applyFilters()}>
+
+ {statusOptions.find((o) => o.value === statusFilter)?.label || 'All statuses'}
+
+
+
+ {#each statusOptions as option}
+
+ {/each}
+
+
+
Filter
-
-
+
+
Refresh
-
+
{#if data.filters.search || data.filters.status}
-
+
Clear
-
+
{/if}
-
-
-
-
- ID
- Hostname
- User
- Reporter
- Status
- Files
- Created
-
-
-
- {#each data.reports as report (report.id)}
- goto(`/reports/${report.id}`)}
- >
- #{report.id}
- {report.hostname || '—'}
- {report.os_user || '—'}
-
- {report.name}
- {report.email}
-
-
-
- {statusLabels[report.status]}
-
-
-
- {#if report.file_count > 0}
-
-
- {report.file_count}
-
- {:else}
- —
- {/if}
-
- {formatDate(report.created_at)}
-
+ {#if data.reports.length === 0}
+
+
+
+
+
+
+ No reports found
+
+ There are no bug reports matching your current filters.
+
+
+ {#if data.filters.search || data.filters.status}
+
+
+ Clear Filters
+
+
{:else}
-
-
- No reports found.
-
-
- {/each}
-
-
-
+
+
+
+ Refresh
+
+
+ {/if}
+
+
+ {:else}
+
+
+
+
+ ID
+ Hostname
+ User
+ Reporter
+ Status
+ Files
+ Created
+
+
+
+ {#each data.reports as report (report.id)}
+ goto(`/reports/${report.id}`)}
+ >
+ #{report.id}
+ {report.hostname || '—'}
+ {report.os_user || '—'}
+
+ {report.name}
+ {report.email}
+
+
+
+ {statusLabels[report.status]}
+
+
+
+ {#if report.file_count > 0}
+
+
+ {report.file_count}
+
+ {:else}
+ —
+ {/if}
+
+
+ {formatDate(report.created_at)}
+
+
+ {/each}
+
+
+
+ {/if}
{#if data.pagination.totalPages > 1}
@@ -158,35 +189,35 @@
)} of {data.pagination.total} reports
- goToPage(data.pagination.page - 1)}
disabled={data.pagination.page <= 1}
- class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
>
-
+
{#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p}
{#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)}
- goToPage(p)}
- class="inline-flex h-9 w-9 items-center justify-center rounded-md text-sm {p ===
- data.pagination.page
- ? 'bg-primary text-primary-foreground'
- : 'border border-input hover:bg-accent'}"
>
{p}
-
+
{:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
...
{/if}
{/each}
- goToPage(data.pagination.page + 1)}
disabled={data.pagination.page >= data.pagination.totalPages}
- class="inline-flex items-center rounded-md border border-input p-2 text-sm hover:bg-accent disabled:opacity-50 disabled:pointer-events-none"
>
-
+
{/if}
diff --git a/server/dashboard/src/routes/login/+page.server.ts b/server/dashboard/src/routes/login/+page.server.ts
new file mode 100644
index 0000000..fc52c9c
--- /dev/null
+++ b/server/dashboard/src/routes/login/+page.server.ts
@@ -0,0 +1,59 @@
+import type { Actions, PageServerLoad } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import { verify } from '@node-rs/argon2';
+import { lucia } from '$lib/server/auth';
+import { db } from '$lib/server/db';
+import { userTable } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (locals.user) {
+ redirect(302, '/');
+ }
+};
+
+export const actions: Actions = {
+ default: async ({ request, cookies }) => {
+ const formData = await request.formData();
+ const username = formData.get('username');
+ const password = formData.get('password');
+
+ if (typeof username !== 'string' || typeof password !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (!username || !password) {
+ return fail(400, { message: 'Username and password are required' });
+ }
+
+ const [user] = await db
+ .select()
+ .from(userTable)
+ .where(eq(userTable.username, username))
+ .limit(1);
+
+ if (!user) {
+ return fail(400, { message: 'Invalid username or password' });
+ }
+
+ const validPassword = await verify(user.passwordHash, password, {
+ memoryCost: 19456,
+ timeCost: 2,
+ outputLen: 32,
+ parallelism: 1
+ });
+
+ if (!validPassword) {
+ return fail(400, { message: 'Invalid username or password' });
+ }
+
+ const session = await lucia.createSession(user.id, {});
+ const sessionCookie = lucia.createSessionCookie(session.id);
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+
+ redirect(302, '/');
+ }
+};
diff --git a/server/dashboard/src/routes/login/+page.svelte b/server/dashboard/src/routes/login/+page.svelte
new file mode 100644
index 0000000..2c74fee
--- /dev/null
+++ b/server/dashboard/src/routes/login/+page.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
EMLy Dashboard
+
Sign in to continue
+
+
+ {#if form?.message}
+
+ {form.message}
+
+ {/if}
+
+
+
+
diff --git a/server/dashboard/src/routes/logout/+page.server.ts b/server/dashboard/src/routes/logout/+page.server.ts
new file mode 100644
index 0000000..67b748e
--- /dev/null
+++ b/server/dashboard/src/routes/logout/+page.server.ts
@@ -0,0 +1,24 @@
+import type { Actions, PageServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
+import { lucia } from '$lib/server/auth';
+
+export const load: PageServerLoad = async () => {
+ redirect(302, '/');
+};
+
+export const actions: Actions = {
+ default: async ({ locals, cookies }) => {
+ if (!locals.session) {
+ redirect(302, '/login');
+ }
+
+ await lucia.invalidateSession(locals.session.id);
+ const sessionCookie = lucia.createBlankSessionCookie();
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+
+ redirect(302, '/login');
+ }
+};
diff --git a/server/dashboard/src/routes/reports/[id]/+page.svelte b/server/dashboard/src/routes/reports/[id]/+page.svelte
index eb43c0a..9a9e713 100644
--- a/server/dashboard/src/routes/reports/[id]/+page.svelte
+++ b/server/dashboard/src/routes/reports/[id]/+page.svelte
@@ -10,14 +10,17 @@
Monitor,
Settings,
Database,
- ChevronDown,
- ChevronRight,
Mail
} from 'lucide-svelte';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import * as Card from '$lib/components/ui/card';
+ import * as Select from '$lib/components/ui/select';
+ import * as Table from '$lib/components/ui/table';
+ import { Button } from '$lib/components/ui/button';
+ import { Textarea } from '$lib/components/ui/textarea';
let { data } = $props();
let showDeleteDialog = $state(false);
- let showSystemInfo = $state(false);
let statusUpdating = $state(false);
let deleting = $state(false);
@@ -64,216 +67,213 @@
-
+
Back to Reports
-
+
-
-
-
-
-
Report #{data.report.id}
-
- {statusLabels[data.report.status]}
-
+
+
+
+
+
+ Report #{data.report.id}
+
+ {statusLabels[data.report.status]}
+
+
+
+ Submitted by {data.report.name} ({data.report.email})
+
-
- Submitted by {data.report.name} ({data.report.email})
-
-
-
-
-
updateStatus(e.currentTarget.value)}
- disabled={statusUpdating}
- class="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
- >
- New
- In Review
- Resolved
- Closed
-
+
+
+
updateStatus(val)}
+ >
+
+ {statusLabels[data.report.status]}
+
+
+
+
+
+
+
+
-
-
-
- ZIP
-
+
+
+
+ ZIP
+
-
-
(showDeleteDialog = true)}
- class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground bg-destructive hover:bg-destructive/80"
- >
-
- Delete
-
+
+
(showDeleteDialog = true)}
+ >
+
+ Delete
+
+
-
-
-
-
-
-
Hostname
-
{data.report.hostname || '—'}
+
+
+
+
+
+
Hostname
+
{data.report.hostname || '—'}
+
+
+
OS User
+
{data.report.os_user || '—'}
+
+
+
HWID
+
{data.report.hwid || '—'}
+
+
+
IP Address
+
{data.report.submitter_ip || '—'}
+
+
+
Created
+
{formatDate(data.report.created_at)}
+
+
+
Updated
+
{formatDate(data.report.updated_at)}
+
-
-
OS User
-
{data.report.os_user || '—'}
-
-
-
HWID
-
{data.report.hwid || '—'}
-
-
-
IP Address
-
{data.report.submitter_ip || '—'}
-
-
-
Created
-
{formatDate(data.report.created_at)}
-
-
-
Updated
-
{formatDate(data.report.updated_at)}
-
-
-
+
+
-
-
Description
-
{data.report.description}
-
+
+
+ Description
+
+
+ {data.report.description}
+
+
{#if data.report.system_info}
-
-
(showSystemInfo = !showSystemInfo)}
- class="flex w-full items-center gap-2 px-6 py-4 text-left text-sm font-medium text-muted-foreground hover:text-foreground"
- >
- {#if showSystemInfo}
-
- {:else}
-
- {/if}
- System Information
-
- {#if showSystemInfo}
-
-
{data.report.system_info}
+
+
+ System Information
+
+
+
+
- {/if}
-
+
+
{/if}
-
-
-
- Attached Files ({data.files.length})
-
- {#if data.files.length > 0}
-
- {@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
- {#if screenshots.length > 0}
-
- {#each screenshots as file}
-
-
-
{file.filename}
-
- {/each}
-
- {/if}
-
-
-
-
- Role
- Filename
- Size
- Action
-
-
-
- {#each data.files as file}
- {@const Icon = roleIcons[file.file_role] || FileText}
-
-
-
-
- {roleLabels[file.file_role] || file.file_role}
-
-
- {file.filename}
- {formatBytes(file.file_size)}
-
-
-
- Download
-
-
-
+
+
+
+ Attached Files ({data.files.length})
+
+
+ {#if data.files.length > 0}
+
+ {@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
+ {#if screenshots.length > 0}
+
+ {#each screenshots as file}
+
+
+
{file.filename}
+
{/each}
-
-
-
- {:else}
-
No files attached.
- {/if}
-
+
+ {/if}
+
+
+
+
+
+ Role
+ Filename
+ Size
+ Action
+
+
+
+ {#each data.files as file}
+ {@const Icon = roleIcons[file.file_role] || FileText}
+
+
+
+
+ {roleLabels[file.file_role] || file.file_role}
+
+
+ {file.filename}
+ {formatBytes(file.file_size)}
+
+
+
+
+
+
+
+ {/each}
+
+
+
+ {:else}
+
No files attached.
+ {/if}
+
+
-{#if showDeleteDialog}
-
-
e.key === 'Escape' && (showDeleteDialog = false)}
- >
-
-
(showDeleteDialog = false)}>
-
-
Delete Report
-
+
+
+
+ Delete Report
+
Are you sure you want to delete report #{data.report.id}? This will permanently remove the
report and all attached files. This action cannot be undone.
-
-
- (showDeleteDialog = false)}
- class="rounded-md border border-input px-4 py-2 text-sm hover:bg-accent"
- >
- Cancel
-
-
- {deleting ? 'Deleting...' : 'Delete'}
-
-
-
-
-{/if}
+
+
+
+ (showDeleteDialog = false)}>Cancel
+
+ {deleting ? 'Deleting...' : 'Delete'}
+
+
+
+
diff --git a/server/dashboard/src/routes/users/+page.server.ts b/server/dashboard/src/routes/users/+page.server.ts
new file mode 100644
index 0000000..6399d53
--- /dev/null
+++ b/server/dashboard/src/routes/users/+page.server.ts
@@ -0,0 +1,215 @@
+import type { Actions, PageServerLoad } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import { hash } from '@node-rs/argon2';
+import { generateIdFromEntropySize } from 'lucia';
+import { db } from '$lib/server/db';
+import { userTable } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+const PASSWORD_MIN_LENGTH = 8;
+const PASSWORD_MAX_LENGTH = 255;
+
+function validatePassword(password: string): string | null {
+ if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) {
+ return `Password must be ${PASSWORD_MIN_LENGTH}-${PASSWORD_MAX_LENGTH} characters`;
+ }
+ if (!/[A-Z]/.test(password)) {
+ return 'Password must contain at least one uppercase letter';
+ }
+ if (!/[a-z]/.test(password)) {
+ return 'Password must contain at least one lowercase letter';
+ }
+ if (!/[0-9]/.test(password)) {
+ return 'Password must contain at least one number';
+ }
+ if (!/[^A-Za-z0-9]/.test(password)) {
+ return 'Password must contain at least one special character';
+ }
+ return null;
+}
+
+async function hashPassword(password: string): Promise
{
+ return hash(password, {
+ memoryCost: 19456,
+ timeCost: 2,
+ outputLen: 32,
+ parallelism: 1
+ });
+}
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ redirect(302, '/');
+ }
+
+ const users = await db
+ .select({
+ id: userTable.id,
+ username: userTable.username,
+ displayname: userTable.displayname,
+ role: userTable.role,
+ createdAt: userTable.createdAt
+ })
+ .from(userTable)
+ .orderBy(userTable.createdAt);
+
+ return { users };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const username = formData.get('username');
+ const displayname = formData.get('displayname') || '';
+ const password = formData.get('password');
+ const confirmPassword = formData.get('confirmPassword');
+ const role = formData.get('role');
+
+ if (
+ typeof username !== 'string' ||
+ typeof displayname !== 'string' ||
+ typeof password !== 'string' ||
+ typeof confirmPassword !== 'string' ||
+ typeof role !== 'string'
+ ) {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (!username || !password) {
+ return fail(400, { message: 'Username and password are required' });
+ }
+
+ if (username.length < 3 || username.length > 255) {
+ return fail(400, { message: 'Username must be 3-255 characters' });
+ }
+
+ if (password !== confirmPassword) {
+ return fail(400, { message: 'Passwords do not match' });
+ }
+
+ const passwordError = validatePassword(password);
+ if (passwordError) {
+ return fail(400, { message: passwordError });
+ }
+
+ if (role !== 'admin' && role !== 'user') {
+ return fail(400, { message: 'Invalid role' });
+ }
+
+ // Check if username already exists
+ const [existing] = await db
+ .select({ id: userTable.id })
+ .from(userTable)
+ .where(eq(userTable.username, username))
+ .limit(1);
+
+ if (existing) {
+ return fail(400, { message: 'Username already exists' });
+ }
+
+ const passwordHash = await hashPassword(password);
+ const userId = generateIdFromEntropySize(10);
+
+ await db.insert(userTable).values({
+ id: userId,
+ username,
+ displayname,
+ passwordHash,
+ role: role as 'admin' | 'user'
+ });
+
+ return { success: true };
+ },
+
+ updateDisplayname: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+ const displayname = formData.get('displayname');
+
+ if (typeof userId !== 'string' || typeof displayname !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ await db.update(userTable).set({ displayname }).where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ resetPassword: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId === 'string' && userId === locals.user.id) {
+ return fail(400, { message: 'Cannot reset your own password from here' });
+ }
+ const newPassword = formData.get('newPassword');
+ const confirmPassword = formData.get('confirmPassword');
+
+ if (
+ typeof userId !== 'string' ||
+ typeof newPassword !== 'string' ||
+ typeof confirmPassword !== 'string'
+ ) {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (newPassword !== confirmPassword) {
+ return fail(400, { message: 'Passwords do not match' });
+ }
+
+ const passwordError = validatePassword(newPassword);
+ if (passwordError) {
+ return fail(400, { message: passwordError });
+ }
+
+ const passwordHash = await hashPassword(newPassword);
+
+ await db.update(userTable).set({ passwordHash }).where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ delete: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (userId === locals.user.id) {
+ return fail(400, { message: 'Cannot delete your own account' });
+ }
+
+ // Prevent deleting admin users
+ const [targetUser] = await db
+ .select({ role: userTable.role })
+ .from(userTable)
+ .where(eq(userTable.id, userId))
+ .limit(1);
+
+ if (targetUser?.role === 'admin') {
+ return fail(400, { message: 'Cannot delete an admin user' });
+ }
+
+ await db.delete(userTable).where(eq(userTable.id, userId));
+
+ return { success: true };
+ }
+};
diff --git a/server/dashboard/src/routes/users/+page.svelte b/server/dashboard/src/routes/users/+page.svelte
new file mode 100644
index 0000000..2459172
--- /dev/null
+++ b/server/dashboard/src/routes/users/+page.svelte
@@ -0,0 +1,476 @@
+
+
+
+
(showCreateForm = true)}>
+
+ Create User
+
+
+
+
+
+
+
+ Username
+ Display Name
+ Role
+ Created
+ Actions
+
+
+
+ {#each data.users as user (user.id)}
+
+ {user.username}
+ {user.displayname || '—'}
+
+
+ {user.role}
+
+
+
+ {user.createdAt ? formatDate(user.createdAt) : '—'}
+
+
+ {#if user.id === data.user?.id}
+ Current user
+ {:else}
+
+
openResetDialog(user.id, user.username)}
+ title="Change Password"
+ >
+
+
+
openDisplaynameDialog(user)}
+ title="Change Display Name"
+ >
+
+
+ {#if user.id !== data.user?.id && data.user?.role === 'admin'}
+
openDeleteDialog(user)}
+ title="Delete User"
+ >
+
+
+ {/if}
+
+ {/if}
+
+
+ {:else}
+
+
+ No users found.
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ Create User
+ Add a new user account with role assignment
+
+
+
+
+
+
+
+
+
+
+ Reset Password
+
+ Set a new password for {resetUsername}
+
+
+
+
+
+
+
+
+
+
+
+ Update Display Name
+
+ Update display name for {selectedUser?.username}
+
+
+
+
+
+
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete the user account
+ {selectedUser?.username} and remove their
+ data from our servers.
+
+
+
+ (deleteDialogOpen = false)}>Cancel
+
+
+
+
diff --git a/server/package.json b/server/package.json
index 987b510..85edc65 100644
--- a/server/package.json
+++ b/server/package.json
@@ -7,6 +7,7 @@
"start": "bun run src/index.ts"
},
"dependencies": {
+ "@node-rs/argon2": "^2.0.2",
"elysia": "^1.2.0",
"mysql2": "^3.11.0"
},
diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts
index ad65da3..350680c 100644
--- a/server/src/db/migrate.ts
+++ b/server/src/db/migrate.ts
@@ -1,5 +1,7 @@
import { readFileSync } from "fs";
import { join } from "path";
+import { randomUUID } from "crypto";
+import { hash } from "@node-rs/argon2";
import { getPool } from "./connection";
export async function runMigrations(): Promise {
@@ -33,5 +35,23 @@ export async function runMigrations(): Promise {
}
}
+ // Seed default admin user if user table is empty
+ const [rows] = await pool.execute("SELECT COUNT(*) as count FROM `user`");
+ const userCount = (rows as Array<{ count: number }>)[0].count;
+ if (userCount === 0) {
+ const passwordHash = await hash("admin", {
+ memoryCost: 19456,
+ timeCost: 2,
+ outputLen: 32,
+ parallelism: 1
+ });
+ const id = randomUUID();
+ await pool.execute(
+ "INSERT INTO `user` (`id`, `username`, `password_hash`, `role`) VALUES (?, ?, ?, ?)",
+ [id, "admin", passwordHash, "admin"]
+ );
+ console.log("Default admin user created (username: admin, password: admin)");
+ }
+
console.log("Database migrations completed");
}
diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql
index 034406c..73b1726 100644
--- a/server/src/db/schema.sql
+++ b/server/src/db/schema.sql
@@ -36,3 +36,19 @@ CREATE TABLE IF NOT EXISTS `rate_limit_hwid` (
`window_start` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`count` INT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `user` (
+ `id` VARCHAR(255) PRIMARY KEY,
+ `username` VARCHAR(255) NOT NULL UNIQUE,
+ `password_hash` VARCHAR(255) NOT NULL,
+ `role` ENUM('admin', 'user') NOT NULL DEFAULT 'user',
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `displayname` VARCHAR(255) NOT NULL DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `session` (
+ `id` VARCHAR(255) PRIMARY KEY,
+ `user_id` VARCHAR(255) NOT NULL,
+ `expires_at` DATETIME NOT NULL,
+ CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;