Support missing roles and deleted user

This commit is contained in:
2025-04-01 23:26:56 +02:00
parent fc302d8ef2
commit 3694492e1a
10 changed files with 88 additions and 47 deletions

View File

@@ -274,7 +274,7 @@ const app = new Elysia()
); );
return Option.match(res, { return Option.match(res, {
onNone: () => error("Not Found", undefined), onNone: () => error("Not Found", new Response() as unknown as void),
onSome: ({ displayName }) => ({ userId, displayName }), onSome: ({ displayName }) => ({ userId, displayName }),
}); });
}, { }, {
@@ -290,13 +290,13 @@ const app = new Elysia()
// --- MARK: PIECE CRUD ------------------------------------------------ // --- MARK: PIECE CRUD ------------------------------------------------
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, session: { idToken } }) => { .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }
@@ -369,7 +369,7 @@ const app = new Elysia()
.executeTakeFirst(); .executeTakeFirst();
if (piece === undefined) { if (piece === undefined) {
return error("Not Found", undefined); return error("Not Found", new Response() as unknown as void);
} }
const attachments = await db const attachments = await db
@@ -396,13 +396,13 @@ const app = new Elysia()
}, },
}) })
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, session: { idToken } }) => { .put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }
@@ -414,7 +414,7 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { if (res.length === 0) {
return error("Not Found", undefined); return error("Not Found", new Response() as unknown as void);
} }
const attachments = await db const attachments = await db
@@ -448,13 +448,13 @@ const app = new Elysia()
}, },
}) })
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, session: { idToken } }) => { .delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }
@@ -465,7 +465,7 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { if (res.length === 0) {
return error("Not Found", undefined); return error("Not Found", new Response() as unknown as void);
} }
set.status = "No Content"; set.status = "No Content";
@@ -482,13 +482,13 @@ const app = new Elysia()
// --- MARK: ATTACHMENT CRUD ------------------------------------------- // --- MARK: ATTACHMENT CRUD -------------------------------------------
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, session: { idToken } }) => { .post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }
@@ -554,7 +554,7 @@ const app = new Elysia()
.executeTakeFirst(); .executeTakeFirst();
if (res === undefined) { if (res === undefined) {
return error("Not Found", undefined); return error("Not Found", new Response() as unknown as void);
} }
set.headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(res.filename)}`; set.headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(res.filename)}`;
@@ -572,13 +572,13 @@ const app = new Elysia()
}, },
}) })
.put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, session: { idToken } }) => { .put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }
@@ -593,7 +593,7 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { if (res.length === 0) {
return error("Not Found", undefined); return error("Not Found", new Response() as unknown as void);
} }
return { return {
@@ -616,13 +616,13 @@ const app = new Elysia()
}, },
}) })
.delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, session: { idToken } }) => { .delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }
@@ -636,7 +636,7 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { if (res.length === 0) {
return error("Not Found", undefined); return error("Not Found", new Response() as unknown as void);
} }
set.status = "No Content"; set.status = "No Content";
@@ -655,13 +655,13 @@ const app = new Elysia()
// --- MARK: REPERTOIRE CRUD ------------------------------------------- // --- MARK: REPERTOIRE CRUD -------------------------------------------
.post("/repertoire", async ({ db, body: { name, entries }, session: { idToken } }) => { .post("/repertoire", async ({ db, body: { name, entries }, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }
@@ -741,7 +741,7 @@ const app = new Elysia()
.executeTakeFirst(); .executeTakeFirst();
if (repertoire === undefined) { if (repertoire === undefined) {
return error("Not Found", undefined); return error("Not Found", new Response() as unknown as void);
} }
const entries = await db const entries = await db
@@ -766,13 +766,13 @@ const app = new Elysia()
}, },
}) })
.put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, session: { idToken } }) => { .put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }
@@ -784,7 +784,7 @@ const app = new Elysia()
.execute(); .execute();
if (res.length === 0) { if (res.length === 0) {
return error("Not Found", undefined); return error("Not Found", new Response() as unknown as void);
} }
await db await db
@@ -821,13 +821,13 @@ const app = new Elysia()
}, },
}) })
.delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, session: { idToken } }) => { .delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, session: { idToken, roles } }) => {
if (Option.isNone(idToken)) { if (Option.isNone(idToken)) {
return error("Unauthorized", "Session invalid or expired"); return error("Unauthorized", "Session invalid or expired");
} }
if (!idToken.value.payload.roles.includes("Editor")) { if (!roles.includes("Editor")) {
return error("Forbidden", "Must be an Editor"); return error("Forbidden", "Must be an Editor");
} }

View File

@@ -158,7 +158,7 @@ export const revalidateTokens = Effect.fn("revaildateTokens")(
userId: Option.map(it, ({ payload: { oid } }) => oid), userId: Option.map(it, ({ payload: { oid } }) => oid),
roles: Option.match(it, { roles: Option.match(it, {
onNone: constant(Object.freeze([])), onNone: constant(Object.freeze([])),
onSome: ({ payload: { roles } }) => roles, onSome: ({ payload: { roles } }) => roles ?? Object.freeze([]),
}), }),
}); });

View File

@@ -20,7 +20,7 @@ export interface IdTokenPayload {
readonly exp: number; readonly exp: number;
readonly name: string; readonly name: string;
readonly oid: Common.UserId; readonly oid: Common.UserId;
readonly roles: readonly string[]; readonly roles?: readonly string[];
} }
const brandedString = <T>() => t.Transform(t.String()) const brandedString = <T>() => t.Transform(t.String())

View File

@@ -9,9 +9,9 @@ export interface User {
} }
export interface SystemInformation { export interface SystemInformation {
readonly createdBy: Option.Option<User>; readonly createdBy: Option.Option<User | null>;
readonly createdAt: string; readonly createdAt: string;
readonly modifiedBy: Option.Option<User>; readonly modifiedBy: Option.Option<User | null>;
readonly modifiedAt: Option.Option<string>; readonly modifiedAt: Option.Option<string>;
} }
@@ -113,7 +113,8 @@ const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
export const userLookup = (userId: UserId) => pipe( export const userLookup = (userId: UserId) => pipe(
Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })), Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })),
Effect.flatMap(mapResponse), Effect.flatMap(mapResponse),
Effect.map((x): User => x), // safely coerce to interface Effect.catchAll((error) => error.status === 404 ? Effect.succeed(null) : Effect.fail(error)),
Effect.map((x): User | null => x), // safely coerce to interface
UserSemaphore.withPermits(1), UserSemaphore.withPermits(1),
); );

View File

@@ -1,6 +1,7 @@
import { client } from "@/client"; import { client } from "@/client";
import { useLoading } from "@/hooks/useLoading.ts"; import { useLoading } from "@/hooks/useLoading.ts";
import { AttachmentId, PieceId } from "common"; import { AttachmentId, PieceId } from "common";
import { Match } from "effect";
import JSZip from "jszip"; import JSZip from "jszip";
import { OpenSheetMusicDisplay } from "opensheetmusicdisplay"; import { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
@@ -102,7 +103,13 @@ export default function Attachment() {
if (error !== null) { if (error !== null) {
return ( return (
<div className="w-full h-full overflow-hidden flex items-center justify-center"> <div className="w-full h-full overflow-hidden flex items-center justify-center">
<div>Wystąpił błąd: {error.status === 422 ? error.value.message : error.value}</div> <div>
Wystąpił błąd: {Match.value(error).pipe(
Match.when({ status: 422 }, ({ value }) => value.message),
Match.when({ status: 404 }, () => "Załącznik nie istnieje"),
Match.exhaustive,
)}
</div>
</div> </div>
); );
} }

View File

@@ -38,7 +38,7 @@ export function Piece() {
return ( return (
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4"> <div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
{error !== null ? ( {error !== null ? (
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}` Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`
) : (<> ) : (<>
<div className="flex flex-col gap-4 p-4 border rounded"> <div className="flex flex-col gap-4 p-4 border rounded">
<h3 className="font-bold text-lg">Utwór</h3> <h3 className="font-bold text-lg">Utwór</h3>

View File

@@ -8,7 +8,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { useLoadingEffect } from "@/hooks/useLoading"; import { useLoadingEffect } from "@/hooks/useLoading";
import { authors, created, DEBOUNCE, modified } from "@/snippets"; import { authors, created, DEBOUNCE, modified } from "@/snippets";
import { PieceId } from "common"; import { PieceId } from "common";
import { Cause, Effect } from "effect"; import { Cause, Effect, Match } from "effect";
import { Loader2, Plus } from "lucide-react"; import { Loader2, Plus } from "lucide-react";
import { FormEventHandler, useId, useRef, useState } from "react"; import { FormEventHandler, useId, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -91,7 +91,7 @@ export function Pieces() {
) : error !== null ? ( ) : error !== null ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center"> <TableCell colSpan={4} className="text-center">
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}`} {Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -124,7 +124,14 @@ function PieceRow(props: PieceRow.Props) {
if (error !== null) { if (error !== null) {
return ( return (
<TableRow> <TableRow>
<TableCell colSpan={4}>Wystąpił błąd: {error.status === 422 ? error.value.message : error.value}</TableCell> <TableCell colSpan={4}>
Wystąpił błąd: {Match.value(error).pipe(
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
Match.when({ status: 422 }, ({ value }) => value.message),
Match.when({ status: 404 }, () => "Utwór nie istnieje"),
Match.exhaustive,
)}
</TableCell>
</TableRow> </TableRow>
); );
} }

View File

@@ -9,7 +9,7 @@ import { useLoadingEffect } from "@/hooks/useLoading";
import { mapProp, Update, Updater } from "@/hooks/useStore"; import { mapProp, Update, Updater } from "@/hooks/useStore";
import { authors, DEBOUNCE, saveDelay } from "@/snippets"; import { authors, DEBOUNCE, saveDelay } from "@/snippets";
import { PieceId, RepertoireId } from "common"; import { PieceId, RepertoireId } from "common";
import { Array, Cause, Effect, Option, pipe } from "effect"; import { Array, Cause, Effect, Match, Option, pipe } from "effect";
import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react"; import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
import { FormEventHandler, useCallback, useId, useMemo, useRef, useState } from "react"; import { FormEventHandler, useCallback, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
@@ -36,7 +36,7 @@ export function Repertoire() {
return ( return (
<div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4"> <div className="p-4 overflow-y-auto flex flex-wrap items-start gap-4">
{error !== null ? ( {error !== null ? (
Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}` Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`
) : (<> ) : (<>
<div className="flex flex-col gap-4 p-4 border rounded"> <div className="flex flex-col gap-4 p-4 border rounded">
<h3 className="font-bold">Repertuar</h3> <h3 className="font-bold">Repertuar</h3>
@@ -343,7 +343,7 @@ function AddEntryDialogContent(props: AddEntryDialogContent.Props) {
) : error !== null ? ( ) : error !== null ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center"> <TableCell colSpan={4} className="text-center">
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}`} {Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -410,7 +410,14 @@ function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
if (error !== null) { if (error !== null) {
return ( return (
<TableRow> <TableRow>
<TableCell colSpan={2}>Wystąpił błąd: {error.status === 422 ? error.value.message : error.value}</TableCell> <TableCell colSpan={2}>
Wystąpił błąd: {Match.value(error).pipe(
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
Match.when({ status: 422 }, ({ value }) => value.message),
Match.when({ status: 404 }, () => "Utwór nie istnieje"),
Match.exhaustive,
)}
</TableCell>
</TableRow> </TableRow>
); );
} }

View File

@@ -8,7 +8,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { useLoadingEffect } from "@/hooks/useLoading"; import { useLoadingEffect } from "@/hooks/useLoading";
import { created, DEBOUNCE, modified } from "@/snippets"; import { created, DEBOUNCE, modified } from "@/snippets";
import { RepertoireId } from "common"; import { RepertoireId } from "common";
import { Cause, Effect } from "effect"; import { Cause, Effect, Match } from "effect";
import { Loader2, Plus } from "lucide-react"; import { Loader2, Plus } from "lucide-react";
import { FormEventHandler, ReactNode, useId, useRef, useState } from "react"; import { FormEventHandler, ReactNode, useId, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -79,7 +79,7 @@ export function Repertoires() {
) : error !== null ? ( ) : error !== null ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center"> <TableCell colSpan={4} className="text-center">
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}`} {Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${JSON.stringify(error.value)}`}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -112,7 +112,14 @@ function RepertoireRow(props: RepertoireRow.Props) {
if (error !== null) { if (error !== null) {
return ( return (
<TableRow> <TableRow>
<TableCell colSpan={4}>Wystąpił błąd: {error.status === 422 ? error.value.message : error.value}</TableCell> <TableCell colSpan={4}>
Wystąpił błąd: {Match.value(error).pipe(
Match.when({ status: 401 }, () => "Zaloguj się ponownie"),
Match.when({ status: 422 }, ({ value }) => value.message),
Match.when({ status: 404 }, () => "Repertuar nie istnieje"),
Match.exhaustive,
)}
</TableCell>
</TableRow> </TableRow>
); );
} }

View File

@@ -36,7 +36,11 @@ export function created({ createdAt, createdBy }: SystemInformation): ReactNode
if (Option.isSome(createdBy)) { if (Option.isSome(createdBy)) {
nodes.push(<br />); nodes.push(<br />);
if (createdBy.value !== null) {
nodes.push(`przez ${createdBy.value.displayName}`); nodes.push(`przez ${createdBy.value.displayName}`);
} else {
nodes.push("przez nieznanego użytkownika");
}
} }
return nodes; return nodes;
@@ -48,7 +52,11 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo
if (Option.isNone(modifiedBy)) { if (Option.isNone(modifiedBy)) {
return "\u2014"; return "\u2014";
} else { } else {
if (modifiedBy.value !== null) {
return `przez ${modifiedBy.value.displayName}`; return `przez ${modifiedBy.value.displayName}`;
} else {
return "przez nieznanego użytkownika";
}
} }
} }
@@ -56,7 +64,11 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo
if (Option.isSome(modifiedBy)) { if (Option.isSome(modifiedBy)) {
nodes.push(<br />); nodes.push(<br />);
if (modifiedBy.value !== null) {
nodes.push(`przez ${modifiedBy.value.displayName}`); nodes.push(`przez ${modifiedBy.value.displayName}`);
} else {
nodes.push("przez nieznanego użytkownika");
}
} }
return nodes; return nodes;