Compare commits

...

3 commits

Author SHA1 Message Date
e68681129f
chore: update authjs 2024-02-18 01:15:49 +01:00
f0b401c216
chore: update dependencies 2024-02-17 23:06:37 +01:00
97cdfe4d88
feat: add new entry form 2024-02-17 00:17:44 +01:00
24 changed files with 1015 additions and 570 deletions

View file

@ -17,49 +17,50 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@auth/core": "^0.18.6",
"@auth/sveltekit": "^0.5.3",
"@auth/core": "^0.27.0",
"@auth/sveltekit": "^0.13.0",
"@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47",
"@prisma/client": "^5.8.1",
"@prisma/client": "^5.9.1",
"isomorphic-dompurify": "^2.3.0",
"marked": "^12.0.0",
"svelte-floating-ui": "^1.5.8",
"svelte-markdown": "^0.4.1",
"zod": "^3.22.4",
"zod-form-data": "^2.0.2"
},
"devDependencies": {
"@faker-js/faker": "^8.4.0",
"@playwright/test": "^1.41.1",
"@sveltejs/adapter-node": "^2.1.2",
"@faker-js/faker": "^8.4.1",
"@playwright/test": "^1.41.2",
"@sveltejs/adapter-node": "^4.0.1",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@tailwindcss/typography": "^0.5.10",
"@trpc/client": "^10.45.0",
"@trpc/server": "^10.45.0",
"@types/node": "^20.11.7",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"@trpc/client": "^10.45.1",
"@trpc/server": "^10.45.1",
"@types/node": "^20.11.19",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"autoprefixer": "^10.4.17",
"daisyui": "^4.6.0",
"daisyui": "^4.7.2",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-no-relative-import-paths": "^1.5.3",
"eslint-plugin-svelte": "^2.35.1",
"postcss-import": "^15.1.0",
"postcss-import": "^16.0.1",
"postcss-nesting": "^12.0.2",
"prettier": "^3.2.4",
"prettier-plugin-svelte": "^3.1.2",
"prisma": "^5.8.1",
"svelte": "^4.2.9",
"svelte-check": "^3.6.3",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.1",
"prisma": "^5.9.1",
"svelte": "^4.2.11",
"svelte-check": "^3.6.4",
"sveltekit-superforms": "^1.13.4",
"tailwindcss": "^3.4.1",
"trpc-sveltekit": "^3.5.22",
"trpc-sveltekit": "^3.5.26",
"tslib": "^2.6.2",
"tsx": "^4.7.0",
"tsx": "^4.7.1",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vitest": "^1.2.2"
"vite": "^5.1.3",
"vitest": "^1.3.0"
},
"type": "module"
}

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,10 @@
import { SvelteKitAuth } from "@auth/sveltekit";
import Keycloak from "@auth/core/providers/keycloak";
import {
KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_ISSUER,
} from "$env/static/private";
import { redirect, type Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import { createTRPCHandle } from "trpc-sveltekit";
import { prisma } from "$lib/server/prisma";
import { PrismaAdapter } from "$lib/server/authAdapter";
import { createContext } from "$lib/server/trpc/context";
import { router } from "$lib/server/trpc/router";
import { skAuthHandle } from "$lib/server/auth";
/**
* Protect the application against unauthorized access.
@ -34,40 +26,7 @@ const authorization: Handle = async ({ event, resolve }) => {
};
export const handle = sequence(
SvelteKitAuth({
adapter: PrismaAdapter(prisma),
providers: [
Keycloak({
clientId: KEYCLOAK_CLIENT_ID,
clientSecret: KEYCLOAK_CLIENT_SECRET,
issuer: KEYCLOAK_ISSUER,
}),
],
session: {
strategy: "jwt",
},
callbacks: {
// Add user ID to session token
jwt({ token, account, user }) {
if (account) {
token.accessToken = account.access_token;
token.id = user?.id;
}
return token;
},
session(opt) {
// @ts-expect-error because of union type
if (opt.session.user && opt.token.id) {
// @ts-expect-error because of union type
opt.session.user.id = opt.token.id;
}
return opt.session;
},
},
trustHost: true,
}),
skAuthHandle,
authorization,
createTRPCHandle({ router, createContext })
);

View file

@ -26,6 +26,7 @@
export let inputCls = "w-full bg-transparent outline-none";
export let asTextInput = false;
export let idInputName: string | undefined = undefined;
export let filterFn: (item: BaseItem) => boolean = () => true;
/** Selection callback. Returns the new input value after selection */
export let onSelect: (item: SelectionOrText, kb: boolean) => OnSelectResult = (
@ -122,9 +123,11 @@
!selection && searchWord.length > 0
? srcItems.filter(
(it) =>
!hiddenIds.has(it.id) && it.name.toLowerCase().includes(searchWord)
!hiddenIds.has(it.id) &&
filterFn(it) &&
it.name.toLowerCase().includes(searchWord)
)
: srcItems.filter((it) => !hiddenIds.has(it.id));
: srcItems.filter((it) => !hiddenIds.has(it.id) && filterFn(it));
markSelection();
}
}
@ -256,10 +259,12 @@
bind:this={inputElm}
on:input={onInput}
on:click={open}
on:focus={open}
on:keydown={onKeyDown}
on:keypress={onKeyPress}
on:blur={onBlur}
use:floatingRef
value=""
/>
{#if opened && filteredItems.length > 0}

View file

@ -81,15 +81,14 @@
/>
</FormField>
<label class="form-control w-full max-w-xs">
<div class="label">Station</div>
<FormField label="Station">
<input
type="text"
class="input input-bordered w-full max-w-xs"
disabled
value={station?.name ?? ""}
/>
</label>
</FormField>
</div>
<div class="flex flex-wrap gap-2">

View file

@ -84,9 +84,9 @@
<style lang="postcss">
.done {
@apply bg-green-500/20 !important;
@apply bg-success/20 !important;
}
.priority {
@apply bg-red-500/20;
@apply bg-warning/20;
}
</style>

View file

@ -73,6 +73,6 @@
<style lang="postcss">
.p-hidden {
@apply bg-red-500/20;
@apply bg-error/20;
}
</style>

View file

@ -1,10 +1,14 @@
<script lang="ts">
export let label = "";
export let errors: string[] | undefined = undefined;
export let fullWidth = false;
</script>
<label class="v-form-field form-control w-full max-w-xs">
<div class="label">{label}</div>
<label class="v-form-field form-control w-full" class:max-w-xs={!fullWidth}>
<div class="label">
<span class="label-text">{label}</span>
<slot name="topLabel" />
</div>
<slot />
{#if errors}
<div class="label flex-col items-start">

View file

@ -0,0 +1,13 @@
<script lang="ts">
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
export let src: string;
$: doc = DOMPurify.sanitize(marked.parse(src) as string, { FORBID_TAGS: ["img"] });
</script>
<p class="prose">
<!--eslint-disable-next-line svelte/no-at-html-tags-->
{@html doc}
</p>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import FormField from "./FormField.svelte";
import Markdown from "./Markdown.svelte";
import type { InputConstraint } from "sveltekit-superforms";
export let label = "";
export let errors: string[] | undefined = undefined;
export let constraints: InputConstraint | undefined = undefined;
let editMode = true;
let value = "";
function toggle() {
editMode = !editMode;
}
</script>
<FormField {label} {errors} fullWidth>
<button slot="topLabel" class="label-text" on:click={toggle} tabindex="-1">
{editMode ? "Vorschau" : "Bearbeiten"}
</button>
{#if editMode}
<textarea class="textarea textarea-bordered w-full" bind:value {...constraints} />
{:else}
<Markdown src={value} />
{/if}
</FormField>

43
src/lib/server/auth.ts Normal file
View file

@ -0,0 +1,43 @@
import { SvelteKitAuth } from "@auth/sveltekit";
import Keycloak from "@auth/core/providers/keycloak";
import { prisma } from "$lib/server/prisma";
import { PrismaAdapter } from "$lib/server/authAdapter";
import { env } from "$env/dynamic/private";
export const {
handle: skAuthHandle,
signIn,
signOut,
} = SvelteKitAuth({
adapter: PrismaAdapter(prisma),
providers: [
Keycloak({
clientId: env.KEYCLOAK_CLIENT_ID,
clientSecret: env.KEYCLOAK_CLIENT_SECRET,
issuer: env.KEYCLOAK_ISSUER,
}),
],
session: {
strategy: "jwt",
},
callbacks: {
// Add user ID to session token
jwt({ token, account, user }) {
if (account) {
token.accessToken = account.access_token;
token.id = user?.id;
}
return token;
},
session(opt) {
if (opt.session.user && opt.token.id) {
// @ts-expect-error because of union type
opt.session.user.id = opt.token.id;
}
return opt.session;
},
},
trustHost: true,
});

View file

@ -55,12 +55,21 @@ export async function getPatient(id: number): Promise<Patient> {
export async function getPatientNames(): Promise<PatientTag[]> {
const patients = await prisma.patient.findMany({
select: { id: true, first_name: true, last_name: true },
select: {
id: true,
first_name: true,
last_name: true,
room_id: true,
},
where: { hidden: false },
orderBy: { last_name: "asc" },
});
return patients.map((p) => {
return { id: p.id, name: p.first_name + " " + p.last_name };
return {
id: p.id,
name: p.first_name + " " + p.last_name,
room_id: p.room_id,
};
});
}

View file

@ -1,7 +1,4 @@
import type { Renderers } from "svelte-markdown";
export const PAGINATION_LIMIT = 20;
export const MARKDOWN_RENDERERS: Partial<Renderers> = { image: undefined };
export const URL_ENTRIES = "/plan";
export const URL_PATIENTS = "/patients";

View file

@ -75,6 +75,7 @@ export type Patient = {
export type PatientTag = {
id: number;
name: string;
room_id: number | null;
};
export type PatientNew = {

View file

@ -1,11 +1,10 @@
<script lang="ts">
import SvelteMarkdown from "svelte-markdown";
import type { PageData } from "./$types";
import { formatDate } from "$lib/shared/util";
import UserField from "$lib/components/table/UserField.svelte";
import RoomField from "$lib/components/table/RoomField.svelte";
import CategoryField from "$lib/components/table/CategoryField.svelte";
import { MARKDOWN_RENDERERS } from "$lib/shared/constants";
import Markdown from "$lib/components/ui/Markdown.svelte";
export let data: PageData;
</script>
@ -34,10 +33,7 @@
<div class="box">
<p class="prose">
<SvelteMarkdown
source={data.entry.current_version.text}
renderers={MARKDOWN_RENDERERS}
/>
<Markdown src={data.entry.current_version.text} />
</p>
</div>
@ -49,10 +45,7 @@
</p>
<p class="prose">
<SvelteMarkdown
source={data.entry.execution?.text}
renderers={MARKDOWN_RENDERERS}
/>
<Markdown src={data.entry.execution?.text} />
</p>
</div>
{/if}

View file

@ -0,0 +1,134 @@
<script lang="ts">
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import FormField from "$lib/components/ui/FormField.svelte";
import { trpc, type RouterOutput } from "$lib/shared/trpc";
import MarkdownInput from "$lib/components/ui/MarkdownInput.svelte";
let room_id: number | null = null;
let patient_id: number | null = null;
let patient: RouterOutput["patient"]["get"] | null = null;
</script>
<svelte:head>
<title>Neuer Eintrag</title>
</svelte:head>
<h1 class="heading">Neuer Eintrag</h1>
<div class="flex flex-wrap gap-2">
<FormField label="Zimmer">
<Autocomplete
inputCls="input input-bordered w-full max-w-xs"
items={async () => {
return await trpc().room.list.query();
}}
onSelect={(item) => {
// @ts-expect-error ids are always numeric
room_id = item.id;
// $form.room_id = item.id;
return { newValue: item.name ?? "", close: true };
}}
onUnselect={() => {
room_id = null;
// $form.room_id = null;
}}
asTextInput
idInputName="room_id"
/>
</FormField>
<FormField label="Patient">
<Autocomplete
inputCls="input input-bordered w-full max-w-xs"
items={async () => {
return await trpc().patient.getNames.query();
}}
onSelect={(item) => {
// @ts-expect-error patient id
patient_id = item.id;
trpc()
// @ts-expect-error patient id
.patient.get.query(item.id)
.then((p) => {
patient = p;
});
// $form.room_id = item.id;
return { newValue: item.name ?? "", close: true };
}}
onUnselect={() => {
patient_id = null;
patient = null;
}}
asTextInput
idInputName="room_id"
filterFn={(itm) => {
// @ts-expect-error patient items have room attr
return room_id === null || itm.room_id === room_id;
}}
placeholder="Neuer Patient"
/>
</FormField>
</div>
<div class="flex flex-wrap gap-2">
<FormField label="Vorname">
<input
type="text"
name="patient_first_name"
value={patient?.first_name ?? null}
disabled={patient_id !== null}
/>
</FormField>
<FormField label="Nachname">
<input
type="text"
name="patient_last_name"
value={patient?.last_name ?? null}
disabled={patient_id !== null}
/>
</FormField>
<FormField label="Alter">
<input
type="number"
name="patient_age"
value={patient?.age ?? null}
disabled={patient_id !== null}
/>
</FormField>
</div>
<div class="flex flex-wrap gap-2">
<FormField label="Kategorie">
<Autocomplete
inputCls="input input-bordered w-full max-w-xs"
items={async () => {
return await trpc().category.list.query();
}}
onSelect={(item) => {
// @ts-expect-erro room items have station attr
// station = item.station;
// @ts-expect-erro ids are always numeric
// $form.room_id = item.id;
return { newValue: item.name ?? "", close: true };
}}
onUnselect={() => {
// $form.room_id = null;
}}
asTextInput
idInputName="category_id"
/>
</FormField>
<div class="form-control w-full max-w-xs">
<label class="label cursor-pointer gap-2 justify-start">
<span class="label-text text-right">Priorität</span>
<input type="checkbox" class="checkbox checkbox-warning" />
</label>
</div>
</div>
<MarkdownInput label="Beschreibung" />
<button class="btn btn-primary max-w-32 mt-4" type="submit">Speichern</button>

View file

@ -0,0 +1,4 @@
import type { Actions } from "./$types";
import { signOut } from "$lib/server/auth";
export const actions: Actions = { default: signOut };

View file

@ -1,23 +1,11 @@
<script lang="ts">
import type { PageData } from "./$types";
import { page } from "$app/stores";
export let data: PageData;
let callbackUrl: string;
$: if ($page.url) {
let u = new URL($page.url.origin);
u.pathname = "/login";
u.searchParams.append("noAuto", "1");
callbackUrl = u.toString();
}
import { enhance } from "$app/forms";
</script>
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
<h1 class="text-xl mt-4">Möchten sie sich abmelden?</h1>
<form action="/auth/signout" method="POST">
<input type="hidden" name="csrfToken" value={data.csrfToken} />
<input type="hidden" name="callbackUrl" value={callbackUrl} />
<form method="POST" use:enhance>
<input type="hidden" name="redirectTo" value="/login?noAuto=1" />
<button class="btn btn-primary mt-4" type="submit">Abmelden</button>
</form>
</div>

View file

@ -1,8 +0,0 @@
import { authjsCsrfToken } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const csrfToken = await authjsCsrfToken(fetch);
return { csrfToken };
};

View file

@ -11,4 +11,6 @@
<title>Planung</title>
</svelte:head>
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query} />
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query}>
<a class="btn btn-sm btn-primary" href="/entry/new">Neuer Eintrag</a>
</FilteredEntryTable>

View file

@ -1,9 +1,11 @@
<script lang="ts">
import { browser } from "$app/environment";
import { page } from "$app/stores";
import ErrorMessage from "$lib/components/ui/ErrorMessage.svelte";
console.log("Fehlerhafte Seite", $page);
// eslint-disable-next-line no-console
if (browser) console.log("Fehlerhafte Seite", $page);
</script>
<svelte:head>

View file

@ -0,0 +1,4 @@
import type { Actions } from "./$types";
import { signIn } from "$lib/server/auth";
export const actions: Actions = { default: signIn };

View file

@ -1,11 +1,9 @@
<script lang="ts">
import type { PageData } from "./$types";
import { page } from "$app/stores";
import { onMount } from "svelte";
import { signIn } from "@auth/sveltekit/client";
import { goto } from "$app/navigation";
export let data: PageData;
import { enhance } from "$app/forms";
let callbackUrl: string;
$: if ($page.url) {
@ -28,9 +26,9 @@
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
<h1 class="text-4xl mt-4">Visitenbuch</h1>
<form action="/auth/signin/keycloak" method="POST">
<input type="hidden" name="csrfToken" value={data.csrfToken} />
<input type="hidden" name="callbackUrl" value={callbackUrl} />
<form method="POST" use:enhance>
<input type="hidden" name="providerId" value="keycloak" />
<input type="hidden" name="redirectTo" value={callbackUrl} />
<button class="btn btn-primary mt-4" type="submit">Anmelden</button>
</form>
</div>

View file

@ -1,8 +0,0 @@
import { authjsCsrfToken } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const csrfToken = await authjsCsrfToken(fetch);
return { csrfToken };
};