mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-08 02:07:39 +02:00
Merge branch 'feat/admin-setup' of github.com:open-reception/appointment-booking-software into feat/admin-setup
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,5 +5,9 @@ export const ROUTES = {
|
||||
CHECK_EMAIL: "/setup/check-email"
|
||||
},
|
||||
RESEND_CONFIRMATION: "/confirm/resend",
|
||||
LOGIN: "/login"
|
||||
LOGIN: "/login",
|
||||
LOGOUT: "/logout",
|
||||
DASHBOARD: {
|
||||
MAIN: "/dashboard"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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 } };
|
||||
};
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user