Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ services:
- backend
environment:
NODE_ENV: production
BACKEND_URL: http://backend:8000

db:
image: postgres:16-alpine
Expand Down
156 changes: 156 additions & 0 deletions frontend/src/lib/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
This project is licensed under Apache 2.0

src/lib/server/auth.ts
Auth helper functions for communicating with the backend
and managing auth cookies
*/

import type { Cookies } from '@sveltejs/kit';

import { getBackendApiV1BaseUrl } from './backend';

// Types
// -----

// see backend/app/api/v1/models.py
export type JwtTokenResponse = {
access_token: string;
token_type: 'bearer' | string;
};

// see backend/app/api/v1/models.py
export type BackendUser = {
id: number;
username: string;
created_at: string;
};

// Acces Tokens
// ------------

// name of the cookie for client-side retrieval
const ACCESS_TOKEN_COOKIE = 'access_token';

export function getAccessTokenFromCookies(cookies: Cookies): string | undefined {
return cookies.get(ACCESS_TOKEN_COOKIE);
}

export function setAccessTokenCookie(cookies: Cookies, params: { token: string; url: URL }): void {
cookies.set(ACCESS_TOKEN_COOKIE, params.token, {
httpOnly: true,
secure: params.url.protocol === 'https:',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7
});
}

export function clearAccessTokenCookie(cookies: Cookies): void {
cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' });
}

// Primary auth functions
// ----------------------

/**
* Logs in a user with a username and password
*
* @param fetchFn - pass in fetch from the load function or actions context
* @param params - username and password for login
* @returns JWT token on success
*/
export async function backendLoginWithPassword(
fetchFn: typeof fetch,
params: { username: string; password: string }
): Promise<JwtTokenResponse> {
const res = await fetchFn(`${getBackendApiV1BaseUrl()}/auth/token`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
username: params.username,
password: params.password
})
});

if (!res.ok) {
let detail = 'Login failed';
try {
const body = await res.json();
if (typeof body?.detail === 'string') detail = body.detail;
} catch {
// ignore
}
throw new Error(detail);
}

return (await res.json()) as JwtTokenResponse;
}

/**
* Registers a user with a username and password
*
* @param fetchFn - pass in fetch from the load function or actions context
* @param params - username and password for registration
* @returns JWT token on success
*/
export async function backendSignup(
fetchFn: typeof fetch,
params: { username: string; password: string }
): Promise<BackendUser> {
const res = await fetchFn(`${getBackendApiV1BaseUrl()}/auth/signup`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
username: params.username,
password: params.password
})
});

if (!res.ok) {
let detail = 'Signup failed';
try {
const body = await res.json();
if (typeof body?.detail === 'string') detail = body.detail;
} catch {
// ignore
}
throw new Error(detail);
}

return (await res.json()) as BackendUser;
}

/**
* Returns information about the currently authenticated user
*
* @param fetchFn - pass in fetch from the load function or actions context
* @param accessToken - the JWT token for authentication
* @returns user information on success
*/
export async function backendGetMe(fetchFn: typeof fetch, accessToken: string): Promise<BackendUser> {
const res = await fetchFn(`${getBackendApiV1BaseUrl()}/auth/me`, {
method: 'GET',
headers: {
authorization: `Bearer ${accessToken}`
}
});

if (!res.ok) {
let detail = 'Not authenticated';
try {
const body = await res.json();
if (typeof body?.detail === 'string') detail = body.detail;
} catch {
// ignore
}
throw new Error(detail);
}

return (await res.json()) as BackendUser;
}
19 changes: 19 additions & 0 deletions frontend/src/lib/server/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
This project is licensed under Apache 2.0

src/lib/server/backend.ts
Backend utility functions
*/

import { env } from '$env/dynamic/private';

// redundancy function; should be set by docker env variables for prod
// or the local .env file for dev
export function getBackendUrl(): string {
return env.BACKEND_URL ?? 'http://localhost:8000';
}

export function getBackendApiV1BaseUrl(): string {
return `${getBackendUrl()}/api/v1`;
}
33 changes: 33 additions & 0 deletions frontend/src/routes/+layout.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
This project is licensed under Apache 2.0

src/routes/+layout.server.ts
server-side logic shared between pages
acts as a middleware
*/

import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';

import { getAccessTokenFromCookies } from '$lib/server/auth';

function isAuthRoute(pathname: string): boolean {
return pathname === '/auth' || pathname.startsWith('/auth/');
}

export const load: LayoutServerLoad = async ({ url, cookies }) => {
const accessToken = getAccessTokenFromCookies(cookies);
const isAuthed = Boolean(accessToken);

if (!isAuthed && !isAuthRoute(url.pathname)) {
const redirectTo = `${url.pathname}${url.search}`;
throw redirect(303, `/auth/login?redirectTo=${encodeURIComponent(redirectTo)}`);
}

if (isAuthed && (url.pathname === '/auth/login' || url.pathname === '/auth/signup')) {
throw redirect(303, url.searchParams.get('redirectTo') || '/');
}

return {};
};
36 changes: 36 additions & 0 deletions frontend/src/routes/auth/login/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
This project is licensed under Apache 2.0

src/routes/auth/login/+page.server.ts
server-side logic for the login page
*/

import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

import { backendLoginWithPassword, setAccessTokenCookie } from '$lib/server/auth';

export const actions: Actions = {
default: async ({ request, cookies, fetch, url }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '').trim();
const password = String(form.get('password') ?? '');

if (!username || !password) {
return fail(400, { error: 'Username and password are required.' });
}

try {
const token = await backendLoginWithPassword(fetch, { username, password });
setAccessTokenCookie(cookies, { token: token.access_token, url });

const redirectTo = url.searchParams.get('redirectTo');
throw redirect(303, redirectTo || '/');
} catch (err) {
return fail(401, {
error: err instanceof Error ? err.message : 'Login failed.'
});
}
}
};
19 changes: 16 additions & 3 deletions frontend/src/routes/auth/login/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ src/routes/auth/login/+page.svelte
import PageHeader from '$lib/components/pageheader.svelte';
import Bento from '$lib/components/bento.svelte';
import Hint from '$lib/components/hint.svelte';
import { page } from '$app/state';

let { form } = $props();
</script>

<main class="max-w-[1400px] mx-auto px-6 md:px-12">
Expand All @@ -22,15 +25,23 @@ src/routes/auth/login/+page.svelte
post_add
</span>

<form class="space-y-8 relative z-10">
<form method="POST" class="space-y-8 relative z-10">
{#if form?.error}
<div class="rounded-DEFAULT bg-error-container text-on-error-container px-5 py-4 font-semibold text-sm">
{form.error}
</div>
{/if}

<div class="space-y-3">
<label class="block font-label text-sm uppercase tracking-widest text-on-surface-variant font-bold" for="username">
Username
</label>
<input
id="username"
name="username"
type="text"
placeholder="Username"
required
class="w-full bg-surface-container-low border-none focus:ring-0 rounded-DEFAULT px-6 py-4 text-on-surface placeholder:text-outline text-lg outline outline-2 outline-transparent focus:outline-primary/30 outline-offset-2"
/>
</div>
Expand All @@ -41,19 +52,21 @@ src/routes/auth/login/+page.svelte
</label>
<input
id="password"
name="password"
type="password"
placeholder="Password"
required
class="w-full bg-surface-container-low border-none focus:ring-0 rounded-DEFAULT px-6 py-4 text-on-surface placeholder:text-outline text-lg outline outline-2 outline-transparent focus:outline-primary/30 outline-offset-2"
/>
</div>

<a href="/auth/signup" class="text-sm text-primary hover:underline">
<a href={'/auth/signup' + page.url.search} class="text-sm text-primary hover:underline">
Don't have an account? Signup here.
</a>

<div class="pt-6">
<button
type="button"
type="submit"
class="w-full md:w-auto md:px-12 bg-gradient-to-r from-primary to-primary-container text-on-primary rounded-pill py-4 text-lg font-bold flex items-center justify-center gap-3 transition-transform hover:scale-[0.99] focus:outline-none"
>
Login
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/routes/auth/signup/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
Copyright (c) 2026 Lachlan Harris. All Rights Reserved.
This project is licensed under Apache 2.0

src/routes/auth/signup/+page.server.ts
server-side logic for the signup page
*/

import { fail, redirect, type Actions } from '@sveltejs/kit';

import { backendLoginWithPassword, backendSignup, setAccessTokenCookie } from '$lib/server/auth';

export const actions: Actions = {
default: async ({ request, cookies, fetch, url }) => {
const form = await request.formData();
const username = String(form.get('username') ?? '').trim();
const password = String(form.get('password') ?? '');

if (!username || !password) {
return fail(400, { error: 'Username and password are required.' });
}

try {
await backendSignup(fetch, { username, password });
const token = await backendLoginWithPassword(fetch, { username, password });
setAccessTokenCookie(cookies, { token: token.access_token, url });

const redirectTo = url.searchParams.get('redirectTo');
throw redirect(303, redirectTo || '/');
} catch (err) {
return fail(400, {
error: err instanceof Error ? err.message : 'Signup failed.'
});
}
}
};
Loading
Loading