Merge branch 'feat/admin-setup' of github.com:open-reception/appointment-booking-software into feat/admin-setup

This commit is contained in:
Hendrik Belitz
2025-08-22 16:07:25 +02:00
23 changed files with 650 additions and 80 deletions
+24 -3
View File
@@ -42,13 +42,28 @@
"passphraseRequirements": "Mindestens 30 Zeichen.",
"errors": {
"email": "Ungültiges Format",
"passphrase": "Muss mindestens 30 Zeichen lang sein"
}
"passphrase": "Muss mindestens 30 Zeichen lang sein",
"noPassAtAll": "Either passphrase or passkey is required",
"bothPassSet": "Nur Passphrase oder Passkey darf gesetzt sein"
},
"passkey": "Passkey"
},
"login": {
"or": "Oder",
"usePasskey": "Passkey verwenden",
"usePassphrase": "Passphrase verwenden"
"usePassphrase": "Passphrase verwenden",
"title": "Anmelden",
"description_passphrase": "Gib Deine E-Mail-Adresse und Deinen Passphrase ein, um Dich anzumelden.",
"action": "Anmelden",
"error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen",
"passkey": {
"initial": "Warte auf Absenden des Formulars",
"success": "Passkey bereit",
"error": "Passkey konnte nicht abgerufen werden",
"user": "Warte während Passkey erstellt wird",
"loading": "Teste Passkey",
"retry": "Erneut versuchen"
}
},
"confirm": {
"success": {
@@ -68,5 +83,11 @@
"action": "E-Mail erneut senden",
"success": "E-Mail gesendet"
}
},
"logout": {
"title": "Erfolgreich abgemeldet",
"description": "Du wurdest abgemeldet.",
"success": "Erfolgreich abgemeldet",
"action": "Zur Anmeldung"
}
}
+30 -3
View File
@@ -43,14 +43,35 @@
"passphraseRequirements": "At least 30 characters.",
"errors": {
"email": "Invalid format",
"passphrase": "Must be at least 30 characters long"
}
"passphrase": "Must be at least 30 characters long",
"noPassAtAll": "Either passphrase or passkey is required",
"bothPassSet": "Only one of passphrase or passkey should be provided"
},
"noPassAtAll": "Either passphrase or passkey is required",
"passkey": "Passkey"
},
"login": {
"or": "Or",
"usePasskey": "use passkey",
"usePassphrase": "use passphrase",
"orUse": "Or {button}"
"orUse": "Or {button}",
"title": "Login",
"description_passphrase": "Enter your email and passphrase below to access your account.",
"action": "Login",
"error": "Login failed. Please retry",
"passphrase": {
"initial": "Requesting passkey",
"success": "Passkey saved",
"error": "Passkey could not be retrieved"
},
"passkey": {
"initial": "Waiting for form to be sent",
"success": "Added Passkey successfully",
"error": "Passkey could not be added",
"user": "Waiting for passkey to be created",
"loading": "Completing challenge",
"retry": "Retry"
}
},
"confirm": {
"success": {
@@ -70,5 +91,11 @@
"action": "Resend E-Mail",
"success": "E-Mail sent"
}
},
"logout": {
"title": "Logout complete",
"description": "You are logged-out.",
"success": "Logout successful",
"action": "Go to Login"
}
}
+4 -2
View File
@@ -5,7 +5,8 @@ import { corsHandle } from "./server-hooks/corsHandle";
import { loggingHandle } from "./server-hooks/loggingHandle";
import { rateLimitHandle } from "./server-hooks/rateLimitHandle";
import { secHeaderHandle } from "./server-hooks/secHeaderHandle";
import { authHandle } from "./server-hooks/authHandle";
import { apiAuthHandle } from "./server-hooks/apiAuthHandle";
import { authGuard } from "./server-hooks/authGuard";
import { i18nHandle } from "./server-hooks/i18nHandle";
import { building } from "$app/environment";
@@ -40,5 +41,6 @@ export const handle = sequence(
rateLimitHandle,
corsHandle,
secHeaderHandle,
authHandle
apiAuthHandle,
authGuard
);
+2 -2
View File
@@ -24,7 +24,7 @@
bind:this={ref}
data-slot="input"
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
@@ -39,7 +39,7 @@
bind:this={ref}
data-slot="input"
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
+5
View File
@@ -0,0 +1,5 @@
import State from "./state.svelte";
const Passkey = { State };
export { Passkey };
@@ -0,0 +1,45 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
import { Text } from "../typography";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
import Check from "@lucide/svelte/icons/check-circle";
import Error from "@lucide/svelte/icons/ban";
import { m } from "$i18n/messages";
export type PasskeyState = "initial" | "user" | "loading" | "success" | "error";
let {
ref = $bindable(null),
state,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLHeadingElement>> & {
state: PasskeyState;
} = $props();
</script>
<div
bind:this={ref}
class={cn(
"dark:bg-input/30 border-input ring-offset-background flex h-9 w-full min-w-0 items-center gap-2 rounded-md border bg-transparent px-3 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
className
)}
{...restProps}
>
{#if state === "initial"}
<Loader2Icon class="size-4 animate-spin" />
{m["login.passkey.initial"]()}
{:else if state === "user"}
<Loader2Icon class="size-4 animate-spin" />
{m["login.passkey.user"]()}
{:else if state === "loading"}
<Loader2Icon class="size-4 animate-spin" />
{m["login.passkey.loading"]()}
{:else if state === "success"}
<Check class="size-4" />
{m["login.passkey.success"]()}
{:else if state === "error"}
<Error class="size-4" />
{m["login.passkey.error"]()}
{/if}
</div>
+5 -1
View File
@@ -5,5 +5,9 @@ export const ROUTES = {
CHECK_EMAIL: "/setup/check-email"
},
RESEND_CONFIRMATION: "/confirm/resend",
LOGIN: "/login"
LOGIN: "/login",
LOGOUT: "/logout",
DASHBOARD: {
MAIN: "/dashboard"
}
};
+1 -1
View File
@@ -189,7 +189,7 @@ export class WebAuthnService {
* This should be stored in the session and used for verification
*/
static generateChallenge(): string {
return randomBytes(32).toString("base64url");
return randomBytes(32).toString("base64");
}
/**
+69
View File
@@ -0,0 +1,69 @@
export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer);
let binary = "";
// Simple loop, no fancy operations
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
};
function base64ToArrayBuffer(base64: string) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
export const fetchChallenge = async (email: string) => {
const resp = await fetch("/api/auth/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email })
});
try {
const data = await resp.json();
return {
id: data.rpId,
challenge: data.challenge
};
} catch {
return null;
}
};
export const generatePasskey = async ({
id,
challenge,
email
}: {
id: string;
challenge: string;
email: string;
}): Promise<{ response: AuthenticatorAttestationResponse } | null> => {
const publicKey: PublicKeyCredentialCreationOptions = {
challenge: base64ToArrayBuffer(challenge),
rp: {
id,
name: "Open Reception"
},
user: {
id: new Uint8Array(16),
name: email,
displayName: email
},
pubKeyCredParams: [
{ alg: -7, type: "public-key" } // ES256
]
};
return (await navigator.credentials.create({ publicKey })) as {
response: AuthenticatorAttestationResponse;
} | null;
};
@@ -16,7 +16,6 @@ export const load: PageServerLoad = async (event) => {
const success = resp.status < 400;
try {
const body = await resp.json();
// TODO: Check if this is the first account
const isSetup = body.isSetup ?? false;
return { success, isSetup };
} catch (error) {
@@ -0,0 +1,2 @@
Hello from Dashboard<br />
<a href="/logout" class="underline">Logout</a>
+39
View File
@@ -0,0 +1,39 @@
import { fail } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import type { Actions, PageServerLoad } from "./$types";
import { formSchema } from "./schema";
export const load: PageServerLoad = async () => {
return {
form: await superValidate(zod(formSchema))
};
};
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod(formSchema));
if (!form.valid) {
return fail(400, {
form
});
}
const resp = await event.fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ email: form.data.email, passphrase: form.data.passphrase })
});
if (resp.status < 400) {
return { form };
} else {
return fail(400, {
form
});
}
}
};
+53 -1
View File
@@ -1 +1,53 @@
Hello from login
<script lang="ts">
import * as Form from "$lib/components/ui/form";
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { PageWithClaim } from "$lib/components/ui/page";
import LoginForm from "./login-form.svelte";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
export let data;
const formId = "create-account-form";
let isSubmitting = false;
const onEvent: EventReporter = (params) => {
if (params.isSubmitting) {
isSubmitting = true;
}
if (params.isSubmitting === false) {
isSubmitting = false;
}
};
</script>
<svelte:head>
<title>{m["login.title"]()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Header>
<CenteredCard.Title>
{m["login.title"]()}
</CenteredCard.Title>
<CenteredCard.Description>
{m["login.description_passphrase"]()}
</CenteredCard.Description>
</CenteredCard.Header>
<CenteredCard.Main>
<LoginForm {formId} {data} {onEvent} />
</CenteredCard.Main>
<CenteredCard.Action>
<Form.Button
size="lg"
class="w-full"
form={formId}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["login.action"]()}
</Form.Button>
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -0,0 +1,86 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { m } from "$i18n/messages.js";
import { Button } from "$lib/components/ui/button";
import * as Form from "$lib/components/ui/form";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
import { Input } from "$lib/components/ui/input";
import { ROUTES } from "$lib/const/routes";
import { toast } from "svelte-sonner";
import { writable } from "svelte/store";
import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import { formSchema, type FormSchema } from "./schema";
let {
data,
formId,
onEvent
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
await goto(ROUTES.DASHBOARD.MAIN);
} else {
toast.error(m["login.error"]());
}
onEvent({ isSubmitting: false });
},
onSubmit: () => onEvent({ isSubmitting: true })
});
const { form: formData, enhance } = form;
const isUsingPasskey = writable(false);
</script>
<Form.Root {formId} {enhance}>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="passphrase">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.passphrase"]()}</Form.Label>
<Input
{...props}
bind:value={$formData.passphrase}
type="password"
minlength={30}
maxlength={100}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description>
{m["login.or"]()}
{#if $isUsingPasskey}
<Button
variant="link"
size="sm"
onclick={() => isUsingPasskey.set(false)}
class="text-inherit"
>
{m["login.usePassphrase"]()}
</Button>
{:else}
<Button
variant="link"
size="sm"
onclick={() => isUsingPasskey.set(true)}
class="text-inherit"
>
{m["login.usePasskey"]()}
</Button>
{/if}.
</Form.Description>
</Form.Field>
</Form.Root>
+9
View File
@@ -0,0 +1,9 @@
import { m } from "$i18n/messages";
import { z } from "zod";
export const formSchema = z.object({
email: z.string().email(m["form.errors.email"]()),
passphrase: z.string().min(30, m["form.errors.passphrase"]())
});
export type FormSchema = typeof formSchema;
+18
View File
@@ -0,0 +1,18 @@
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const success: Promise<boolean> = event
.fetch("/api/auth/logout", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})
.then(async (resp) => {
// TODO: Why is this failing?
console.log("resp", resp, await resp.json());
return resp.status < 400;
});
return { streaming: { success } };
};
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { CenterLoadingState, CenterState } from "$lib/components/templates/empty-state";
import { Button } from "$lib/components/ui/button";
import { PageWithClaim } from "$lib/components/ui/page";
import { Skeleton } from "$lib/components/ui/skeleton";
import { ROUTES } from "$lib/const/routes.js";
import Check from "@lucide/svelte/icons/check";
export let data;
</script>
<svelte:head>
<title>{m["logout.title"]()} - OpenReception</title>
</svelte:head>
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Main>
{#await data.streaming.success}
<CenterLoadingState />
{:then}
<CenterState
Icon={Check}
headline={m["logout.title"]()}
description={m["logout.description"]()}
/>
{/await}
</CenteredCard.Main>
<CenteredCard.Action>
{#await data.streaming.success}
<Skeleton class="h-10 w-full" />
{:then}
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
{m["logout.action"]()}
</Button>
{/await}
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -25,19 +25,30 @@ export const actions: Actions = {
}
// Create admin account
const admin = await UserService.createUser({
name: "Admin",
email: form.data.email,
passphrase: form.data.passphrase,
language: form.data.language
}, event.url);
const hasPasskey = false;
const admin = await UserService.createUser(
{
name: "Admin",
email: form.data.email,
passphrase: form.data.passphrase, // Will be undefined if passkey is used
language: form.data.language
},
event.url
);
if (form.data.passkey) {
await UserService.addPasskey(admin.id, {
id: form.data.passkey.id,
publicKey: form.data.passkey.publicKey,
counter: form.data.passkey.counter || 0,
deviceName: form.data.passkey.deviceName || "Unknown Device"
});
}
log.debug("Admin account created successfully", {
adminId: admin.id,
email: admin.email,
authMethod: hasPasskey ? "passkey" : "passphrase"
// passkeyId: hasPasskey ? body.passkey.id : undefined
authMethod: form.data.passkey ? "passkey" : "passphrase",
passkeyId: form.data.passkey ? form.data.passkey.id : undefined
});
return { form };
@@ -1,18 +1,23 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { m } from "$i18n/messages.js";
import { getLocale } from "$i18n/runtime.js";
import { Button } from "$lib/components/ui/button";
import * as Form from "$lib/components/ui/form";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
import { Input } from "$lib/components/ui/input";
import { Passkey } from "$lib/components/ui/passkey";
import { ROUTES } from "$lib/const/routes";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import { writable, type Writable } from "svelte/store";
import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms";
import { zodClient } from "sveltekit-superforms/adapters";
import { formSchema, type FormSchema } from "./schema";
import { Button } from "$lib/components/ui/button";
import { writable } from "svelte/store";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { getLocale } from "$i18n/runtime.js";
import { onMount } from "svelte";
import { Text } from "$lib/components/ui/typography";
import { Label } from "$lib/components/ui/label";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey";
let {
data,
@@ -33,17 +38,81 @@
onEvent({ isSubmitting: false });
},
onSubmit: () => onEvent({ isSubmitting: true })
onSubmit: () => {
onEvent({ isSubmitting: true });
if ($isUsingPasskey) {
$passkeyLoading = "user";
fetchChallenge($formData.email).then((challenge) => {
if (!challenge) {
$passkeyLoading = "error";
} else {
generatePasskey({ ...challenge, email: $formData.email })
.then(async (passkeyResp) => {
console.log("passkeyResp", passkeyResp);
if (!passkeyResp) {
throw "Unusable passkey response";
}
// Returns ArrayBuffer that has to be converted to base64 string
const publicKey = passkeyResp.response.getPublicKey();
console.log("publicKey", publicKey);
if (!publicKey) {
throw "Public key not found in passkey response";
}
// May include device name and counter
const authenticatorData = passkeyResp.response.getAuthenticatorData();
console.log("authenticatorData", authenticatorData);
try {
const publicKeyBase64 = arrayBufferToBase64(publicKey);
console.log("publicKeyBase64", publicKeyBase64);
} catch (error) {
console.log("error", error);
throw error;
}
// $formData.passkey = {
// id: passkeyResp.id,
// publicKey,
// counter: 0,
// deviceName: passkeyResp.response.getClientExtensionResults().deviceName || "Unknown Device"
// };
})
.catch(() => {
$passkeyLoading = "error";
});
}
});
}
}
});
const { form: formData, enhance } = form;
const isUsingPasskey = writable(false);
const { form: formData, enhance, ...rest } = form;
const isUsingPasskey = writable(true);
const passkeyLoading: Writable<PasskeyState> = writable("initial");
onMount(() => {
if (!$formData.language) {
$formData.language = getLocale() ?? "en";
}
});
$effect(() => {
if ($isUsingPasskey) {
formData.update((data) => ({
...data,
passphrase: ""
}));
} else {
formData.update((data) => ({
...data,
passkey: undefined
}));
}
});
</script>
<Form.Root {formId} {enhance}>
@@ -63,7 +132,7 @@
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="passphrase">
<Form.Field {form} name="passphrase" hidden={$isUsingPasskey}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.passphrase"]()}</Form.Label>
@@ -80,25 +149,29 @@
<Form.Description>
{m["form.passphraseRequirements"]()}
{m["login.or"]()}
{#if $isUsingPasskey}
<Button
variant="link"
size="sm"
onclick={() => isUsingPasskey.set(false)}
class="text-inherit"
>
{m["login.usePassphrase"]()}
</Button>
{:else}
<Button
variant="link"
size="sm"
onclick={() => isUsingPasskey.set(true)}
class="text-inherit"
>
{m["login.usePasskey"]()}
</Button>
{/if}.
<Button
variant="link"
size="sm"
onclick={() => ($isUsingPasskey = true)}
class="text-inherit"
>
{m["login.usePasskey"]()}
</Button>.
</Form.Description>
</Form.Field>
<div class:hidden={!$isUsingPasskey}>
<Label class="mb-2">{m["form.passkey"]()}</Label>
<Passkey.State state={$passkeyLoading} />
<Text style="md" color="medium">
{m["login.or"]()}
<Button
variant="link"
size="sm"
onclick={() => ($isUsingPasskey = false)}
class="text-inherit"
>
{m["login.usePassphrase"]()}
</Button>.
</Text>
</div>
</Form.Root>
@@ -1,10 +1,27 @@
import { m } from "$i18n/messages";
import { z } from "zod";
export const formSchema = z.object({
email: z.string().email(m["form.errors.email"]()),
passphrase: z.string().min(30, m["form.errors.passphrase"]()),
language: z.enum(["de", "en"])
});
export const formSchema = z
.object({
email: z.string().email(m["form.errors.email"]()),
passphrase: z.string().min(30, m["form.errors.passphrase"]()).optional(),
passkey: z
.object({
id: z.string().min(10),
publicKey: z.string().min(10),
counter: z.number().min(0),
deviceName: z.string().min(1).optional()
})
.optional(),
language: z.enum(["de", "en"])
})
.refine((data) => !!data.passphrase || !!data.passkey, {
message: m["form.errors.noPassAtAll"](),
path: ["passphrase"]
})
.refine((data) => !(data.passphrase && data.passkey), {
message: m["form.errors.bothPassSet"](),
path: ["passphrase"]
});
export type FormSchema = typeof formSchema;
@@ -2,12 +2,13 @@ import type { Handle } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import { UniversalLogger } from "$lib/logger";
import { AuthorizationService } from "$lib/server/auth/authorization-service";
import { getAccessToken } from "./utils/accessToken";
const logger = new UniversalLogger().setContext("AuthHandle");
const ACCESS_TOKEN_COOKIE_NAME = "access_token";
const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin", "/api/tenants", "/api/auth/register"];
const PUBLIC_PATHS = [
"/",
"/api/auth/challenge",
"/api/auth/login",
"/api/auth/register",
@@ -33,8 +34,8 @@ const PROTECTED_AUTH_PATHS = [
"/api/auth/invite"
];
export const authHandle: Handle = async ({ event, resolve }) => {
const { url, request } = event;
export const apiAuthHandle: Handle = async ({ event, resolve }) => {
const { url } = event;
const path = url.pathname;
if (!path.startsWith("/api")) {
@@ -63,22 +64,7 @@ export const authHandle: Handle = async ({ event, resolve }) => {
);
}
let accessToken: string | null = null;
// Get access token from cookie
const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME);
if (accessTokenCookie) {
accessToken = accessTokenCookie;
}
// Fallback: check Authorization header
if (!accessToken) {
const authHeader = request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
accessToken = authHeader.substring(7);
}
}
const accessToken: string | null = getAccessToken(event);
if (!accessToken) {
logger.warn(`Authentication required for ${path}`);
return new Response(JSON.stringify({ error: "Authentication required" }), {
@@ -104,8 +90,6 @@ export const authHandle: Handle = async ({ event, resolve }) => {
sessionId: sessionData.sessionId
};
logger.debug(`Added user information for ${sessionData.user.id}`);
if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) {
return new Response(JSON.stringify({ error: "Authentication failed" }), {
status: 403,
+42
View File
@@ -0,0 +1,42 @@
import { redirect, type Handle } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import { AuthorizationService } from "$lib/server/auth/authorization-service";
import { getAccessToken } from "./utils/accessToken";
import { ROUTES } from "$lib/const/routes";
export const authGuard: Handle = async ({ event, resolve }) => {
const { url } = event;
const path = url.pathname;
// Do not handle api paths
if (path.startsWith("/api")) {
return resolve(event);
}
// Only guard protected routes
const isDashboardRoute = path.startsWith(ROUTES.DASHBOARD.MAIN);
if (isDashboardRoute) {
// Check for accessToken
const accessToken: string | null = getAccessToken(event);
if (!accessToken) redirect(302, ROUTES.LOGIN);
// Verify access token with database session check
const sessionData = await SessionService.validateTokenWithDB(accessToken);
if (!sessionData) redirect(302, ROUTES.LOGIN);
switch (true) {
case isDashboardRoute &&
AuthorizationService.hasAnyRole(sessionData.user, [
"GLOBAL_ADMIN",
"TENANT_ADMIN",
"STAFF"
]):
return resolve(event);
default:
// Access not granted
redirect(302, ROUTES.LOGIN);
}
}
return resolve(event);
};
+24
View File
@@ -0,0 +1,24 @@
import type { RequestEvent } from "@sveltejs/kit";
const ACCESS_TOKEN_COOKIE_NAME = "access_token";
export const getAccessToken = (
event: RequestEvent<Partial<Record<string, string>>, string | null>
): string | null => {
let accessToken: string | null = null;
// Get access token from cookie
const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME);
if (accessTokenCookie) {
accessToken = accessTokenCookie;
}
// Fallback: check Authorization header
if (!accessToken) {
const authHeader = event.request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
accessToken = authHeader.substring(7);
}
}
return accessToken;
};