& so it begins

This commit is contained in:
user
2026-02-28 14:50:04 +02:00
commit f00381f2b6
536 changed files with 26294 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import { auth } from "@pkg/logic/domains/auth/config.base";
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load = (async (c) => {
const sess = await auth.api.getSession({
headers: c.request.headers,
});
if ((!sess?.user || !sess?.session) && c.url.pathname !== "/auth/login") {
return redirect(302, "/auth/login");
}
return { user: c.locals.user, session: c.locals.session };
}) satisfies LayoutServerLoad;

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import AppSidebar from "$lib/components/app-sidebar.svelte";
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import { mainNavTree } from "$lib/core/constants";
import { breadcrumbs, session, user } from "$lib/global.stores";
import { onMount } from "svelte";
import type { LayoutData } from "./$types";
breadcrumbs.set([mainNavTree[0]]);
let { children, data }: { children: any; data: LayoutData } = $props();
onMount(() => {
if (data.user) {
user.set(data.user);
}
if (data.session) {
session.set(data.session);
}
});
</script>
<Sidebar.Provider>
<AppSidebar />
<Sidebar.Inset>
<header
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-16"
>
<div class="flex items-center gap-2 px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 h-4" />
<Breadcrumb.Root>
<Breadcrumb.List>
{#if $breadcrumbs.length > 0}
{#each $breadcrumbs as breadcrumb, i}
<Breadcrumb.Item class="hidden md:block">
<Breadcrumb.Link href={breadcrumb.url}>
{breadcrumb.title}
</Breadcrumb.Link>
</Breadcrumb.Item>
{#if i < $breadcrumbs.length - 1}
<Breadcrumb.Separator
class="hidden md:block"
/>
{/if}
{/each}
{/if}
</Breadcrumb.List>
</Breadcrumb.Root>
</div>
</header>
<main class="p-8 md:p-4">
{@render children()}
</main>
</Sidebar.Inset>
</Sidebar.Provider>

View File

@@ -0,0 +1,6 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load = (async (c) => {
throw redirect(302, "/dashboard");
}) satisfies PageServerLoad;

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { mainNavTree } from "$lib/core/constants";
import { breadcrumbs } from "$lib/global.stores";
import { makeClient } from "$lib/make-client.js";
import { onMount } from "svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
const client = makeClient(fetch);
breadcrumbs.set([mainNavTree[0]]);
onMount(() => {
goto("/dashboard");
});
</script>

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto, onNavigate } from "$app/navigation";
import { page } from "$app/state";
import Icon from "$lib/components/atoms/icon.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import * as Sheet from "$lib/components/ui/sheet/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import { secondaryNavTree } from "$lib/core/constants";
import Menu from "@lucide/svelte/icons/menu";
import { onMount } from "svelte";
let { children } = $props();
let chosen = $state(secondaryNavTree[0].url);
let sheetOpen = $state(false);
let isMobile = $state(false);
// Check if mobile on initial load and set up resize listener
function updateIsMobile() {
if (browser) {
isMobile = window.innerWidth < 768;
}
}
onNavigate((info) => {
if (!info || !info.to) return;
chosen = info.to.url.pathname;
// Close sheet after navigation on mobile
if (isMobile) {
sheetOpen = false;
}
});
// Set initial state and add resize listener
$effect(() => {
updateIsMobile();
if (browser) {
const handleResize = () => {
updateIsMobile();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}
});
// Function to handle navigation from tabs
function handleNavigation(url: string) {
goto(url);
}
onMount(() => {
if (page.url.pathname !== secondaryNavTree[0].url) {
const found = secondaryNavTree.find(
(each) => each.url === page.url.pathname,
);
if (found) {
chosen = found.url;
}
}
});
</script>
<MaxWidthWrapper
cls="grid grid-cols-1 gap-4 md:grid-cols-5 lg:grid-cols-4 max-w-5xl pt-20"
>
<!-- Desktop sidebar - hidden on mobile -->
<div class="hidden w-full md:col-span-2 md:block lg:col-span-1">
<Tabs.Root value={chosen}>
<Tabs.List class="flex h-full w-full flex-col gap-2">
{#each secondaryNavTree as each}
<Tabs.Trigger
class="flex w-full items-start gap-2 text-start"
value={each.url}
onclick={() => {
handleNavigation(each.url);
}}
>
<Icon icon={each.icon} cls="w-4 h-4" />
<p class="w-full">
{each.title}
</p>
</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</div>
<div class="h-full w-full md:col-span-3">
{@render children()}
</div>
</MaxWidthWrapper>
<!-- FAB for mobile - fixed position -->
{#if isMobile}
<button
class="bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring fixed right-6 bottom-6 z-40 flex h-14 w-14 items-center justify-center rounded-full shadow-lg focus:ring-2 focus:ring-offset-2 focus:outline-none"
onclick={() => (sheetOpen = true)}
aria-label="Open navigation menu"
>
<Menu class="h-6 w-6" />
</button>
<!-- Sheet for mobile navigation -->
<Sheet.Root bind:open={sheetOpen}>
<Sheet.Content side="bottom">
<div class="py-6">
<Tabs.Root value={chosen}>
<Tabs.List class="flex h-full w-full flex-col gap-4">
{#each secondaryNavTree as each}
<Tabs.Trigger
class="flex w-full items-center gap-3 text-start"
value={each.url}
onclick={() => {
handleNavigation(each.url);
}}
>
<Icon icon={each.icon} cls="w-5 h-5" />
<p class="w-full text-base">
{each.title}
</p>
</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</div>
</Sheet.Content>
</Sheet.Root>
{/if}

View File

@@ -0,0 +1,247 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import * as Avatar from "$lib/components/ui/avatar";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Separator } from "$lib/components/ui/separator";
import { secondaryNavTree } from "$lib/core/constants";
import { accountVM } from "$lib/domains/account/account.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import AtSign from "@lucide/svelte/icons/at-sign";
import Mail from "@lucide/svelte/icons/mail";
import Save from "@lucide/svelte/icons/save";
import Upload from "@lucide/svelte/icons/upload";
import User from "@lucide/svelte/icons/user";
import type { PageData } from "./$types";
breadcrumbs.set([secondaryNavTree[0]]);
let { data }: { data: PageData } = $props();
const user = $state(data.user!);
// Separate form state for profile and email
let profileData = $state({
name: user.name ?? "",
username: user.username ?? "",
});
let emailData = $state({
email: user.email,
});
// Handle profile form submission (name, username)
async function handleProfileSubmit(e: SubmitEvent) {
e.preventDefault();
await accountVM.updateProfile(profileData);
}
// Handle email form submission
async function handleEmailSubmit(e: SubmitEvent) {
e.preventDefault();
if (user.email !== emailData.email) {
await accountVM.changeEmail(emailData.email);
}
}
// Handle image upload - would connect to your storage service
function handleImageUpload() {
// In a real implementation, this would trigger a file picker
console.log("Image upload triggered");
}
</script>
<div class="space-y-8">
<!-- Profile Information -->
<Card.Root>
<Card.Header>
<div class="text-center">
<div class="flex flex-col items-center justify-center gap-4">
<div class="relative">
<Avatar.Root class="border-muted h-24 w-24 border-2">
{#if user.image}
<Avatar.Image
src={user.image}
alt={user.name || "User"}
/>
{:else}
<Avatar.Fallback class="bg-primary/10 text-xl">
{(user.name || "User")
.substring(0, 2)
.toUpperCase()}
</Avatar.Fallback>
{/if}
</Avatar.Root>
<button
class="bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring absolute right-0 bottom-0 rounded-full p-1.5 shadow focus:ring-2 focus:ring-offset-2 focus:outline-none"
onclick={handleImageUpload}
aria-label="Upload new profile image"
type="button"
>
<Icon icon={Upload} cls="h-3 w-3" />
</button>
</div>
<div>
<h1 class="text-foreground text-xl font-semibold">
{user.name}
</h1>
<p class="text-muted-foreground text-sm">
Member since {new Date(
user.createdAt.toString(),
).toLocaleDateString()}
</p>
</div>
</div>
</div>
<Separator class="mt-4 mb-8" />
<Card.Title>Personal Information</Card.Title>
<Card.Description>
Update your personal information and how others see you on the
platform.
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<!-- Profile Form (Name, Username) -->
<form onsubmit={handleProfileSubmit} class="space-y-6">
<!-- Full Name -->
<div class="grid grid-cols-1 gap-1.5">
<Label for="name" class="flex items-center gap-1.5">
<Icon
icon={User}
cls="h-3.5 w-3.5 text-muted-foreground"
/>
Full Name
</Label>
<Input
id="name"
bind:value={profileData.name}
placeholder="Your name"
minlength={3}
/>
</div>
<!-- Username -->
<div class="grid grid-cols-1 gap-1.5">
<Label for="username" class="flex items-center gap-1.5">
<Icon
icon={AtSign}
cls="h-3.5 w-3.5 text-muted-foreground"
/>
Username
</Label>
<Input
id="username"
bind:value={profileData.username}
placeholder="username"
minlength={6}
maxlength={32}
/>
<p class="text-muted-foreground text-xs">
This is your public username visible to other users.
</p>
</div>
<div class="flex justify-end">
<Button
type="submit"
disabled={accountVM.loading}
class="w-full sm:w-auto"
>
{#if accountVM.loading}
<Icon icon={Save} cls="h-4 w-4 mr-2 animate-spin" />
Saving...
{:else}
<Icon icon={Save} cls="h-4 w-4 mr-2" />
Save Profile
{/if}
</Button>
</div>
</form>
</Card.Content>
<Card.Footer>
<p class="text-muted-foreground text-xs">
Last updated: {new Date(
user.updatedAt.toString(),
).toLocaleString()}
</p>
</Card.Footer>
</Card.Root>
<!-- Email Settings -->
<Card.Root>
<Card.Header>
<Card.Title>Email Settings</Card.Title>
<Card.Description>
Manage your email address and verification status.
</Card.Description>
</Card.Header>
<Card.Content>
<form onsubmit={handleEmailSubmit} class="space-y-4">
<!-- Email -->
<div class="grid grid-cols-1 gap-1.5">
<Label for="email" class="flex items-center gap-1.5">
<Icon
icon={Mail}
cls="h-3.5 w-3.5 text-muted-foreground"
/>
Email Address
</Label>
<Input
id="email"
type="email"
bind:value={emailData.email}
placeholder="your.email@example.com"
minlength={6}
maxlength={128}
/>
<div class="flex items-center gap-1.5">
<span
class={user.emailVerified
? "text-xs text-emerald-600 dark:text-emerald-400"
: "text-xs text-amber-600"}
>
{user.emailVerified
? "Email verified"
: "Email not verified"}
</span>
{#if !user.emailVerified}
<Button
variant="link"
size="sm"
class="h-auto p-0 text-xs">Verify now</Button
>
{/if}
</div>
</div>
<div class="flex justify-end">
<Button
type="submit"
disabled={accountVM.emailLoading ||
user.email === emailData.email}
variant="outline"
class="w-full sm:w-auto"
>
{#if accountVM.emailLoading}
<Icon icon={Mail} cls="h-4 w-4 mr-2 animate-spin" />
Sending...
{:else}
<Icon icon={Mail} cls="h-4 w-4 mr-2" />
Update Email
{/if}
</Button>
</div>
</form>
</Card.Content>
<Card.Footer>
<p class="text-muted-foreground text-xs">
Changing your email will require verification of the new
address.
</p>
</Card.Footer>
</Card.Root>
</div>

View File

@@ -0,0 +1,240 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { authVM } from "$lib/domains/security/auth.vm.svelte";
import {
ArrowRight,
CheckCircle,
Loader2,
Mail,
User,
XCircle,
} from "@lucide/svelte";
import { onMount } from "svelte";
let verificationState = $state<"loading" | "success" | "error">("loading");
let errorMessage = $state<string>("");
onMount(async () => {
try {
verificationState = "loading";
await authVM.verifyEmailChange();
verificationState = "success";
setTimeout(() => {
window.location.href = "/account";
}, 2000);
} catch (error) {
console.error(error);
verificationState = "error";
errorMessage =
error instanceof Error
? error.message
: "Email verification failed";
}
});
function handleReturnToAccount() {
window.location.href = "/account";
}
</script>
<div
class="from-muted/20 via-background to-accent/10 flex min-h-screen items-center justify-center bg-linear-to-br p-4"
>
<div class="w-full max-w-md">
<Card.Root class="bg-card/95 border shadow-2xl backdrop-blur-sm">
<Card.Content class="p-8">
<div class="flex flex-col items-center space-y-6 text-center">
{#if verificationState === "loading"}
<!-- Header Icon -->
<div class="relative">
<div
class="bg-primary/20 absolute inset-0 animate-pulse rounded-full blur-xl"
></div>
<div
class="bg-primary/10 border-primary/20 relative rounded-full border p-4"
>
<Icon icon={Mail} cls="h-8 w-8 text-primary" />
</div>
</div>
{/if}
<!-- Dynamic Content Based on State -->
{#if verificationState === "loading"}
<div class="space-y-4">
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Verifying Your Email
</Title>
<div
class="text-muted-foreground flex items-center justify-center space-x-2"
>
<Icon
icon={Loader2}
cls="h-5 w-5 animate-spin"
/>
<span class="text-sm"
>Please wait while we verify your email
change...</span
>
</div>
<!-- Progress Animation -->
<div
class="bg-muted/30 h-2 w-full overflow-hidden rounded-full"
>
<div
class="bg-primary from-primary/50 to-primary h-full animate-pulse rounded-full bg-linear-to-r transition-all duration-1000 ease-in-out"
></div>
</div>
<!-- Security Features -->
<div class="space-y-3 pt-4">
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="rounded-full border border-emerald-500/20 bg-emerald-500/10 p-1"
>
<CheckCircle
class="h-4 w-4 text-emerald-500"
/>
</div>
<span>Secure encrypted connection</span>
</div>
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="rounded-full border border-emerald-500/20 bg-emerald-500/10 p-1"
>
<CheckCircle
class="h-4 w-4 text-emerald-500"
/>
</div>
<span>Validating email ownership</span>
</div>
<div
class="text-muted-foreground flex items-center space-x-3 text-sm"
>
<div
class="bg-primary/10 border-primary/20 rounded-full border p-1"
>
<Loader2
class="text-primary h-4 w-4 animate-spin"
/>
</div>
<span>Updating account settings</span>
</div>
</div>
</div>
{:else if verificationState === "success"}
<div class="grid place-items-center space-y-4">
<div class="relative w-max">
<div
class="absolute inset-0 animate-pulse rounded-full bg-emerald-500/20 blur-lg"
></div>
<div
class="relative rounded-full border border-emerald-500/20 bg-emerald-500/10 p-4"
>
<Icon
icon={CheckCircle}
cls="h-8 w-8 text-emerald-500"
/>
</div>
</div>
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Email Verified Successfully!
</Title>
<p class="text-muted-foreground text-sm">
Your email address has been updated and
verified. Redirecting you to your account...
</p>
<div
class="flex items-center justify-center space-x-2 text-sm text-emerald-500"
>
<span>Redirecting</span>
<Icon
icon={ArrowRight}
cls="h-4 w-4 animate-bounce"
/>
</div>
</div>
{:else if verificationState === "error"}
<div class="grid place-items-center space-y-4">
<div class="relative w-max">
<div
class="bg-destructive/20 absolute inset-0 rounded-full blur-lg"
></div>
<div
class="bg-destructive/10 border-destructive/20 relative rounded-full border p-4"
>
<Icon
icon={XCircle}
cls="h-8 w-8 text-destructive"
/>
</div>
</div>
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Email Verification Failed
</Title>
<p
class="text-muted-foreground text-center text-sm"
>
{errorMessage ||
"We couldn't verify your email change. The verification link may have expired or been used already."}
</p>
<div class="space-y-3 pt-2">
<Button
onclick={handleReturnToAccount}
class="w-full"
>
<Icon icon={User} cls="h-4 w-4 mr-2" />
Return to Account
</Button>
<p
class="text-muted-foreground text-center text-xs"
>
You can try changing your email again from
your account settings.
</p>
</div>
</div>
{/if}
<!-- Footer Note -->
<div class="border-border w-full border-t pt-4">
<p class="text-muted-foreground text-center text-xs">
Email verification links are secure, one-time use
tokens that expire after 10 minutes.
</p>
</div>
</div>
</Card.Content>
</Card.Root>
</div>
</div>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree } from "$lib/core/constants";
import { breadcrumbs } from "$lib/global.stores";
breadcrumbs.set([mainNavTree[0]]);
</script>
<MaxWidthWrapper cls="space-y-8">
<div class="space-y-2">
<h1 class="text-3xl font-bold tracking-tight">
Dashboard Not Yet Implemented
</h1>
<p class="text-muted-foreground">
This is where your implementation will go
</p>
</div>
</MaxWidthWrapper>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { secondaryNavTree } from "$lib/core/constants";
import { notificationViewModel } from "$lib/domains/notifications/notification.vm.svelte";
import NotificationsTable from "$lib/domains/notifications/notifications-table.svelte";
import { breadcrumbs, user } from "$lib/global.stores";
import { onMount } from "svelte";
import { get } from "svelte/store";
// Set breadcrumb to notifications
breadcrumbs.set([secondaryNavTree[2]]);
onMount(() => {
// Ensure user ID is set in the view model
const currentUser = get(user);
if (currentUser?.id) {
notificationViewModel.filters = {
...notificationViewModel.filters,
userId: currentUser.id,
};
}
});
</script>
<MaxWidthWrapper cls="space-y-8 pt-12">
<NotificationsTable />
</MaxWidthWrapper>

View File

@@ -0,0 +1,13 @@
import { UserRoleMap } from "@pkg/logic/domains/user/data";
import type { PageServerLoad } from "./$types";
import { redirect } from "@sveltejs/kit";
// Info: this ensures only admin type users can access this page
export const load = (async (c) => {
const user = c.locals.user;
if (!user || user.role !== UserRoleMap.admin) {
return redirect(307, "/dashboard");
}
return {};
}) satisfies PageServerLoad;

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { mainNavTree } from "$lib/core/constants";
import { breadcrumbs } from "$lib/global.stores";
import { Plus, RefreshCw, Search } from "@lucide/svelte";
import { toast } from "svelte-sonner";
breadcrumbs.set([mainNavTree[1]]);
</script>
<MaxWidthWrapper cls="">
<Card.Root>
<Card.Content>
<div class="flex items-center justify-between gap-2">
<Card.Title>Users</Card.Title>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => {
toast("Refreshing users...", {
description: "simulating API call",
});
}}
>
<Icon icon={RefreshCw} cls="h-4 w-auto" />
Refresh
</Button>
<Button
onclick={() => {
toast("Creating user...", {
description: "simulating API call",
});
}}
size="sm"
>
<Icon icon={Plus} cls="h-4 w-auto" />
Create User
</Button>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
<!-- Search -->
<div class="relative">
<Icon
icon={Search}
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="pl-10"
placeholder="Search by email, name, or username..."
oninput={(event) => {
toast.info("Searching...", {
description: "simulating API call",
});
}}
/>
</div>
</div>
<div class="grid place-items-center p-8 py-32">
<span class="text-xl font-semibold"
>INFO: Normally would show all the users table here
</span>
</div>
</Card.Content>
</Card.Root>
</MaxWidthWrapper>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { browser } from "$app/environment";
import { Toaster } from "$lib/components/ui/sonner/index.js";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { ModeWatcher } from "mode-watcher";
import { apiClient, breadcrumbs } from "$lib/global.stores";
import { makeClient } from "$lib/make-client";
import { onMount } from "svelte";
import "./layout.css";
let { children }: { children: any } = $props();
const queryClient = new QueryClient({
defaultOptions: {
queries: { enabled: browser },
},
});
onMount(() => {
apiClient.set(makeClient(fetch));
});
</script>
<svelte:head>
<title>{$breadcrumbs[$breadcrumbs.length - 1]?.title ?? "Dashboard"}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="A base SaaS template" />
</svelte:head>
<ModeWatcher />
<Toaster />
<QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>

View File

@@ -0,0 +1,92 @@
import { UserRoleMap } from "@pkg/logic/domains/user/data";
import { user } from "@pkg/db/schema/better.auth.schema";
import type { RequestHandler } from "./$types";
import { env } from "$env/dynamic/private";
import { logger } from "@pkg/logger";
import { db, eq } from "@pkg/db";
import { nanoid } from "nanoid";
import * as v from "valibot";
function isAuthorized(authHeader?: string | null) {
if (!authHeader) return false;
const authToken = authHeader.toString().replace("Bearer ", "");
return authToken === env.DEBUG_API_KEY;
}
export const GET: RequestHandler = async ({ request }) => {
if (!isAuthorized(request.headers.get("Authorization"))) {
return new Response("Unauthorized", { status: 401 });
}
const users = await db.query.user.findMany({});
return new Response(JSON.stringify({ users }), { status: 200 });
};
export const PUT: RequestHandler = async ({ request }) => {
if (!isAuthorized(request.headers.get("Authorization"))) {
return new Response("Unauthorized", { status: 401 });
}
const data = await request.json();
if (!data.username) {
return new Response("Invalid data", { status: 400 });
}
await db
.update(user)
.set({ role: UserRoleMap.admin })
.where(eq(user.username, data.username))
.execute();
return new Response("Not implemented", { status: 200 });
};
export const POST: RequestHandler = async ({ request }) => {
if (!isAuthorized(request.headers.get("Authorization"))) {
return new Response("Unauthorized", { status: 401 });
}
const data = await request.json();
const _schema = v.object({
username: v.string(),
email: v.string(),
usertype: v.enum(UserRoleMap),
});
const res = v.safeParse(_schema, data);
if (!res.success) {
return new Response("Invalid data", { status: 400 });
}
if (
!!(
await db.query.user
.findFirst({
where: eq(user.role, UserRoleMap.user),
columns: { id: true },
})
.execute()
)?.id
) {
return new Response("Admin already exists", { status: 400 });
}
const resData = res.output;
logger.info(
`Creating ${resData.username} | ${resData.email} (${resData.usertype})`,
);
const out = await db.insert(user).values({
id: nanoid(),
username: resData.username,
email: resData.email,
emailVerified: false,
name: resData.username,
role: resData.usertype,
createdAt: new Date(),
updatedAt: new Date(),
});
logger.debug(out);
return new Response(JSON.stringify({ ...out }), { status: 200 });
};

View File

@@ -0,0 +1,93 @@
import { getUserController } from "@pkg/logic/domains/user/controller";
import type { Session, User } from "@pkg/logic/domains/user/data";
import { auth } from "@pkg/logic/domains/auth/config.base";
import type { RequestHandler } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
import { api } from "$lib/api";
async function createContext(locals: App.Locals) {
return { ...env, locals };
}
async function getExecutionContext(sess: Session, user: User) {
const flowId = crypto.randomUUID();
return {
flowId: flowId,
userId: user.id,
sessionId: sess.id,
};
}
async function getContext(headers: Headers) {
const sess = await auth.api.getSession({ headers });
if (!sess?.session) {
return false;
}
// @ts-ignore
const fCtx = getExecutionContext(sess.session, sess.user);
return await getUserController()
.getUserInfo(fCtx, sess.user.id)
.match(
(user) => {
return {
user: user,
session: sess.session,
fCtx: fCtx,
};
},
(error) => {
console.error(error);
return false;
},
);
}
export const GET: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const HEAD: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const POST: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const PUT: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const DELETE: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};
export const OPTIONS: RequestHandler = async ({ request }) => {
const context = await getContext(request.headers);
if (!context || typeof context === "boolean") {
return new Response("Unauthorized", { status: 401 });
}
return api.fetch(request, await createContext(context));
};

View File

@@ -0,0 +1,14 @@
import { auth } from "@pkg/logic/domains/auth/config.base";
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load = (async ({ request }) => {
const sess = await auth.api.getSession({
headers: request.headers,
});
if (!sess || !sess.user || !sess.session) {
return redirect(302, "/auth/login");
}
// sess.session.id =
return { user: sess.user as any, session: sess.session as any };
}) satisfies LayoutServerLoad;

View File

@@ -0,0 +1,285 @@
<script lang="ts">
import { authClient } from "$lib/auth.client";
import ButtonText from "$lib/components/atoms/button-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import * as Card from "$lib/components/ui/card/index.js";
import * as InputOTP from "$lib/components/ui/input-otp";
import { twoFactorVerifyVM } from "$lib/domains/security/2fa-verify.vm.svelte";
import { session, user } from "$lib/global.stores";
import {
AlertTriangle,
ArrowLeft,
Key,
Loader2,
Shield,
Smartphone,
XCircle,
} from "@lucide/svelte";
import { onMount } from "svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
let mounted = $state(false);
onMount(async () => {
if (data.user) {
user.set(data.user);
}
if (data.session) {
session.set(data.session);
}
setTimeout(async () => {
mounted = true;
await twoFactorVerifyVM.startVerification();
}, 500);
});
async function handleSubmit() {
await twoFactorVerifyVM.verifyCode();
}
function handleTryAgain() {
twoFactorVerifyVM.reset();
twoFactorVerifyVM.startVerification();
}
function handleSignOut() {
authClient.signOut();
window.location.href = "/auth/login";
}
</script>
<main class="grid h-screen w-screen place-items-center overflow-y-hidden">
<div class="w-full max-w-lg">
<Card.Root class="bg-card/95 border shadow-2xl backdrop-blur-sm">
{#if !mounted || twoFactorVerifyVM.startingVerification}
<!-- Loading State -->
<Card.Content class="p-8">
<div class="flex flex-col items-center space-y-6 text-center">
<div class="relative">
<div
class="bg-primary/20 absolute inset-0 animate-pulse rounded-full blur-xl"
></div>
<div
class="bg-primary/10 border-primary/20 relative rounded-full border p-4"
>
<Icon icon={Shield} cls="h-8 w-8 text-primary" />
</div>
</div>
<div class="space-y-4">
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Preparing Verification
</Title>
<div
class="text-muted-foreground flex items-center justify-center space-x-2"
>
<Icon icon={Loader2} cls="h-5 w-5 animate-spin" />
<span class="text-sm"
>Setting up secure verification...</span
>
</div>
<!-- Progress Animation -->
<div
class="bg-muted/30 h-2 w-full overflow-hidden rounded-full"
>
<div
class="bg-primary from-primary/50 to-primary h-full animate-pulse rounded-full bg-gradient-to-r transition-all duration-1000 ease-in-out"
></div>
</div>
</div>
</div>
</Card.Content>
{:else if twoFactorVerifyVM.verificationToken}
<!-- Verification Form -->
<Card.Header>
<div class="flex items-center justify-center space-x-2 py-2">
<Icon icon={Smartphone} cls="h-6 w-6 text-primary" />
<Card.Title>
<Title size="h4" weight="medium" center>
Two-Factor Authentication
</Title>
</Card.Title>
</div>
<Card.Description class="text-center">
Enter the 6-digit code from your authenticator app to
continue
</Card.Description>
</Card.Header>
<Card.Content
class="flex w-full flex-col items-center justify-center gap-6 p-8"
>
<!-- 2FA Input -->
<div class="w-full max-w-sm space-y-4">
<div class="flex justify-center">
<InputOTP.Root
maxlength={6}
bind:value={twoFactorVerifyVM.verificationCode}
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell (cell)}
<InputOTP.Slot {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
</div>
{#if twoFactorVerifyVM.errorMessage}
<div
class="text-destructive flex items-center justify-center space-x-2 text-sm"
>
<Icon icon={AlertTriangle} cls="h-4 w-4" />
<span>{twoFactorVerifyVM.errorMessage}</span>
</div>
{/if}
<Button
onclick={handleSubmit}
disabled={twoFactorVerifyVM.verifying ||
!twoFactorVerifyVM.verificationCode ||
twoFactorVerifyVM.verificationCode.length !== 6}
class="w-full"
>
<ButtonText
loading={twoFactorVerifyVM.verifying}
text="Verify Code"
loadingText="Verifying..."
/>
</Button>
</div>
<!-- Alternative Options -->
<div class="w-full max-w-sm space-y-3">
<div class="flex w-full items-center gap-4">
<span class="border-muted h-1 w-full border-t-2"
></span>
<p class="text-sm text-zinc-500">OR</p>
<span class="border-muted h-1 w-full border-t-2"
></span>
</div>
<Button
variant="outline"
onclick={() => twoFactorVerifyVM.handleBackupCode()}
class="w-full"
>
<Icon icon={Key} cls="h-4 w-4 mr-2" />
Use Recovery Code
</Button>
</div>
<!-- Troubleshooting -->
<div class="w-full max-w-sm space-y-3 pt-4">
<div class="space-y-2 text-center">
<p class="text-muted-foreground text-xs">
Having trouble?
</p>
<div class="flex justify-center gap-2">
<Button
variant="ghost"
size="sm"
onclick={handleTryAgain}
disabled={twoFactorVerifyVM.verifying}
>
<Icon icon={ArrowLeft} cls="h-3 w-3 mr-1" />
Try Refreshing
</Button>
<Button
variant="ghost"
size="sm"
onclick={handleSignOut}
disabled={twoFactorVerifyVM.verifying}
>
Sign Out
</Button>
</div>
</div>
</div>
<!-- Security Note -->
<div class="border-border w-full border-t pt-4">
<div
class="text-muted-foreground flex items-center justify-center space-x-2 text-xs"
>
<Icon icon={Shield} cls="h-3 w-3" />
<span>
This verification expires in 10 minutes for your
security
</span>
</div>
</div>
</Card.Content>
{:else}
<!-- Error State -->
<Card.Content class="p-8">
<div class="flex flex-col items-center space-y-6 text-center">
<div class="relative w-max">
<div
class="bg-destructive/20 absolute inset-0 rounded-full blur-lg"
></div>
<div
class="bg-destructive/10 border-destructive/20 relative rounded-full border p-4"
>
<Icon
icon={XCircle}
cls="h-8 w-8 text-destructive"
/>
</div>
</div>
<div class="space-y-4">
<Title
size="h4"
weight="semibold"
color="theme"
center
>
Verification Setup Failed
</Title>
<p class="text-muted-foreground text-center text-sm">
{twoFactorVerifyVM.errorMessage ||
"We couldn't set up your verification session. Please try again."}
</p>
<div class="space-y-3 pt-2">
<Button onclick={handleTryAgain} class="w-full">
<Icon icon={ArrowLeft} cls="h-4 w-4 mr-2" />
Try Refreshing
</Button>
<Button
variant="outline"
onclick={handleSignOut}
class="w-full"
>
Sign Out & Start Over
</Button>
</div>
</div>
</div>
</Card.Content>
{/if}
</Card.Root>
</div>
</main>

View File

@@ -0,0 +1,13 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { auth } from "@pkg/logic/domains/auth/config.base";
export const load = (async (c) => {
const sess = await auth.api.getSession({
headers: c.request.headers,
});
if (!!sess && !!sess.user && !!sess.session) {
return redirect(302, "/");
}
}) satisfies PageServerLoad;

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import * as Card from "$lib/components/ui/card/index.js";
import EmailLoginForm from "$lib/domains/security/email-login-form.svelte";
import { Shield } from "@lucide/svelte";
</script>
<main
class="from-background via-muted/20 to-accent/5 relative grid h-screen w-screen place-items-center overflow-hidden bg-gradient-to-br"
>
<!-- Animated Background Elements -->
<div class="absolute inset-0 overflow-hidden">
<div
class="bg-primary/5 absolute -top-40 -right-40 h-80 w-80 rounded-full blur-3xl"
></div>
<div
class="bg-accent/5 absolute -bottom-40 -left-40 h-80 w-80 rounded-full blur-3xl"
></div>
<div
class="bg-primary/3 absolute top-1/3 left-1/4 h-32 w-32 animate-pulse rounded-full blur-2xl"
></div>
</div>
<div class="relative z-10 w-full max-w-lg px-4">
<Card.Root
class="bg-card/95 border-border/50 hover:shadow-3xl border-2 shadow-2xl backdrop-blur-lg transition-all duration-300"
>
<Card.Header class="pt-8 pb-6 text-center">
<div class="flex flex-col items-center space-y-4">
<div class="relative">
<div
class="bg-primary/20 absolute inset-0 animate-pulse rounded-full blur-lg"
></div>
<div
class="from-primary/10 to-accent/10 border-primary/20 relative rounded-full border-2 bg-gradient-to-br p-4"
>
<Icon icon={Shield} cls="h-8 w-8 text-primary" />
</div>
</div>
<div class="space-y-2">
<Card.Title>
<Title size="h3" weight="semibold" center>
Welcome Back
</Title>
</Card.Title>
<Card.Description class="text-muted-foreground">
Sign in to your account to continue
</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="px-8 pb-8">
<EmailLoginForm />
<div class="w-full flex gap-4 items-center justify-center py-4">
<div class="h-0.5 w-full bg-muted-foreground"></div>
<p class="text-lg">OR</p>
<div class="h-0.5 w-full bg-muted-foreground"></div>
</div>
</Card.Content>
<Card.Footer class="flex items-center justify-between">
<p class="text-muted-foreground text-xs w-full text-center">
By signing in, you agree to our
<button class="text-primary hover:underline">Terms</button>
and
<button class="text-primary hover:underline"
>Privacy Policy</button
>
</p>
</Card.Footer>
</Card.Root>
</div>
</main>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import MagicLinkVerification from "$lib/domains/security/magic-link-verification.svelte";
</script>
<div
class="from-muted/20 via-background to-accent/10 flex min-h-screen items-center justify-center bg-gradient-to-br p-4"
>
<div class="w-full max-w-md">
<MagicLinkVerification />
</div>
</div>

View File

@@ -0,0 +1,196 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/typography";
@font-face {
font-family: "Manrope";
src: url("/fonts/manrope-variable.ttf") format("truetype");
}
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.994 0 0);
--foreground: oklch(0 0 0);
--card: oklch(0.994 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(0.991 0 0);
--popover-foreground: oklch(0 0 0);
/* --- main theme: lavender/royal purple --- */
--primary: oklch(0.6 0.2 280); /* medium lavender purple */
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.93 0.05 285); /* soft pale lavender */
--secondary-foreground: oklch(0.25 0.03 285);
--muted: oklch(0.96 0.01 275);
--muted-foreground: oklch(0.4 0.01 278);
--accent: oklch(0.86 0.08 275); /* lavender accent */
--accent-foreground: oklch(0.5 0.15 280);
--destructive: oklch(0.63 0.18 25);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.92 0.02 284);
--input: oklch(0.94 0 0);
--ring: oklch(0.6 0.2 280);
/* charts — more variety but still within lavender spectrum */
--chart-1: oklch(0.7 0.16 275);
--chart-2: oklch(0.6 0.2 280);
--chart-3: oklch(0.72 0.18 295); /* slightly more magenta */
--chart-4: oklch(0.65 0.15 265); /* slightly bluer lavender */
--chart-5: oklch(0.76 0.1 285);
--sidebar: oklch(0.97 0.01 280);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6 0.2 280);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.92 0.02 284);
--sidebar-accent-foreground: oklch(0.2 0.02 280);
--sidebar-border: oklch(0.92 0.02 284);
--sidebar-ring: oklch(0.6 0.2 280);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: IBM Plex Mono, monospace;
--radius: 0.69rem;
--shadow-2xs: 0px 2px 3px 0px hsl(0 0% 0% / 0.08);
--shadow-xs: 0px 2px 3px 0px hsl(0 0% 0% / 0.08);
--shadow-sm:
0px 2px 3px 0px hsl(0 0% 0% / 0.16),
0px 1px 2px -1px hsl(0 0% 0% / 0.16);
--shadow:
0px 2px 3px 0px hsl(0 0% 0% / 0.16),
0px 1px 2px -1px hsl(0 0% 0% / 0.16);
--shadow-md:
0px 2px 3px 0px hsl(0 0% 0% / 0.16),
0px 2px 4px -1px hsl(0 0% 0% / 0.16);
--shadow-lg:
0px 2px 3px 0px hsl(0 0% 0% / 0.16),
0px 4px 6px -1px hsl(0 0% 0% / 0.16);
--shadow-xl:
0px 2px 3px 0px hsl(0 0% 0% / 0.16),
0px 8px 10px -1px hsl(0 0% 0% / 0.16);
--shadow-2xl: 0px 2px 3px 0px hsl(0 0% 0% / 0.4);
--tracking-normal: -0.025em;
--spacing: 0.27rem;
}
.dark {
--background: oklch(0.23 0.01 278);
--foreground: oklch(0.95 0 0);
--card: oklch(0.25 0.015 278);
--card-foreground: oklch(0.95 0 0);
--popover: oklch(0.25 0.015 278);
--popover-foreground: oklch(0.95 0 0);
--primary: oklch(0.56 0.17 280);
--primary-foreground: oklch(0.97 0 0);
--secondary: oklch(0.35 0.03 280);
--secondary-foreground: oklch(0.92 0 0);
--muted: oklch(0.33 0.02 280);
--muted-foreground: oklch(0.7 0.01 280);
--accent: oklch(0.44 0.1 278);
--accent-foreground: oklch(0.88 0.09 280);
--destructive: oklch(0.7 0.17 25);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.34 0.02 278);
--input: oklch(0.34 0.02 278);
--ring: oklch(0.65 0.22 280);
--ring: oklch(0.56 0.17 280);
--chart-1: oklch(0.68 0.15 275);
--chart-2: oklch(0.62 0.2 280);
--chart-3: oklch(0.7 0.14 292);
--chart-4: oklch(0.65 0.16 265);
--chart-5: oklch(0.72 0.1 285);
--sidebar: oklch(0.2 0.01 278);
--sidebar-foreground: oklch(0.95 0 0);
--sidebar-primary: oklch(0.56 0.17 280);
--sidebar-primary-foreground: oklch(0.97 0 0);
--sidebar-accent: oklch(0.35 0.03 280);
--sidebar-accent-foreground: oklch(0.65 0.22 280);
--sidebar-border: oklch(0.34 0.02 278);
--sidebar-ring: oklch(0.65 0.22 280);
}
@theme inline {
--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-destructive-foreground: var(--destructive-foreground);
--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);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: "Manrope", sans-serif;
letter-spacing: var(--tracking-normal);
}
}