Compare commits

..

No commits in common. "22b74987e53038dd1a9623584545b49e6a0da787" and "d9167dcd470825e6101af9fd3609257690546193" have entirely different histories.

30 changed files with 400 additions and 1254 deletions

View file

@ -15,14 +15,6 @@ npm run dev
npm run dev -- --open
```
### Handle the prisma ORM
```bash
./run/db_up.sh # Start the docker container, create a new database and run migrations + insert test data
npx prisma migrate dev --name my_migration --create-only # Create a new migration
npx prisma migrate dev # Apply migrations to the database
```
### Building
To create a production version of your app:

View file

@ -18,53 +18,49 @@
},
"dependencies": {
"@auth/core": "^0.27.0",
"@auth/sveltekit": "^0.13.0",
"@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47",
"@prisma/client": "^5.10.2",
"diff": "^5.2.0",
"isomorphic-dompurify": "^2.4.0",
"@prisma/client": "^5.9.1",
"isomorphic-dompurify": "^2.3.0",
"marked": "^12.0.0",
"set-cookie-parser": "^2.6.0",
"svelte-floating-ui": "^1.5.8",
"zod": "^3.22.4",
"zod-form-data": "^2.0.2"
},
"devDependencies": {
"@faker-js/faker": "^8.4.1",
"@playwright/test": "^1.42.0",
"@playwright/test": "^1.41.2",
"@sveltejs/adapter-node": "^4.0.1",
"@sveltejs/kit": "^2.5.2",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@tailwindcss/typography": "^0.5.10",
"@trpc/client": "^10.45.1",
"@trpc/server": "^10.45.1",
"@types/diff": "^5.0.9",
"@types/node": "^20.11.21",
"@types/set-cookie-parser": "^2.4.7",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@types/node": "^20.11.19",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"autoprefixer": "^10.4.17",
"daisyui": "^4.7.2",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"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": "^16.0.1",
"postcss-nesting": "^12.0.4",
"postcss-nesting": "^12.0.2",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"prisma": "^5.10.2",
"svelte": "^4.2.12",
"svelte-check": "^3.6.5",
"sveltekit-superforms": "^2.6.2",
"prettier-plugin-svelte": "^3.2.1",
"prisma": "^5.9.1",
"svelte": "^4.2.11",
"svelte-check": "^3.6.4",
"sveltekit-superforms": "^2.3.0",
"tailwindcss": "^3.4.1",
"trpc-sveltekit": "^3.5.27",
"trpc-sveltekit": "^3.5.26",
"tslib": "^2.6.2",
"tsx": "^4.7.1",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vitest": "^1.3.1"
"vite": "^5.1.3",
"vitest": "^1.3.0"
},
"type": "module"
}

File diff suppressed because it is too large Load diff

View file

@ -33,5 +33,5 @@ EXECUTE PROCEDURE update_entry_tsvec ();
ALTER TABLE patients
ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
CREATE INDEX entries_tsvec_idx ON entries USING GIN (tsvec);
CREATE INDEX entries_tsvec ON entries USING GIN (tsvec);
CREATE INDEX patients_full_name ON patients USING gin (full_name gin_trgm_ops);

View file

@ -1,5 +0,0 @@
CREATE COLLATION NATURAL_CI (provider = icu, locale = 'en-u-kn-true');
ALTER TABLE stations ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;
ALTER TABLE rooms ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;
ALTER TABLE categories ALTER COLUMN NAME TYPE TEXT COLLATE NATURAL_CI;

View file

@ -108,7 +108,6 @@ model Entry {
tsvec Unsupported("tsvector")?
@@map("entries")
@@index([tsvec], type: Gin)
}
model EntryVersion {

View file

@ -2,14 +2,13 @@
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
docker compose up -d
docker-compose up -d
echo 'Waiting for database to be ready...'
"$DIR/wait-for-it.sh" "localhost:5432" -- echo 'Database is ready!'
# Create temporary test database
docker compose exec -u 999:999 db sh -e -c 'dropdb -f --if-exists test; createdb test'
docker-compose exec -u 999:999 db sh -e -c 'dropdb -f --if-exists test; createdb test'
cd "$DIR/../"
DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force

6
src/app.d.ts vendored
View file

@ -1,13 +1,9 @@
import type { Session } from "@auth/core/types";
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
session: Session;
}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}

View file

@ -17,10 +17,6 @@ button {
text-overflow: ellipsis;
}
.border-1 {
border-width: 1px;
}
.v-form-field > input {
@apply input input-bordered w-full max-w-xs;
}
@ -29,32 +25,3 @@ button {
.v-form-field > [aria-invalid="true"] {
@apply input-error;
}
.card2 {
@apply bg-base-200;
@apply rounded-xl;
@apply mb-8;
@apply flex flex-col;
@apply border-solid border-base-content/30 border-[1px];
.row {
@apply flex flex-row p-2;
@apply border-solid border-base-content/30 border-t-[1px];
}
.row:first-child {
@apply rounded-t-xl border-none;
}
.row:last-child {
@apply rounded-b-xl;
}
.c-light {
@apply bg-base-content/20;
}
.c-primary {
@apply bg-primary text-primary-content;
}
}

View file

@ -13,10 +13,9 @@ import { skAuthHandle } from "$lib/server/auth";
* auth mechanism)
*/
const authorization: Handle = async ({ event, resolve }) => {
// Allowed pages without login: TRPC API (has its own auth hook), Auth.js internal
// pages and the login site
if (!/^\/(login|trpc)/.test(event.url.pathname)) {
if (!event.locals.session) {
const session = await event.locals.getSession();
if (!session) {
const params = new URLSearchParams({ returnURL: event.url.pathname });
redirect(303, "/login?" + params.toString());
}

View file

@ -2,15 +2,10 @@
import { URL_ENTRIES } from "$lib/shared/constants";
import type { Category } from "$lib/shared/model";
import { gotoEntityQuery } from "$lib/shared/util";
import { getTextColor, colorToHex, hexToColor } from "$lib/shared/util/colors";
export let category: Category;
export let baseUrl = URL_ENTRIES;
$: textColor = category.color
? colorToHex(getTextColor(hexToColor(category.color)))
: null;
function onClick(e: MouseEvent) {
gotoEntityQuery(
{
@ -27,8 +22,6 @@
<button
class="badge ellipsis"
class:badge-neutral={!category.color}
style={category.color
? `color: ${textColor}; background-color: #${category.color};`
: undefined}
style={category.color ? `background-color: ${category.color};` : undefined}
on:click={onClick}>{category.name}</button
>

View file

@ -1,23 +0,0 @@
<script lang="ts">
import { mdiChevronLeft } from "@mdi/js";
import Icon from "./Icon.svelte";
export let title = "";
export let backHref: string | undefined = undefined;
</script>
<div class="mb-4 flex flex-row">
<div class="flex flex-wrap items-center gap-2">
{#if backHref}
<a href={backHref} class="btn btn-sm btn-circle btn-ghost">
<Icon path={mdiChevronLeft} size={1.8} />
</a>
{/if}
<h1 class="heading">{title}</h1>
<slot />
</div>
<slot name="rightBtn" />
</div>

View file

@ -1,21 +1,12 @@
import {
Auth,
isAuthAction,
raw,
setEnvDefaults,
skipCSRFCheck,
type AuthConfig,
} from "@auth/core";
import { SvelteKitAuth, type SvelteKitAuthConfig } from "@auth/sveltekit";
import { Auth, raw, skipCSRFCheck } from "@auth/core";
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";
import { redirect, type Handle, type RequestEvent } from "@sveltejs/kit";
import { parse } from "set-cookie-parser";
import { redirect, type RequestEvent } from "@sveltejs/kit";
const AUTH_BASE_PATH: string = "/auth";
export const AUTH_CFG: AuthConfig = {
export const AUTH_CFG: SvelteKitAuthConfig = {
adapter: PrismaAdapter(prisma),
providers: [
Keycloak({
@ -45,26 +36,10 @@ export const AUTH_CFG: AuthConfig = {
return opt.session;
},
},
basePath: AUTH_BASE_PATH,
trustHost: true,
};
/*
Info: the @auth/sveltekit library has currently an issue with building request URLs.
If the application is run in production mode, it always creates HTTPS URLs
for the internal auth.js requests which fails if the application is not running
with HTTPS. This is the reason for not using the library and implementing the
auth handler function myself.
Original source: https://github.com/nextauthjs/next-auth/blob/main/packages/frameworks-sveltekit/src/lib/actions.ts
ISC License
*/
function authjsUrl(event: RequestEvent, authjsEndpoint: string): string {
const url = event.url;
return url.protocol + "//" + url.host + AUTH_BASE_PATH + "/" + authjsEndpoint;
}
export async function makeAuthjsRequest(
event: RequestEvent,
authjsEndpoint: string,
@ -73,8 +48,11 @@ export async function makeAuthjsRequest(
const headers = new Headers(event.request.headers);
headers.set("Content-Type", "application/x-www-form-urlencoded");
const url = event.url;
const baseUrl = url.protocol + "//" + url.host;
const body = new URLSearchParams(params);
const req = new Request(authjsUrl(event, authjsEndpoint), {
const req = new Request(baseUrl + authjsEndpoint, {
method: "POST",
headers,
body,
@ -86,38 +64,4 @@ export async function makeAuthjsRequest(
return redirect(302, res.redirect ?? "");
}
export async function auth(event: RequestEvent) {
const { request: req } = event;
setEnvDefaults(env, AUTH_CFG);
const sessionUrl = authjsUrl(event, "session");
const request = new Request(sessionUrl, {
headers: { cookie: req.headers.get("cookie") ?? "" },
});
const response = await Auth(request, AUTH_CFG);
const authCookies = parse(response.headers.getSetCookie());
for (const cookie of authCookies) {
const { name, value, ...options } = cookie;
// @ts-expect-error - Review: SvelteKit and set-cookie-parser are mismatching
event.cookies.set(name, value, { path: "/", ...options });
}
const { status = 200 } = response;
const data = await response.json();
if (!data || !Object.keys(data).length) return null;
if (status === 200) return data;
throw new Error(data.message);
}
export const skAuthHandle: Handle = async ({ event, resolve }) => {
const { url, request } = event;
if (
url.pathname.startsWith(AUTH_BASE_PATH + "/") &&
isAuthAction(url.pathname.slice(AUTH_BASE_PATH.length + 1).split("/")[0])
) {
return Auth(request, AUTH_CFG);
} else {
event.locals.session = await auth(event);
}
return resolve(event);
};
export const skAuthHandle = SvelteKitAuth(AUTH_CFG).handle;

View file

@ -6,11 +6,13 @@ import { type inferAsyncReturnType, TRPCError } from "@trpc/server";
// hence the eslint-disable rule
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function createContext(event: RequestEvent) {
if (!event.locals.session?.user) {
const session = await event.locals.getSession();
if (!session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
}
const user = ZUser.parse(event.locals.session?.user);
const user = ZUser.parse(session?.user);
return {
user,
};

View file

@ -17,7 +17,6 @@ import {
newEntryExecution,
newEntryVersion,
} from "$lib/server/query";
import { versionsDiff } from "$lib/shared/util/diff";
const ZEntityId = fields.EntityId();
@ -39,12 +38,6 @@ export const entryRouter = t.router({
versions: t.procedure
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryVersions(opts.input))),
versionsDiff: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
const versions = await getEntryVersions(opts.input);
return versionsDiff(versions);
})
),
executions: t.procedure
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))),

12
src/lib/server/util.ts Normal file
View file

@ -0,0 +1,12 @@
import type { RequestEvent } from "@sveltejs/kit";
export async function userId(event: RequestEvent): Promise<number> {
const sess = await event.locals.getSession();
const id = Number(sess?.user?.id);
if (id) {
return id;
} else {
// This should never happen, since unauthorized requests are caught by hooks.server.ts
throw new Error("no user id");
}
}

View file

@ -1,52 +0,0 @@
import { describe, it, expect } from "vitest";
import { hexToColor, colorToHex } from "./colors";
describe.each([
{
hex: "#ffffff",
color: { r: 255, g: 255, b: 255 },
},
{
hex: "#000000",
color: { r: 0, g: 0, b: 0 },
},
{
hex: "#ff0000",
color: { r: 255, g: 0, b: 0 },
},
{
hex: "#00ff00",
color: { r: 0, g: 255, b: 0 },
},
{
hex: "#0000ff",
color: { r: 0, g: 0, b: 255 },
},
{
hex: "#8ECAE6",
color: { r: 142, g: 202, b: 230 },
},
{
hex: "#219EBC",
color: { r: 33, g: 158, b: 188 },
},
{
hex: "#023047",
color: { r: 2, g: 48, b: 71 },
},
{
hex: "#FFB703",
color: { r: 255, g: 183, b: 3 },
},
{
hex: "#FB8500",
color: { r: 251, g: 133, b: 0 },
},
])("color conversion", ({ hex, color }) => {
it("colorToHex", () => {
expect(colorToHex(color)).toBe(hex.toLowerCase());
});
it("hexToColor", () => {
expect(hexToColor(hex)).toStrictEqual(color);
});
});

View file

@ -1,72 +0,0 @@
// Source: https://css-tricks.com/nailing-the-perfect-contrast-between-light-text-and-a-background-image/
/**
* RGB color value (0-255)
*/
export type Color = {
r: number;
g: number;
b: number;
};
const WHITE: Color = { r: 255, g: 255, b: 255 };
const BLACK: Color = { r: 0, g: 0, b: 0 };
export function colorToHex(c: Color): string {
return "#" + ((1 << 24) | (c.r << 16) | (c.g << 8) | c.b).toString(16).slice(1);
}
export function hexToColor(s: string): Color {
const hexStr = s[0] === "#" ? s.substring(1) : s;
const c = parseInt(hexStr, 16);
return {
r: c >> 16,
g: (c >> 8) & 255,
b: c & 255,
};
}
function getContrast(color1: Color, color2: Color): number {
const color1_luminance = getLuminance(color1);
const color2_luminance = getLuminance(color2);
const lighterColorLuminance = Math.max(color1_luminance, color2_luminance);
const darkerColorLuminance = Math.min(color1_luminance, color2_luminance);
const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);
return contrast;
}
function getLuminance(c: Color): number {
return (
0.2126 * getLinearRGB(c.r) + 0.7152 * getLinearRGB(c.g) + 0.0722 * getLinearRGB(c.b)
);
}
function convert_8bit_RGB_to_standard_RGB(primaryColor_8bit: number): number {
return primaryColor_8bit / 255;
}
function convert_standard_RGB_to_linear_RGB(primaryColor_sRGB: number): number {
const primaryColor_linear =
primaryColor_sRGB < 0.03928
? primaryColor_sRGB / 12.92
: Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4);
return primaryColor_linear;
}
function getLinearRGB(primaryColor_8bit: number): number {
// First convert from 8-bit rbg (0-255) to standard RGB (0-1)
const primaryColor_sRGB = convert_8bit_RGB_to_standard_RGB(primaryColor_8bit);
// Then convert from sRGB to linear RGB so we can use it to calculate luminance
const primaryColor_RGB_linear = convert_standard_RGB_to_linear_RGB(primaryColor_sRGB);
return primaryColor_RGB_linear;
}
/**
* Get the preferred text color for the given background color
*/
export function getTextColor(bgColor: Color): Color {
const cWhite = getContrast(bgColor, WHITE);
const cBlack = getContrast(bgColor, BLACK);
return cWhite > cBlack ? WHITE : BLACK;
}

View file

@ -1,110 +0,0 @@
import { test, expect } from "vitest";
import type { EntryVersion } from "$lib/shared/model";
import { CATEGORIES, U1, U2 } from "$tests/helpers/testdata";
import { versionsDiff } from "./diff";
test("versions diff", () => {
const versions: EntryVersion[] = [
{
id: 1,
author: U1,
category: CATEGORIES[0],
created_at: new Date(Date.UTC(2024, 1, 10, 10, 30)),
date: "2024-01-11",
priority: false,
text: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ getestet werden.",
},
{
id: 2,
author: U2,
category: CATEGORIES[0],
created_at: new Date(Date.UTC(2024, 1, 10, 11, 30)),
date: "2024-01-12",
priority: true,
text: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
},
{
id: 3,
author: U1,
category: CATEGORIES[0],
created_at: new Date(Date.UTC(2024, 1, 10, 11, 31)),
date: "2024-01-12",
priority: true,
text: "10ml Blut abnehmen.\n\nDas Blut muss auf Lambda-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
},
];
const diff = versionsDiff(versions);
expect(diff).toStrictEqual([
{
id: 3,
author: {
id: 1,
name: "Sven Schulz",
email: "sven.schulz@example.com",
},
category: {
id: 1,
name: "Laborabnahme",
description: "Blutabnahme zur Untersuchung im Labor",
color: "FF0000",
},
created_at: new Date("2024-02-10T11:31:00.000Z"),
date: "2024-01-12",
priority: true,
text: [
{
count: 38,
added: true,
removed: undefined,
value:
"10ml Blut abnehmen.\n\nDas Blut muss auf Lambda-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
},
],
},
{
id: 2,
author: {
id: 2,
name: "Sabrina Loewe",
email: "sabrina.loewe@example.com",
},
created_at: new Date("2024-02-10T11:30:00.000Z"),
category: undefined,
date: undefined,
priority: undefined,
text: [
{ count: 16, value: "10ml Blut abnehmen.\n\nDas Blut muss auf " },
{ count: 1, added: undefined, removed: true, value: "Lambda" },
{ count: 1, added: true, removed: undefined, value: "XYZ" },
{
count: 21,
value: "-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
},
],
},
{
id: 1,
author: {
id: 1,
name: "Sven Schulz",
email: "sven.schulz@example.com",
},
created_at: new Date("2024-02-10T10:30:00.000Z"),
category: undefined,
text: [
{ count: 17, value: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ" },
{ count: 2, added: undefined, removed: true, value: "-Erreger" },
{ count: 5, value: " getestet werden." },
{
count: 14,
added: undefined,
removed: true,
value: "\n\nHierfür ist das Labor Meier zuständig.",
},
],
date: "2024-01-11",
priority: false,
},
]);
});

View file

@ -1,46 +0,0 @@
import { diffWords } from "diff";
import type { EntryVersion, Category, Option, UserTag } from "$lib/shared/model";
export type EntryVersionChange = {
id: number;
author: UserTag;
created_at: Date;
text: Diff.Change[];
date?: string;
category?: Option<Category>;
priority?: boolean;
};
function newOrUndef<T>(o: T, n: T): T | undefined {
return o === n ? undefined : n;
}
export function versionsDiff(versions: EntryVersion[]): EntryVersionChange[] {
let prev = versions[versions.length - 1];
const changes: EntryVersionChange[] = [
{
...prev,
text: diffWords("", prev.text),
},
];
for (let i = versions.length - 2; i >= 0; i--) {
const v = versions[i];
const text = diffWords(prev.text, v.text);
changes.push({
id: v.id,
author: v.author,
created_at: v.created_at,
text,
date: newOrUndef(prev.date, v.date),
category: prev.category?.id === v.category?.id ? undefined : v.category,
priority: newOrUndef(prev.priority, v.priority),
});
prev = v;
}
return changes;
}

View file

@ -2,7 +2,6 @@ import { goto } from "$app/navigation";
import type { EntityQuery } from "$lib/shared/model";
import { TRPCClientError } from "@trpc/client";
import { error } from "@sveltejs/kit";
import { ZodError } from "zod";
export function formatDate(date: Date | string, time = false): string {
let dt = date;
@ -26,10 +25,6 @@ export function formatDate(date: Date | string, time = false): string {
}
}
export function formatBool(val: boolean): string {
return val ? "Ja" : "Nein";
}
export function getQueryUrl(q: EntityQuery, basePath: string): string {
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
return basePath + "/" + JSON.stringify(q);
@ -61,8 +56,6 @@ export async function loadWrap<T>(f: () => Promise<T>) {
} catch (e) {
if (e instanceof TRPCClientError) {
error(e.data?.httpStatus ?? 500, e.message);
} else if (e instanceof ZodError) {
error(400, e.message);
} else if (e instanceof Error) {
error(500, e.message);
} else {

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { page } from "$app/stores";
import NavLink from "$lib/components/ui/NavLink.svelte";
import { signOut } from "@auth/sveltekit/client";
import { mdiAccount, mdiHome } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte";

View file

@ -5,9 +5,6 @@
import RoomField from "$lib/components/table/RoomField.svelte";
import CategoryField from "$lib/components/table/CategoryField.svelte";
import Markdown from "$lib/components/ui/Markdown.svelte";
import Header from "$lib/components/ui/Header.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { mdiHistory } from "@mdi/js";
export let data: PageData;
</script>
@ -16,57 +13,50 @@
<title>Eintrag #{data.entry.id}</title>
</svelte:head>
<Header title="Eintrag #{data.entry.id}">
<h1 class="heading">
Eintrag #{data.entry.id}
{#if data.entry.current_version.category}
<CategoryField category={data.entry.current_version.category} />
{/if}
{#if data.entry.current_version.priority}
<div class="badge ellipsis badge-warning">Priorität</div>
{/if}
</h1>
<a
href="/entry/{data.entry.id}/versions"
class="btn btn-sm btn-primary ml-auto"
slot="rightBtn"
>
<Icon path={mdiHistory} />
<span class="hidden sm:inline">Versionen</span>
</a>
</Header>
<div class="card2">
<div class="row c-light text-sm">Patient</div>
<div class="row items-center gap-2">
<div class="box">
<p>
{#if data.entry.patient.room}
<RoomField room={data.entry.patient.room} />
{/if}
{data.entry.patient.first_name}
{data.entry.patient.last_name}
({data.entry.patient.age})
</div>
</p>
</div>
<div class="card2">
<div class="row c-light text-sm">Beschreibung</div>
<div class="row">
<p class="prose">
<Markdown src={data.entry.current_version.text} />
</p>
</div>
<div class="box">
<p class="prose">
<Markdown src={data.entry.current_version.text} />
</p>
</div>
{#if data.entry.execution}
<div class="card2">
<div class="row c-light text-sm">
<p>
Erledigt am {formatDate(data.entry.execution.created_at, true)} von
<UserField user={data.entry.execution.author} filterName="executor" />:
</p>
</div>
<div class="row">
<p class="prose">
<Markdown src={data.entry.execution?.text} />
</p>
</div>
<div class="box">
<p>
Erledigt am {formatDate(data.entry.execution.created_at, true)} von
<UserField user={data.entry.execution.author} filterName="executor" />:
</p>
<p class="prose">
<Markdown src={data.entry.execution?.text} />
</p>
</div>
{/if}
<style lang="postcss">
p {
@apply my-2;
}
.box {
@apply rounded-xl border-solid border-2 border-base-content/40;
@apply px-2 my-4;
}
</style>

View file

@ -4,10 +4,8 @@ import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
const entry = await loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id);
return trpc(event).entry.get.query(id);
});
const id = ZUrlEntityId.parse(event.params.id);
const entry = await loadWrap(async () => trpc(event).entry.get.query(id));
return { entry };
};

View file

@ -1,71 +0,0 @@
<script lang="ts">
import CategoryField from "$lib/components/table/CategoryField.svelte";
import UserField from "$lib/components/table/UserField.svelte";
import { formatBool, formatDate } from "$lib/shared/util";
import type { PageData } from "./$types";
import Header from "$lib/components/ui/Header.svelte";
import { page } from "$app/stores";
export let data: PageData;
$: entryId = $page.params.id;
</script>
<svelte:head>
<title>Eintrag #{entryId} - Versionen</title>
</svelte:head>
<Header title="Eintrag #{entryId} - Versionen" backHref="/entry/{entryId}" />
<div class="overflow-x-auto">
{#each data.versions as version}
<div class="card2">
<div class="row c-light text-sm">
<UserField user={version.author} />, {formatDate(version.created_at, true)}
</div>
{#if version.category}
<div class="row">
<div>Kategeorie</div>
<div><CategoryField category={version.category} /></div>
</div>
{/if}
{#if version.text.length > 0}
<div class="row">
<div>Text</div>
<div class="whitespace-pre-wrap">
{#each version.text as change}
<span class:added={change.added} class:removed={change.removed}>
{change.value}
</span>
{/each}
</div>
</div>
{/if}
{#if version.date !== undefined}
<div class="row">
<div>Datum</div>
<div>{formatDate(version.date)}</div>
</div>
{/if}
{#if version.priority !== undefined}
<div class="row">
<div>Priorität</div>
<div>{formatBool(version.priority)}</div>
</div>
{/if}
</div>
{/each}
</div>
<style lang="postcss">
.row > div:first-child {
min-width: 6.5rem;
}
.added {
@apply bg-success/20;
}
.removed {
@apply bg-error/20;
}
</style>

View file

@ -1,11 +0,0 @@
import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
const id = ZUrlEntityId.parse(event.params.id);
const versions = await loadWrap(async () => trpc(event).entry.versionsDiff.query(id));
return { versions };
};

View file

@ -6,6 +6,6 @@ export const actions: Actions = {
default: async (event) => {
const callbackUrl = baseUrl(event.url) + "/login?noAuto=1";
return makeAuthjsRequest(event, "signout", { callbackUrl });
return makeAuthjsRequest(event, "/auth/signout", { callbackUrl });
},
};

View file

@ -2,6 +2,6 @@ import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async (event) => {
return {
session: event.locals.session,
session: await event.locals.getSession(),
};
};

View file

@ -1,40 +1,11 @@
import type { Actions, RequestEvent } from "./$types";
import type { Actions } from "./$types";
import { makeAuthjsRequest } from "$lib/server/auth";
import { baseUrl } from "$lib/shared/util";
import type { PageServerLoad } from "./$types";
/**
* This cookie stores the timestamp of the last automatic login.
* It is used to prevent infinite redirect loops in case the authentication fails.
*/
const COOKIE_NAME = "autoLoginTs";
async function doLogin(event: RequestEvent) {
const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url);
return makeAuthjsRequest(event, "signin/keycloak", { callbackUrl });
}
// Try to login the user using OIDC
export const load: PageServerLoad = async (event) => {
// the noAuto URL parameter disables auto-login
if (event.url.searchParams.get("noAuto")) return;
let autoLoginTs = null;
const autoLoginTsStr = event.cookies.get(COOKIE_NAME);
if (autoLoginTsStr) {
autoLoginTs = parseInt(autoLoginTsStr);
}
const now = Date.now();
if (!autoLoginTs || autoLoginTs + 15000 < now) {
event.cookies.set(COOKIE_NAME, now.toString(), { path: "/login" });
return doLogin(event);
}
};
export const actions: Actions = {
default: async (event) => {
return doLogin(event);
const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url);
return makeAuthjsRequest(event, "/auth/signin/keycloak", { callbackUrl });
},
};

View file

@ -1,5 +1,37 @@
<script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import { signIn } from "@auth/sveltekit/client";
import { goto } from "$app/navigation";
import { enhance } from "$app/forms";
const STORAGE_KEY = "autoLoginTs";
let callbackUrl: string;
$: if ($page.url) {
let u = new URL($page.url.protocol + "//" + $page.url.host);
u.pathname = $page.url.searchParams.get("returnURL") ?? "/";
callbackUrl = u.toString();
}
// Client side auto-login / redirect
onMount(() => {
if (!$page.url.searchParams.get("noAuto")) {
// Keep track of last login time to avoid redirect loop if the login fails
const lastUpdateStr = sessionStorage.getItem(STORAGE_KEY);
const lastUpdate = lastUpdateStr ? parseInt(lastUpdateStr) : null;
const now = Date.now();
if (!lastUpdate || lastUpdate + 10000 < now) {
sessionStorage.setItem(STORAGE_KEY, now.toString());
if (!$page.data.session?.user) {
signIn("keycloak", { callbackUrl });
} else {
goto(callbackUrl);
}
}
}
});
</script>
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">