mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-26 19:04:50 +02:00
Added passphrase change for global admins
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
"errors": {
|
||||
"email": "Ungültiges Format",
|
||||
"passphrase": "Muss mindestens 30 Zeichen lang sein",
|
||||
"passphraseMismatch": "Ist nicht mit dem Passphrase identisch",
|
||||
"noPassAtAll": "Either passphrase or passkey is required",
|
||||
"bothPassSet": "Nur Passphrase oder Passkey darf gesetzt sein",
|
||||
"name": "Muss mindestens 2 Zeichen lang sein",
|
||||
@@ -813,7 +814,17 @@
|
||||
"change-passphrase": {
|
||||
"navItem": "Passphrase",
|
||||
"title": "Change Passphrase",
|
||||
"description": "Globale Admins können einen Passphrase als Backup-Methode verwenden."
|
||||
"description": "Globale Admins können einen Passphrase als Backup-Methode verwenden.",
|
||||
"currentPassphrase": "Aktueller Passphrase",
|
||||
"newPassphrase": "Neuer Passphrase",
|
||||
"repeatedPassphrase": "Neuer Passphrase (Wiederholung)",
|
||||
"action": "Passphrase ändern",
|
||||
"success": "Passphrase geändert",
|
||||
"errors": {
|
||||
"notAGlobalAdmin": "Du darfst keinen Passphrase einrichten",
|
||||
"passphraseIncorrect": "Aktueller Passphrase falsch",
|
||||
"unknown": "Passphrase konnte nicht geändert werden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"staff": {
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"errors": {
|
||||
"email": "Invalid format",
|
||||
"passphrase": "Must be at least 30 characters long",
|
||||
"passphraseMismatch": "Does not match the new passphrase",
|
||||
"noPassAtAll": "Either passphrase or passkey is required",
|
||||
"bothPassSet": "Only one of passphrase or passkey should be provided",
|
||||
"name": "Must be at least 2 characters",
|
||||
@@ -821,7 +822,17 @@
|
||||
"change-passphrase": {
|
||||
"navItem": "Passphrase",
|
||||
"title": "Change Passphrase",
|
||||
"description": "Global Admins can set strong passphrases as a backup login method."
|
||||
"description": "Global Admins can set strong passphrases as a backup login method.",
|
||||
"currentPassphrase": "Current Passphrase",
|
||||
"newPassphrase": "New Passphrase",
|
||||
"repeatedPassphrase": "New Passphrase (repeat)",
|
||||
"action": "Change Passphrase",
|
||||
"success": "Passphrase changed",
|
||||
"errors": {
|
||||
"notAGlobalAdmin": "You are now allowed to add a passphrase",
|
||||
"passphraseIncorrect": "Current Passphrase is incorrect",
|
||||
"unknown": "Passphrase could not be updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"staff": {
|
||||
|
||||
+11
-9
@@ -13,18 +13,20 @@ export interface PasskeyAuthData {
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
refreshPromise: Promise<Response> | null;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
language: SupportedLocale;
|
||||
// The currently selected tenant
|
||||
tenantId?: string | null;
|
||||
};
|
||||
user?: AuthStateUser;
|
||||
passkeyAuthData?: PasskeyAuthData;
|
||||
}
|
||||
|
||||
export type AuthStateUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
language: SupportedLocale;
|
||||
// The currently selected tenant
|
||||
tenantId?: string | null;
|
||||
};
|
||||
|
||||
function createAuthStore() {
|
||||
const store = writable<AuthState>({
|
||||
isAuthenticated: false,
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages.js";
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
|
||||
let account = $derived($auth.user);
|
||||
const form = superForm(
|
||||
{
|
||||
passphrase: "",
|
||||
newPassphrase: "",
|
||||
repeatedPassphrase: "",
|
||||
},
|
||||
{
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["account.change-passphrase.success"]());
|
||||
} else if (event.result.type === "failure") {
|
||||
switch (event.result.status) {
|
||||
case 401:
|
||||
toast.error(m["account.change-passphrase.errors.passphraseIncorrect"]());
|
||||
break;
|
||||
case 403:
|
||||
toast.error(m["account.change-passphrase.errors.notAGlobalAdmin"]());
|
||||
break;
|
||||
default:
|
||||
toast.error(m["account.change-passphrase.errors.unknown"]());
|
||||
break;
|
||||
}
|
||||
}
|
||||
isSubmitting = false;
|
||||
},
|
||||
onSubmit: () => (isSubmitting = true),
|
||||
},
|
||||
);
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
</script>
|
||||
|
||||
{#if account}
|
||||
<Form.Root {enhance} action="?/edit">
|
||||
<Form.Field {form} name="passphrase">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["account.change-passphrase.currentPassphrase"]()}</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.passphrase}
|
||||
type="password"
|
||||
minlength={30}
|
||||
maxlength={100}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="newPassphrase">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["account.change-passphrase.newPassphrase"]()}</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.newPassphrase}
|
||||
type="password"
|
||||
minlength={30}
|
||||
maxlength={100}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description>
|
||||
{m["form.passphraseRequirements"]()}
|
||||
</Form.Description>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="repeatedPassphrase">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["account.change-passphrase.repeatedPassphrase"]()}</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.repeatedPassphrase}
|
||||
type="password"
|
||||
minlength={30}
|
||||
maxlength={100}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<div class="mt-6 flex flex-col gap-4">
|
||||
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
|
||||
{m["account.change-passphrase.action"]()}
|
||||
</Form.Button>
|
||||
</div>
|
||||
</Form.Root>
|
||||
{/if}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import ChangePassphraseForm from "./change-passphrase-form.svelte";
|
||||
|
||||
export { ChangePassphraseForm };
|
||||
export { formSchema } from "./schema";
|
||||
export type { FormSchema } from "./schema";
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { m } from "$i18n/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z
|
||||
.object({
|
||||
passphrase: z.string().min(30, m["form.errors.passphrase"]()),
|
||||
newPassphrase: z.string().min(30, m["form.errors.passphrase"]()),
|
||||
repeatedPassphrase: z.string().min(30, m["form.errors.passphrase"]()),
|
||||
})
|
||||
.check((ctx) => {
|
||||
if (ctx.value.newPassphrase !== ctx.value.repeatedPassphrase) {
|
||||
ctx.issues.push({
|
||||
code: "custom",
|
||||
message: m["form.errors.passphraseMismatch"](),
|
||||
path: ["repeatedPassphrase"],
|
||||
input: ctx.value.repeatedPassphrase,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type FormSchema = typeof formSchema;
|
||||
@@ -0,0 +1,49 @@
|
||||
import logger from "$lib/logger";
|
||||
import { fail, type Actions } from "@sveltejs/kit";
|
||||
import { superValidate } from "sveltekit-superforms";
|
||||
import { zod4 as zod } from "sveltekit-superforms/adapters";
|
||||
import { formSchema as editFormSchema } from "./(components)/change-passphrase-form";
|
||||
|
||||
const log = logger.setContext(import.meta.filename);
|
||||
|
||||
export const actions: Actions = {
|
||||
edit: async (event) => {
|
||||
const form = await superValidate(event, zod(editFormSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
log.error("Edit account settings form is not valid", { errors: form.errors });
|
||||
return fail(400, {
|
||||
form: { ...form, data: { ...form.data } },
|
||||
error: "Form is not valid",
|
||||
});
|
||||
}
|
||||
|
||||
const resp = await event.fetch(`/api/me/passphrase`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
passphrase: form.data.passphrase,
|
||||
newPassphrase: form.data.newPassphrase,
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.status < 400) {
|
||||
return { form };
|
||||
} else {
|
||||
let error = "Unknown error";
|
||||
try {
|
||||
const body = await resp.json();
|
||||
error = body.error;
|
||||
} catch (e) {
|
||||
log.error("Failed to parse edit account settings error response", { error: e });
|
||||
}
|
||||
return fail(resp.status, {
|
||||
form: { ...form, data: { ...form.data } },
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@
|
||||
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
|
||||
import { Headline } from "$lib/components/ui/typography";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import { ChangePassphraseForm } from "./(components)/change-passphrase-form";
|
||||
</script>
|
||||
|
||||
<SidebarLayout
|
||||
@@ -20,5 +21,6 @@
|
||||
>
|
||||
<MaxPageWidth maxWidth="md" class="flex flex-col gap-6">
|
||||
<Headline level="h1" style="h3">{m["account.change-passphrase.title"]()}</Headline>
|
||||
<ChangePassphraseForm />
|
||||
</MaxPageWidth>
|
||||
</SidebarLayout>
|
||||
|
||||
+25
-18
@@ -3,7 +3,7 @@
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { supportedLocales, translatedLocales } from "$lib/const/locales";
|
||||
import { supportedLocales, translatedLocales, type SupportedLocale } from "$lib/const/locales";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { untrack } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -15,41 +15,48 @@
|
||||
const form = superForm(
|
||||
{
|
||||
name: untrack(() => account?.name || ""),
|
||||
language: untrack(() => account?.language) as string,
|
||||
language: untrack(() => account?.language as string),
|
||||
},
|
||||
{
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
if (event.result.type === "success") {
|
||||
if (account) {
|
||||
auth.setUser({
|
||||
...account,
|
||||
name: event.result.data?.form.data.name,
|
||||
language: event.result.data?.form.data.language,
|
||||
});
|
||||
}
|
||||
toast.success(m["account.general.success"]());
|
||||
} else if (event.result.type === "failure") {
|
||||
toast.error(m["account.general.error"]());
|
||||
}
|
||||
isSubmitting = false;
|
||||
},
|
||||
onUpdated: ({ form }) => {
|
||||
if (account) {
|
||||
auth.setUser({
|
||||
...account,
|
||||
name: form.data.name,
|
||||
language: form.data.language as SupportedLocale,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubmit: () => (isSubmitting = true),
|
||||
},
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
// Fixes hard reload of page resulting in empty form
|
||||
if (!$formData.name && account?.name) {
|
||||
$formData.name = account.name;
|
||||
$formData.language = account.language;
|
||||
}
|
||||
});
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
const { form: formData, enhance, reset } = form;
|
||||
|
||||
$effect(() => {
|
||||
if (account) {
|
||||
untrack(() =>
|
||||
reset({
|
||||
data: {
|
||||
name: account.name,
|
||||
language: account.language,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if account}
|
||||
|
||||
@@ -110,7 +110,6 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
updateData,
|
||||
});
|
||||
|
||||
// Delete appointment with authentication verification
|
||||
const user = await UserService.updateUser(locals.user.id, updateData);
|
||||
|
||||
log.debug("User updated", {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* API Route: Dashboard User manages their own account
|
||||
*/
|
||||
|
||||
import { logger } from "$lib/logger";
|
||||
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
import { challengeThrottleService } from "$lib/server/services/challenge-throttle";
|
||||
import { UserService } from "$lib/server/services/user-service";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
BackendError,
|
||||
InternalError,
|
||||
logError,
|
||||
ValidationError,
|
||||
} from "$lib/server/utils/errors";
|
||||
import { hashPassphrase, verifyPassphrase } from "$lib/server/utils/passphrase";
|
||||
import type { RequestHandler } from "@sveltejs/kit";
|
||||
import { json } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
|
||||
const requestSchema = z.object({
|
||||
passphrase: z.string().min(30).max(100).optional(),
|
||||
newPassphrase: z.string().min(30).max(100),
|
||||
});
|
||||
|
||||
// Register OpenAPI documentation for DELETE
|
||||
registerOpenAPIRoute("/me/passphrase", "PUT", {
|
||||
summary: "Update current account passphrase",
|
||||
description: "Allows a global admins to update their passphrase.",
|
||||
tags: ["Staff", "Account"],
|
||||
requestBody: {
|
||||
description: "Account data",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
passphrase: {
|
||||
type: "string",
|
||||
description: "Current passphrase",
|
||||
},
|
||||
newPassphrase: {
|
||||
type: "string",
|
||||
description: "New passphrase",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Passphrase changed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Invalid request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": {
|
||||
description: "Unauthorized",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Forbidden. This account cannot set a passphrase",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"500": {
|
||||
description: "Internal server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
const log = logger.setContext("API.Me.Passphrase");
|
||||
|
||||
try {
|
||||
if (!locals.user) {
|
||||
throw new AuthenticationError("Unauthorized");
|
||||
}
|
||||
|
||||
if (locals.user.role !== "GLOBAL_ADMIN") {
|
||||
throw new AuthorizationError("This user cannot set a passphrase");
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const updateData = requestSchema.parse(body);
|
||||
|
||||
const user = await UserService.getUserByEmail(locals.user.email);
|
||||
// Check passphrase, if user currently has a passphrase set
|
||||
if (user.passphraseHash) {
|
||||
const isPassphraseValid = await verifyPassphrase(user.passphraseHash || "", body.passphrase);
|
||||
if (!isPassphraseValid) {
|
||||
await challengeThrottleService.recordFailedAttempt(user.email, "passphrase");
|
||||
return json({ error: "Invalid passphrase" }, { status: 401 });
|
||||
}
|
||||
// Clear throttle on successful passphrase check
|
||||
await challengeThrottleService.clearThrottle(user.email, "passphrase");
|
||||
}
|
||||
|
||||
log.debug("User setting new passphrase", {
|
||||
userId: locals.user.id,
|
||||
});
|
||||
|
||||
const passphraseHash = await hashPassphrase(updateData.newPassphrase);
|
||||
await UserService.updateUser(locals.user.id, { passphraseHash });
|
||||
|
||||
log.debug("User passphrase updated", {
|
||||
userId: locals.user.id,
|
||||
});
|
||||
|
||||
return json({});
|
||||
} catch (error) {
|
||||
logError(log)("Error updating user passphrase", error);
|
||||
|
||||
if (error instanceof BackendError) {
|
||||
return error.toJson();
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return new ValidationError("Invalid request data").toJson();
|
||||
}
|
||||
|
||||
return new InternalError().toJson();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user