register / login

This commit is contained in:
2025-06-09 21:09:21 +02:00
parent 6b562b58d1
commit 104e142e43
16 changed files with 293 additions and 40 deletions

View File

@@ -7,23 +7,24 @@
"dayjs": "^1.11.13",
"jdenticon": "^3.3.0",
"jsonwebtoken": "^9.0.2",
"mode-watcher": "0.5.1",
"mode-watcher": "^1.0.6",
},
"devDependencies": {
"@internationalized/date": "^3.5.6",
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.513.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/jsonwebtoken": "^9.0.9",
"bits-ui": "^2.4.1",
"bits-ui": "^2.5.0",
"clsx": "^2.1.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.0.4",
"tailwind-merge": "^3.0.2",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",
@@ -208,7 +209,7 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bits-ui": ["bits-ui@2.4.1", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-Z0qZkPgtxP5dkEOyZqCK2PQ1oFXsD0AlfFMtsrMLJqaYtjUBoQGosHVKEH03M9q4G8kxVnG74Zb0F9pS0X4+6w=="],
"bits-ui": ["bits-ui@2.5.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-PbjylA1UWd4A/c5AYqie/EVxQ1/8uugmJKLg9whLoBBHbfPEBGhK09dCPrahK9kA6DRHhMmij0XXIUGIfrmNow=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
@@ -312,7 +313,7 @@
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"mode-watcher": ["mode-watcher@0.5.1", "", { "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.1" } }, "sha512-adEC6T7TMX/kzQlaO/MtiQOSFekZfQu4MC+lXyoceQG+U5sKpJWZ4yKXqw846ExIuWJgedkOIPqAYYRk/xHm+w=="],
"mode-watcher": ["mode-watcher@1.0.7", "", { "dependencies": { "runed": "^0.25.0", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.27.0" } }, "sha512-ZGA7ZGdOvBJeTQkzdBOnXSgTkO6U6iIFWJoyGCTt6oHNg9XP9NBvS26De+V4W2aqI+B0yYXUskFG2VnEo3zyMQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
@@ -358,6 +359,8 @@
"svelte-check": ["svelte-check@4.2.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="],
"svelte-sonner": ["svelte-sonner@1.0.4", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-ctm9jeV0Rf3im2J6RU1emccrJFjRSdNSPsLlxaF62TLZw9bB1D40U/U7+wqEgohJY/X7FBdghdj0BFQF/IqKPQ=="],
"svelte-toolbelt": ["svelte-toolbelt@0.9.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.28.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-wBX6MtYw/kpht80j5zLpxJyR9soLizXPIAIWEVd9llAi17SR44ZdG291bldjB7r/K5duC0opDFcuhk2cA1hb8g=="],
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
@@ -404,6 +407,14 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"mode-watcher/runed": ["runed@0.25.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg=="],
"mode-watcher/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
"svelte-sonner/runed": ["runed@0.26.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-qWFv0cvLVRd8pdl/AslqzvtQyEn5KaIugEernwg9G98uJVSZcs/ygvPBvF80LA46V8pwRvSKnaVLDI3+i2wubw=="],
"tailwind-variants/tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="],
"mode-watcher/svelte-toolbelt/runed": ["runed@0.23.4", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA=="],
}
}

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
@@ -12,5 +12,5 @@
"lib": "$lib"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -14,20 +14,21 @@
"lint": "prettier --check ."
},
"devDependencies": {
"@internationalized/date": "^3.5.6",
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.513.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/jsonwebtoken": "^9.0.9",
"bits-ui": "^2.4.1",
"bits-ui": "^2.5.0",
"clsx": "^2.1.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.0.4",
"tailwind-merge": "^3.0.2",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",
@@ -39,6 +40,6 @@
"dayjs": "^1.11.13",
"jdenticon": "^3.3.0",
"jsonwebtoken": "^9.0.2",
"mode-watcher": "0.5.1"
"mode-watcher": "^1.0.6"
}
}

20
src/lib/Navbar.svelte Normal file
View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button';
import { LogOutIcon } from '@lucide/svelte';
</script>
<nav class="flex h-12 w-full items-center justify-center border border-b">
<div class="flex w-full max-w-screen-2xl flex-row">
<div class="flex-grow"></div>
<form
class={['flex flex-shrink flex-row-reverse justify-end', page.data.token == null && 'hidden']}
method="POST"
action="/?/logout"
use:enhance
>
<Button type="submit" variant="outline" size="icon"><LogOutIcon /></Button>
</form>
</div>
</nav>

View File

@@ -1 +1,9 @@
import { decode, type JwtPayload } from 'jsonwebtoken';
export const AUTH_COOIKIE_NAME: string = 'auth-token' as const;
export function getUserIdFromToken(token: string): number {
const decoded = decode(token) as JwtPayload;
return decoded.userId;
}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--popover); --normal-text: var(--popover-foreground); --normal-border: var(--border);"
{...restProps}
/>

View File

@@ -1,43 +1,22 @@
import { PUBLIC_BACKEND_URL } from '$env/static/public';
import { AUTH_COOIKIE_NAME } from '$lib/auth';
import { error } from '@sveltejs/kit';
import { AUTH_COOIKIE_NAME, getUserIdFromToken } from '$lib/auth';
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { decode, type JwtPayload } from 'jsonwebtoken';
export const load: LayoutServerLoad = async ({ fetch, cookies }) => {
export const load: LayoutServerLoad = async ({ cookies, url }) => {
let token = cookies.get(AUTH_COOIKIE_NAME);
if (token != null) {
const userId = getUserIdFromToken(token);
return {
token,
userId
};
if (token == null) {
if (url.pathname !== '/register' && url.pathname !== '/login') {
return redirect(303, '/login');
}
const response = await fetch(`${PUBLIC_BACKEND_URL}/auth`);
if (!response.ok) {
return error(response.status);
return { };
}
token = await response.text();
const userId = getUserIdFromToken(token);
cookies.set(AUTH_COOIKIE_NAME, token, {
path: '/',
secure: true
});
return {
token,
userId
};
};
function getUserIdFromToken(token: string): number {
const decoded = decode(token) as JwtPayload;
return decoded.userId;
}

View File

@@ -6,6 +6,8 @@
import { browser } from '$app/environment';
import { setMessageStore } from '$lib/message.svelte';
import type { LayoutServerData } from './$types.js';
import Navbar from '$lib/Navbar.svelte';
import { Toaster } from '$lib/components/ui/sonner';
interface Props {
children: Snippet;
@@ -26,8 +28,10 @@
</script>
<ModeWatcher />
<Toaster />
<div class="flex h-full w-full flex-col items-center">
<Navbar/>
<div class="my-8 flex h-full w-full max-w-screen-2xl flex-col">
{@render children()}
</div>

View File

@@ -1,4 +1,4 @@
import { error, fail, type Actions } from '@sveltejs/kit';
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
import { AUTH_COOIKIE_NAME } from '$lib/auth';
import type { PageServerLoad } from './$types';
@@ -47,5 +47,10 @@ export const actions = {
},
body: formData
});
},
logout: async ({ cookies }) => {
cookies.delete(AUTH_COOIKIE_NAME, { path: '/' });
redirect(303, '/login');
}
} satisfies Actions;

View File

@@ -0,0 +1,29 @@
import { PUBLIC_BACKEND_URL } from "$env/static/public";
import { fail, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { AUTH_COOIKIE_NAME } from "$lib/auth";
export const actions = {
login: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
const response = await fetch(`${PUBLIC_BACKEND_URL}/login`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
return fail(response.status, { message: 'Username or password are incorrect' });
}
const token = await response.text();
cookies.set(AUTH_COOIKIE_NAME, token, {
path: '/',
secure: true
});
return redirect(303, '/');
},
} satisfies Actions;

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { toast } from 'svelte-sonner';
import type { SubmitFunction } from './$types';
const submitLogin: SubmitFunction = async ({ formData }) => {
const username = formData.get('name');
return async ({ update, result }) => {
await update({
invalidateAll: true,
reset: true
});
if (result.type === 'redirect') {
toast.success('Login', {
description: `Successfully logged in as ${username}`
});
return;
}
if (result.type === 'failure') {
toast.error('Login', {
description: result.data?.message ?? 'Failed to login'
});
}
};
};
</script>
<form
class="mt-16 flex w-fit flex-col justify-center space-y-4 self-center"
method="POST"
action="?/login"
use:enhance={submitLogin}
>
<h1 class="scroll-m-20 text-xl font-semibold tracking-tight">Login</h1>
<div class="flex flex-col gap-1.5">
<Label for="name">Username</Label>
<Input
id="name"
name="name"
type="text"
class="w-fit min-w-48"
placeholder="cool-name"
minlength={2}
maxlength={24}
autocomplete="off"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="password">Password</Label>
<Input
id="password"
name="password"
type="password"
class="w-fit min-w-48"
placeholder="mysecretpassword123"
minlength={8}
maxlength={64}
autocomplete="off"
required
/>
</div>
<div class="flex flex-col gap-0.5">
<a class="text-muted-foreground text-sm" href="/register">Register instead?</a>
<Button type="submit">Login</Button>
</div>
</form>

View File

@@ -0,0 +1,29 @@
import { PUBLIC_BACKEND_URL } from "$env/static/public";
import { fail, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { AUTH_COOIKIE_NAME } from "$lib/auth";
export const actions = {
register: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
const response = await fetch(`${PUBLIC_BACKEND_URL}/register`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
return fail(response.status);
}
const token = await response.text();
cookies.set(AUTH_COOIKIE_NAME, token, {
path: '/',
secure: true
});
return redirect(303, '/');
},
} satisfies Actions;

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
</script>
<form
class="mt-16 flex w-fit flex-col justify-center space-y-4 self-center"
method="POST"
action="?/register"
use:enhance
>
<h1 class="scroll-m-20 text-xl font-semibold tracking-tight">Register</h1>
<div class="flex flex-col gap-1.5">
<Label for="name">Username</Label>
<Input
id="name"
name="name"
type="text"
class="w-fit min-w-48"
placeholder="cool-name"
minlength={2}
maxlength={24}
autocomplete="off"
required
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="password">Password</Label>
<Input
id="password"
name="password"
type="password"
class="w-fit min-w-48"
placeholder="mysecretpassword123"
minlength={8}
maxlength={64}
autocomplete="off"
required
/>
</div>
<div class="flex flex-col gap-0.5">
<a class="text-muted-foreground text-sm" href="/login">Login instead?</a>
<Button type="submit">Register</Button>
</div>
</form>