& so it begins
This commit is contained in:
13
apps/main/src/routes/(main)/+layout.server.ts
Normal file
13
apps/main/src/routes/(main)/+layout.server.ts
Normal 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;
|
||||
58
apps/main/src/routes/(main)/+layout.svelte
Normal file
58
apps/main/src/routes/(main)/+layout.svelte
Normal 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>
|
||||
6
apps/main/src/routes/(main)/+page.server.ts
Normal file
6
apps/main/src/routes/(main)/+page.server.ts
Normal 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;
|
||||
18
apps/main/src/routes/(main)/+page.svelte
Normal file
18
apps/main/src/routes/(main)/+page.svelte
Normal 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>
|
||||
134
apps/main/src/routes/(main)/account/+layout.svelte
Normal file
134
apps/main/src/routes/(main)/account/+layout.svelte
Normal 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}
|
||||
247
apps/main/src/routes/(main)/account/+page.svelte
Normal file
247
apps/main/src/routes/(main)/account/+page.svelte
Normal 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>
|
||||
240
apps/main/src/routes/(main)/account/verify-email/+page.svelte
Normal file
240
apps/main/src/routes/(main)/account/verify-email/+page.svelte
Normal 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>
|
||||
18
apps/main/src/routes/(main)/dashboard/+page.svelte
Normal file
18
apps/main/src/routes/(main)/dashboard/+page.svelte
Normal 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>
|
||||
27
apps/main/src/routes/(main)/notifications/+page.svelte
Normal file
27
apps/main/src/routes/(main)/notifications/+page.svelte
Normal 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>
|
||||
13
apps/main/src/routes/(main)/users/+page.server.ts
Normal file
13
apps/main/src/routes/(main)/users/+page.server.ts
Normal 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;
|
||||
73
apps/main/src/routes/(main)/users/+page.svelte
Normal file
73
apps/main/src/routes/(main)/users/+page.svelte
Normal 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>
|
||||
36
apps/main/src/routes/+layout.svelte
Normal file
36
apps/main/src/routes/+layout.svelte
Normal 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>
|
||||
92
apps/main/src/routes/api/debug/users/+server.ts
Normal file
92
apps/main/src/routes/api/debug/users/+server.ts
Normal 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 });
|
||||
};
|
||||
93
apps/main/src/routes/api/v1/[...paths]/+server.ts
Normal file
93
apps/main/src/routes/api/v1/[...paths]/+server.ts
Normal 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));
|
||||
};
|
||||
14
apps/main/src/routes/auth/2fa/+layout.server.ts
Normal file
14
apps/main/src/routes/auth/2fa/+layout.server.ts
Normal 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;
|
||||
285
apps/main/src/routes/auth/2fa/+page.svelte
Normal file
285
apps/main/src/routes/auth/2fa/+page.svelte
Normal 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>
|
||||
13
apps/main/src/routes/auth/login/+page.server.ts
Normal file
13
apps/main/src/routes/auth/login/+page.server.ts
Normal 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;
|
||||
77
apps/main/src/routes/auth/login/+page.svelte
Normal file
77
apps/main/src/routes/auth/login/+page.svelte
Normal 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>
|
||||
11
apps/main/src/routes/auth/magic-link/+page.svelte
Normal file
11
apps/main/src/routes/auth/magic-link/+page.svelte
Normal 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>
|
||||
196
apps/main/src/routes/layout.css
Normal file
196
apps/main/src/routes/layout.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user