oops i forgot to commit as I go again

This commit is contained in:
2025-01-24 17:18:04 -06:00
parent a09e8245c0
commit 567aa4cb71
35 changed files with 1455 additions and 37 deletions
+78 -3
View File
@@ -1,3 +1,78 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 0 0% 98%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@@ -0,0 +1,25 @@
<script lang="ts">
import { Button as ButtonPrimitive } from "bits-ui";
import { type Events, type Props, buttonVariants } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = Props;
type $$Events = Events;
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export let size: $$Props["size"] = "default";
export let builders: $$Props["builders"] = [];
export { className as class };
</script>
<ButtonPrimitive.Root
{builders}
class={cn(buttonVariants({ variant, size, className }))}
type="button"
{...$$restProps}
on:click
on:keydown
>
<slot />
</ButtonPrimitive.Root>
+49
View File
@@ -0,0 +1,49 @@
import { type VariantProps, tv } from "tailwind-variants";
import type { Button as ButtonPrimitive } from "bits-ui";
import Root from "./button.svelte";
const buttonVariants = tv({
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
type Variant = VariantProps<typeof buttonVariants>["variant"];
type Size = VariantProps<typeof buttonVariants>["size"];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
size?: Size;
};
type Events = ButtonPrimitive.Events;
export {
Root,
type Props,
type Events,
//
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
};
@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("p-6", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLParagraphElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<p class={cn("text-muted-foreground text-sm", className)} {...$$restProps}>
<slot />
</p>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("flex items-center p-6 pt-0", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("flex flex-col space-y-1.5 p-6 pb-0", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { HeadingLevel } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
tag?: HeadingLevel;
};
let className: $$Props["class"] = undefined;
export let tag: $$Props["tag"] = "h3";
export { className as class };
</script>
<svelte:element
this={tag}
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...$$restProps}
>
<slot />
</svelte:element>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div
class={cn("bg-card text-card-foreground rounded-lg border shadow-sm", className)}
{...$$restProps}
>
<slot />
</div>
+24
View File
@@ -0,0 +1,24 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
};
export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
+29
View File
@@ -0,0 +1,29 @@
import Root from "./input.svelte";
export type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement;
};
export type InputEvents = {
blur: FormInputEvent<FocusEvent>;
change: FormInputEvent<Event>;
click: FormInputEvent<MouseEvent>;
focus: FormInputEvent<FocusEvent>;
focusin: FormInputEvent<FocusEvent>;
focusout: FormInputEvent<FocusEvent>;
keydown: FormInputEvent<KeyboardEvent>;
keypress: FormInputEvent<KeyboardEvent>;
keyup: FormInputEvent<KeyboardEvent>;
mouseover: FormInputEvent<MouseEvent>;
mouseenter: FormInputEvent<MouseEvent>;
mouseleave: FormInputEvent<MouseEvent>;
mousemove: FormInputEvent<MouseEvent>;
paste: FormInputEvent<ClipboardEvent>;
input: FormInputEvent<InputEvent>;
wheel: FormInputEvent<WheelEvent>;
};
export {
Root,
//
Root as Input,
};
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import type { InputEvents } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = HTMLInputAttributes;
type $$Events = InputEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"] = undefined;
export { className as class };
// Workaround for https://github.com/sveltejs/svelte/issues/9305
// Fixed in Svelte 5, but not backported to 4.x.
export let readonly: $$Props["readonly"] = undefined;
</script>
<input
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:value
{readonly}
on:blur
on:change
on:click
on:focus
on:focusin
on:focusout
on:keydown
on:keypress
on:keyup
on:mouseover
on:mouseenter
on:mouseleave
on:mousemove
on:paste
on:input
on:wheel|passive
{...$$restProps}
/>
+7
View File
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
type $$Props = LabelPrimitive.Props;
type $$Events = LabelPrimitive.Events;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<LabelPrimitive.Root
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...$$restProps}
on:mousedown
>
<slot />
</LabelPrimitive.Root>
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+62
View File
@@ -0,0 +1,62 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
type FlyAndScaleParams = {
y?: number;
x?: number;
start?: number;
duration?: number;
};
export const flyAndScale = (
node: Element,
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
): TransitionConfig => {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
const scaleConversion = (
valueA: number,
scaleA: [number, number],
scaleB: [number, number]
) => {
const [minA, maxA] = scaleA;
const [minB, maxB] = scaleB;
const percentage = (valueA - minA) / (maxA - minA);
const valueB = percentage * (maxB - minB) + minB;
return valueB;
};
const styleToString = (
style: Record<string, number | string | undefined>
): string => {
return Object.keys(style).reduce((str, key) => {
if (style[key] === undefined) return str;
return str + `${key}:${style[key]};`;
}, "");
};
return {
duration: params.duration ?? 200,
delay: 0,
css: (t) => {
const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);
const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);
const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);
return styleToString({
transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
opacity: t
});
},
easing: cubicOut
};
};
+10
View File
@@ -0,0 +1,10 @@
<script>
import { page } from '$app/stores';
</script>
<div class="flex flex-riow gap-4">
{#if $page.error}
<span class="text-red-500 font-bold">{$page.status}:</span>
<p>{$page.error.message}</p>
{/if}
</div>
+6 -1
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
import { ModeWatcher } from 'mode-watcher';
</script>
{@render children()}
<ModeWatcher />
<div class="w-screen h-screen flex flex-col items-center justify-center">
{@render children()}
</div>
+77
View File
@@ -0,0 +1,77 @@
// src/routes/api/login/+server.ts
import { prisma } from '$lib/prisma';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto'; // For generating a random session ID
import { z } from 'zod';
import { json, type RequestEvent } from '@sveltejs/kit';
import { serialize } from 'cookie'; // For cookie serialization
// Define the schema for validation using Zod
const loginSchema = z.object({
email: z.string().email().min(5, 'Email is required'),
password: z.string().min(8, 'Password must be at least 8 characters')
});
export const POST = async ({ request }: RequestEvent) => {
try {
// Parse the request body
const formData = await request.formData();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
// Validate the incoming data
const parsed = loginSchema.safeParse({ email, password });
if (!parsed.success) {
return json({ message: parsed.error.errors[0].message }, { status: 400 });
}
// Find the user in the database by email
const user = await prisma.user.findUnique({
where: { email }
});
if (!user) {
return json({ message: 'Invalid email or password' }, { status: 400 });
}
// Compare the provided password with the hashed password in the database
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return json({ message: 'Invalid email or password' }, { status: 400 });
}
// Generate a secure random session ID using crypto
const sessionId = randomBytes(16).toString('hex'); // 64-byte hex session ID
// Create a new session in the database
await prisma.session.create({
data: {
id: sessionId, // Session ID is generated randomly
userId: user.id // Associate session with user
}
});
// Set the session ID in an HttpOnly cookie
const cookie = serialize('session_id', sessionId, {
httpOnly: true, // Ensures the cookie is not accessible via JavaScript
secure: process.env.NODE_ENV === 'production', // Set secure flag in production
maxAge: 60 * 60, // 1 hour expiry
path: '/' // Make the cookie available throughout the site
});
// Respond with the session token
return json(
{ message: 'Login successful' },
{
status: 200,
headers: { 'Set-Cookie': cookie } // Set cookie header
}
);
} catch (error) {
console.error(error);
return json({ message: 'Internal server error' }, { status: 500 });
}
};
+42
View File
@@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export const GET = async ({ cookies }: RequestEvent) => {
// Retrieve the session_id cookie
const sessionId = cookies.get('session_id');
// If there's no session_id cookie, return an empty object
if (!sessionId) {
return json({ message: 'No session cookie!' }, { status: 500 });
}
try {
// Find the session and include the associated user
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: { include: { roles: true } } }
});
// If no session is found, return an empty object
if (!session) {
return json({ message: 'Session doesnt exist!' }, { status: 500 });
}
// Sanitize the user data by removing the passwordHash and sanitizing the session
const sanitizedUser = {
...session.user,
passwordHash: undefined, // Remove the password hash
sessions: undefined // Remove sessions if included in the model
};
// Return the sanitized user data
return json(sanitizedUser);
} catch (error) {
// Log the error and return an empty object
console.error('Error fetching session:', error);
return json({ message: 'Internal server error' }, { status: 500 });
}
};
+74
View File
@@ -0,0 +1,74 @@
import { prisma } from '$lib/prisma'; // Your Prisma client instance
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { type RequestHandler } from '@sveltejs/kit';
// Define the schema for validation using Zod
const registerSchema = z.object({
email: z.string().email().min(5, 'Email is required'),
password: z.string().min(8, 'Password must be at least 8 characters'),
firstName: z.string().optional(),
lastName: z.string().optional()
});
// Define the POST handler with explicit typing for the request
export const POST: RequestHandler = async ({ request }) => {
try {
// Parse form data
const formData = await request.formData();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
// Validate the incoming data
const parsed = registerSchema.safeParse({
email,
password,
firstName: formData.get('firstName') as string | null,
lastName: formData.get('lastName') as string | null
});
if (!parsed.success) {
return new Response(JSON.stringify({ message: parsed.error.errors[0].message }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Check if the email already exists
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
return new Response(JSON.stringify({ message: 'Email already registered' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Hash the password before storing it
const hashedPassword = await bcrypt.hash(password, 10);
// Create the new user in the database
await prisma.user.create({
data: {
email,
passwordHash: hashedPassword,
firstName: parsed.data.firstName || undefined, // Store firstName if provided
lastName: parsed.data.lastName || undefined // Store lastName if provided
}
});
// Redirect to the login page
return new Response(JSON.stringify({ message: 'Successfully created an account' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
// Handle unexpected errors
return new Response(JSON.stringify({ message: 'Internal server error', error: error }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};
+56
View File
@@ -0,0 +1,56 @@
import { prisma } from '$lib/prisma';
import { parse } from 'cookie';
import { redirect, error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ request, url }) => {
const cookies = parse(request.headers.get('cookie') || '');
const sessionId = cookies['session_id'];
const redirectURL = url.searchParams.get('redirect');
if (!redirectURL) throw error(400, 'Invalid redirect domain');
if (sessionId) {
// Validate the session by checking if the session ID exists in the database
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true }
});
if (session) {
// Session is valid, check if there's a redirectURL query parameter
if (redirectURL) {
let redirectWithToken;
try {
redirectWithToken = new URL(redirectURL);
// Extract the domain from the redirect URL
const redirectDomain = redirectWithToken.hostname;
// Check if an application exists with the provided domain
const app = await prisma.app.findFirst({
where: { domain: redirectDomain }
});
if (!app) {
// If no application with the domain exists, throw an error
throw error(400, 'Invalid redirect domain');
}
// Append the session ID or token to the redirect URL
redirectWithToken.searchParams.append('token', session.id);
// Perform the redirect with the session token attached
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
// Handle malformed URL or other errors
throw error(400, 'Invalid redirect domain!');
}
redirect(302, redirectWithToken.toString());
}
}
}
// If no session exists, allow the user to stay on the login page or show the login form
return {};
};
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
let redirectUrl: string | null = page.url.searchParams.get('redirect');
let email = '';
let password = '';
let successMessage: string | null = null;
let errorMessage: string | null = null;
const login = async (event: Event) => {
event.preventDefault();
successMessage = null;
errorMessage = null;
try {
// Send login request
const response = await fetch('/api/login', {
method: 'POST',
body: new URLSearchParams({ email, password })
});
if (response.ok) {
successMessage = 'Login successful! Redirecting...';
setTimeout(() => {
location.reload(); // Reload the page to reflect the logged-in state
}, 2000); // Add slight delay to show success message
} else {
errorMessage = 'Login failed: Invalid email or password.';
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
errorMessage = 'An error occurred while trying to log in. Please try again later.';
}
};
</script>
<Card.Root class="w-[350px]">
<form on:submit={login}>
<Card.Header>
<Card.Title>Login to Account</Card.Title>
<Card.Description>Please enter your credentials below to login.</Card.Description>
<Card.Description>You will be redirected to .</Card.Description>
</Card.Header>
<Card.Content>
<!-- Display success or error message -->
{#if successMessage}
<p class="text-green-500 mb-4">{successMessage}</p>
{:else if errorMessage}
<p class="text-red-500 mb-4">{errorMessage}</p>
{/if}
<Label for="email">Email:</Label>
<Input id="email" type="email" bind:value={email} required />
<Label for="password">Password:</Label>
<Input id="password" type="password" bind:value={password} required />
<!-- Submit button within the form -->
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" on:click={() => goto(`/register?redirect=${redirectUrl}`)}
>Register</Button
>
<Button type="submit">Login</Button>
</Card.Footer>
</form>
</Card.Root>
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
export const GET = async ({ cookies, url }: RequestEvent) => {
// Delete the cookie
cookies.delete('session_id', { path: '/' });
// Get the redirect URL from the query parameter
const redirectUrl = url.searchParams.get('redirect') || 'https://rummage.cc';
// Redirect to the specified URL
throw redirect(302, redirectUrl);
};
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index';
import * as Card from '$lib/components/ui/card/index';
import { Input } from '$lib/components/ui/input/index';
import { Label } from '$lib/components/ui/label/index';
import { page } from '$app/state';
import { goto } from '$app/navigation'; // Import for redirecting to the login page
let redirectUrl: string | null = page.url.searchParams.get('redirect');
let email = '';
let password = '';
let firstName = '';
let lastName = '';
let errorMessage = '';
let successMessage = '';
// Form submission handler
async function submitForm() {
// Reset messages
errorMessage = '';
successMessage = '';
const formData = new FormData();
formData.append('email', email);
formData.append('password', password);
formData.append('firstName', firstName);
formData.append('lastName', lastName);
try {
const response = await fetch('/api/register', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
successMessage = data.message;
setTimeout(() => {
goto(`/login?redirect=${redirectUrl}`); // Redirect to login page on success
}, 1000); // Delay redirect to show success message
} else {
errorMessage = data.message;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
errorMessage = 'An error occurred. Please try again.';
}
}
</script>
<Card.Root class="w-[350px]">
<form on:submit|preventDefault={submitForm}>
<Card.Header>
<Card.Title>Register Account</Card.Title>
<Card.Description>Please fill in the details below to create an account.</Card.Description>
</Card.Header>
<Card.Content>
{#if successMessage}
<p class="text-green-500">{successMessage}</p>
{:else if errorMessage}
<p class="text-red-500">{errorMessage}</p>
{/if}
<Label for="email">Email:</Label>
<Input id="email" type="email" bind:value={email} required />
<Label for="password">Password:</Label>
<Input id="password" type="password" bind:value={password} required />
<Label for="firstName">First Name:</Label>
<Input id="firstName" type="text" bind:value={firstName} />
<Label for="lastName">Last Name:</Label>
<Input id="lastName" type="text" bind:value={lastName} />
</Card.Content>
<Card.Footer class="flex justify-between">
<Button variant="outline" on:click={() => goto(`/login?redirect=${redirectUrl}`)}
>Login</Button
>
<Button type="submit">Register</Button>
</Card.Footer>
</form>
</Card.Root>