mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
feat(authz): enforce owner-only delete permissions for pages and exercises, UI of search tool
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE html_pages
|
||||
ADD COLUMN IF NOT EXISTS created_by bigint;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'html_pages_created_by_fkey'
|
||||
) THEN
|
||||
ALTER TABLE html_pages
|
||||
ADD CONSTRAINT html_pages_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -616,13 +616,18 @@ CREATE TABLE public.html_pages (
|
||||
grammar_topic_id integer,
|
||||
description text,
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_by bigint
|
||||
updated_by bigint,
|
||||
created_by bigint
|
||||
);
|
||||
|
||||
CREATE INDEX html_pages_search_fts_idx ON public.html_pages USING gin (to_tsvector('simple'::regconfig, ((((COALESCE(title, ''::text) || ' '::text) || COALESCE(description, ''::text)) || ' '::text) || COALESCE(content, ''::text))));
|
||||
|
||||
CREATE TRIGGER update_html_pages_updated_at BEFORE UPDATE ON public.html_pages FOR EACH ROW EXECUTE FUNCTION updated_at();
|
||||
|
||||
ALTER TABLE public.html_pages
|
||||
ADD CONSTRAINT html_pages_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE public.html_pages
|
||||
ADD CONSTRAINT html_pages_grammar_topic_id_fkey
|
||||
FOREIGN KEY (grammar_topic_id) REFERENCES grammar_topics(id);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -38,6 +38,7 @@ The `@/*` alias maps to `./src/*`, making imports consistent regardless of file
|
||||
|
||||
- API route composition uses `withAuth` and `withInputValidation` from `backend/middleware` to apply auth checks and Zod validation.
|
||||
- Sessions are JWT-based (HS256) stored in an HTTP-only cookie (`AUTH_COOKIE_NAME`) via helpers in `backend/auth/session.js`.
|
||||
- Session probe endpoint `GET /api/auth/session` returns HTTP `200` for both authenticated and logged-out states (`{ loggedIn: true, user }` or `{ loggedIn: false }`).
|
||||
- Database access goes through the pooled `DB` object in `backend/db.js` and transactional helper `DB.transaction`.
|
||||
- Shared validation schemas are authored in `shared/schemas/` and consumed in API routes with `withInputValidation`.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
import DOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
@@ -193,9 +194,10 @@ export const DELETE = withAuth(
|
||||
|
||||
await DB.transaction(async (tx) => {
|
||||
const { rows } = await tx.query(
|
||||
`SELECT exercise_id
|
||||
FROM click_to_exercises
|
||||
WHERE click_id = $1`,
|
||||
`SELECT cte.exercise_id, e.created_by
|
||||
FROM click_to_exercises cte
|
||||
JOIN exercises e ON e.id = cte.exercise_id
|
||||
WHERE cte.click_id = $1`,
|
||||
[click_id]
|
||||
);
|
||||
|
||||
@@ -203,6 +205,10 @@ export const DELETE = withAuth(
|
||||
throw new Error("CLICK_EXERCISE_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!canDeleteOwnedContent(request.user, rows[0].created_by)) {
|
||||
throw new Error("FORBIDDEN");
|
||||
}
|
||||
|
||||
const exerciseId = rows[0].exercise_id;
|
||||
|
||||
await tx.query(
|
||||
@@ -229,6 +235,10 @@ export const DELETE = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === "FORBIDDEN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
console.error("Error deleting click exercise:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
import { dndMatchCreateSchema } from "@/shared/schemas/dnd-match.schemas";
|
||||
|
||||
@@ -132,7 +133,10 @@ export const DELETE = withAuth(
|
||||
const { match_id } = await params;
|
||||
|
||||
const { rows } = await DB.pool(
|
||||
`SELECT exercise_id FROM dnd_match_exercises WHERE id = $1`,
|
||||
`SELECT dme.exercise_id, e.created_by
|
||||
FROM dnd_match_exercises dme
|
||||
JOIN exercises e ON e.id = dme.exercise_id
|
||||
WHERE dme.id = $1`,
|
||||
[match_id]
|
||||
);
|
||||
|
||||
@@ -143,6 +147,10 @@ export const DELETE = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
if (!canDeleteOwnedContent(request.user, rows[0].created_by)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Deleting from exercises cascades to dnd_match_exercises → pairs → user_answers
|
||||
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
|
||||
rows[0].exercise_id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
const parseWords = (content) => [
|
||||
@@ -261,9 +262,10 @@ export const DELETE = withAuth(
|
||||
|
||||
await DB.transaction(async (tx) => {
|
||||
const dndRes = await tx.query(
|
||||
`SELECT exercise_id
|
||||
FROM dnd_exercises
|
||||
WHERE id = $1`,
|
||||
`SELECT dnde.exercise_id, e.created_by
|
||||
FROM dnd_exercises dnde
|
||||
JOIN exercises e ON e.id = dnde.exercise_id
|
||||
WHERE dnde.id = $1`,
|
||||
[dnd_id]
|
||||
);
|
||||
|
||||
@@ -271,6 +273,10 @@ export const DELETE = withAuth(
|
||||
throw new Error("DND_EXERCISE_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!canDeleteOwnedContent(request.user, dndRes.rows[0].created_by)) {
|
||||
throw new Error("FORBIDDEN");
|
||||
}
|
||||
|
||||
const exerciseId = dndRes.rows[0].exercise_id;
|
||||
|
||||
await tx.query(
|
||||
@@ -295,6 +301,10 @@ export const DELETE = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message === "FORBIDDEN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
console.error("Error deleting dragdrop exercise:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
import { fillGapCreateSchema } from "@/shared/schemas/fillinthegap.schemas";
|
||||
import {
|
||||
@@ -205,9 +206,10 @@ export const DELETE = withAuth(
|
||||
const { exercise_id } = await params;
|
||||
|
||||
const { rows } = await DB.pool(
|
||||
`SELECT exercise_id
|
||||
FROM fill_gap_exercises
|
||||
WHERE id = $1`,
|
||||
`SELECT fge.exercise_id, e.created_by
|
||||
FROM fill_gap_exercises fge
|
||||
JOIN exercises e ON e.id = fge.exercise_id
|
||||
WHERE fge.id = $1`,
|
||||
[exercise_id]
|
||||
);
|
||||
|
||||
@@ -218,6 +220,10 @@ export const DELETE = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
if (!canDeleteOwnedContent(request.user, rows[0].created_by)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
|
||||
rows[0].exercise_id,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export const GET = withAuth(
|
||||
@@ -22,8 +23,12 @@ export const GET = withAuth(
|
||||
`,
|
||||
[exercise_id]
|
||||
);
|
||||
|
||||
const parentExerciseId = rows[0].exercise_id;
|
||||
const createdBy = rows[0].created_by;
|
||||
if (exerciseRows.length === 0) {
|
||||
if (!canDeleteOwnedContent(request.user, createdBy)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Exercise not found" },
|
||||
{ status: 404 }
|
||||
@@ -215,9 +220,10 @@ export const DELETE = withAuth(
|
||||
|
||||
const { rows } = await DB.pool(
|
||||
`
|
||||
SELECT exercise_id
|
||||
FROM free_form_exercises
|
||||
WHERE id = $1
|
||||
SELECT ffe.exercise_id, e.created_by
|
||||
FROM free_form_exercises ffe
|
||||
JOIN exercises e ON e.id = ffe.exercise_id
|
||||
WHERE ffe.id = $1
|
||||
`,
|
||||
[exercise_id]
|
||||
);
|
||||
@@ -229,14 +235,16 @@ export const DELETE = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
const parentExerciseId = rows[0].exercise_id;
|
||||
if (!canDeleteOwnedContent(request.user, rows[0].created_by)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await DB.pool(
|
||||
`
|
||||
DELETE FROM exercises
|
||||
WHERE id = $1
|
||||
`,
|
||||
[parentExerciseId]
|
||||
[rows[0].exercise_id]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
|
||||
import { DB } from "@/backend/db";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export const GET = withAuth(async (request, { params }) => {
|
||||
@@ -105,6 +106,22 @@ export const DELETE = withAuth(
|
||||
const { id } = await params;
|
||||
try {
|
||||
await DB.transaction(async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`SELECT jse.exercise_id, e.created_by
|
||||
FROM jumbled_sentence_exercises jse
|
||||
JOIN exercises e ON e.id = jse.exercise_id
|
||||
WHERE jse.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error("NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!canDeleteOwnedContent(request.user, rows[0].created_by)) {
|
||||
throw new Error("FORBIDDEN");
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
|
||||
[id]
|
||||
@@ -116,6 +133,12 @@ export const DELETE = withAuth(
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
if (err.message === "FORBIDDEN") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (err.message === "NOT_FOUND") {
|
||||
return NextResponse.json({ error: "Übung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export const GET = withAuth(
|
||||
@@ -155,7 +156,10 @@ export const DELETE = withAuth(
|
||||
|
||||
try {
|
||||
const { rows } = await DB.pool(
|
||||
`SELECT exercise_id FROM memory_game_exercises WHERE id = $1`,
|
||||
`SELECT mge.exercise_id, e.created_by
|
||||
FROM memory_game_exercises mge
|
||||
JOIN exercises e ON e.id = mge.exercise_id
|
||||
WHERE mge.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
@@ -166,6 +170,10 @@ export const DELETE = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
if (!canDeleteOwnedContent(request.user, rows[0].created_by)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await DB.pool(`DELETE FROM exercises WHERE id = $1`, [
|
||||
rows[0].exercise_id,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
const validateContent = (content) => {
|
||||
@@ -246,9 +247,10 @@ export const DELETE = withAuth(
|
||||
const { exercise_id } = await params;
|
||||
|
||||
const { rows } = await DB.pool(
|
||||
`SELECT exercise_id
|
||||
FROM multichoice_exercises
|
||||
WHERE id = $1`,
|
||||
`SELECT mce.exercise_id, e.created_by
|
||||
FROM multichoice_exercises mce
|
||||
JOIN exercises e ON e.id = mce.exercise_id
|
||||
WHERE mce.id = $1`,
|
||||
[exercise_id]
|
||||
);
|
||||
|
||||
@@ -259,6 +261,10 @@ export const DELETE = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
if (!canDeleteOwnedContent(request.user, rows[0].created_by)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await DB.pool("DELETE FROM exercises WHERE id = $1", [
|
||||
rows[0].exercise_id,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getPageData, setPageData } from "@/backend/html-services";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
import { canDeleteOwnedContent } from "@/backend/content-permissions";
|
||||
import { JSDOM } from "jsdom";
|
||||
import DOMPurify from "dompurify";
|
||||
import { NextResponse } from "next/server";
|
||||
@@ -61,6 +62,21 @@ export const DELETE = withAuth(
|
||||
try {
|
||||
const { type, slug } = await params;
|
||||
|
||||
const pageResult = await DB.pool(
|
||||
`SELECT created_by
|
||||
FROM html_pages
|
||||
WHERE slug = $1 AND page_group = $2`,
|
||||
[slug, type]
|
||||
);
|
||||
|
||||
if (pageResult.rowCount === 0) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!canDeleteOwnedContent(req.user, pageResult.rows[0].created_by)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const query = `DELETE FROM html_pages WHERE slug = $1 AND page_group = $2 RETURNING *`;
|
||||
const result = await DB.pool(query, [slug, type]);
|
||||
|
||||
@@ -118,8 +134,8 @@ export const PUT = withAuth(
|
||||
|
||||
if (!updated && type === "grammar") {
|
||||
const insertQuery = `
|
||||
INSERT INTO html_pages (title, description, content, slug, page_group, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO html_pages (title, description, content, slug, page_group, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
@@ -130,6 +146,7 @@ export const PUT = withAuth(
|
||||
newData.slug,
|
||||
type,
|
||||
req.user?.id ?? null,
|
||||
req.user?.id ?? null,
|
||||
]);
|
||||
|
||||
updated = await setPageData(type, slug, newData);
|
||||
@@ -168,7 +185,7 @@ export const POST = withAuth(
|
||||
});
|
||||
}
|
||||
const { title, description } = await req.json();
|
||||
const query = `INSERT INTO html_pages (title, description, content, slug, page_group, updated_by) VALUES ($1, $2, $3, $4, $5, $6)`;
|
||||
const query = `INSERT INTO html_pages (title, description, content, slug, page_group, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7)`;
|
||||
const result = await DB.pool(query, [
|
||||
title,
|
||||
normalizePageDescription(description),
|
||||
@@ -176,6 +193,7 @@ export const POST = withAuth(
|
||||
slug,
|
||||
type,
|
||||
req.user?.id ?? null,
|
||||
req.user?.id ?? null,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0)
|
||||
|
||||
@@ -125,6 +125,32 @@ describe("PUT /api/admin/pages/[type]/[slug]", () => {
|
||||
expect(result.rows[0].content).toContain('data-size="320x180"');
|
||||
expect(result.rows[0].content).not.toContain("onerror");
|
||||
});
|
||||
|
||||
it("should set created_by when grammar fallback insert creates a page", async () => {
|
||||
const admin = await TestFactory.user({ is_admin: true });
|
||||
const { mockPut, mockParams } = useTestRequest(admin);
|
||||
|
||||
const response = await PUT(
|
||||
mockPut("/api/admin/pages/grammar/neu-thema", {
|
||||
title: "Neues Thema",
|
||||
content: "<p>Hallo</p>",
|
||||
}),
|
||||
mockParams({ type: "grammar", slug: "neu-thema" })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const inserted = await DB.pool(
|
||||
`SELECT created_by, updated_by
|
||||
FROM html_pages
|
||||
WHERE page_group = $1 AND slug = $2`,
|
||||
["grammar", "neu-thema"]
|
||||
);
|
||||
|
||||
expect(inserted.rowCount).toBe(1);
|
||||
expect(inserted.rows[0].created_by).toBe(admin.id);
|
||||
expect(inserted.rows[0].updated_by).toBe(admin.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/admin/pages/[type]/[slug]", () => {
|
||||
@@ -151,6 +177,10 @@ describe("DELETE /api/admin/pages/[type]/[slug]", () => {
|
||||
|
||||
it("should delete an existing page", async () => {
|
||||
const admin = await TestFactory.user({ is_admin: true });
|
||||
await DB.pool(
|
||||
`UPDATE html_pages SET created_by = $1 WHERE page_group = $2 AND slug = $3`,
|
||||
[admin.id, "resources", "test-page"]
|
||||
);
|
||||
const { mockDelete, mockParams } = useTestRequest(admin);
|
||||
|
||||
const response = await DELETE(
|
||||
@@ -182,4 +212,43 @@ describe("DELETE /api/admin/pages/[type]/[slug]", () => {
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe("Not found");
|
||||
});
|
||||
|
||||
it("should reject deleting a page created by another admin", async () => {
|
||||
const owner = await TestFactory.user({ is_admin: true });
|
||||
const otherAdmin = await TestFactory.user({ is_admin: true });
|
||||
await DB.pool(
|
||||
`UPDATE html_pages SET created_by = $1 WHERE page_group = $2 AND slug = $3`,
|
||||
[owner.id, "resources", "test-page"]
|
||||
);
|
||||
const { mockDelete, mockParams } = useTestRequest(otherAdmin);
|
||||
|
||||
const response = await DELETE(
|
||||
mockDelete("/api/admin/pages/resources/test-page"),
|
||||
mockParams({ type: "resources", slug: "test-page" })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe("Forbidden");
|
||||
});
|
||||
|
||||
it("should allow a superadmin to delete any page", async () => {
|
||||
const owner = await TestFactory.user({ is_admin: true });
|
||||
const superadmin = await TestFactory.user({
|
||||
is_admin: true,
|
||||
is_superadmin: true,
|
||||
});
|
||||
await DB.pool(
|
||||
`UPDATE html_pages SET created_by = $1 WHERE page_group = $2 AND slug = $3`,
|
||||
[owner.id, "resources", "test-page"]
|
||||
);
|
||||
const { mockDelete, mockParams } = useTestRequest(superadmin);
|
||||
|
||||
const response = await DELETE(
|
||||
mockDelete("/api/admin/pages/resources/test-page"),
|
||||
mockParams({ type: "resources", slug: "test-page" })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ export const POST = withAuth(
|
||||
}
|
||||
|
||||
await DB.pool(
|
||||
"INSERT INTO html_pages (title, description, content, slug, page_group, page_order, grammar_topic_id, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
"INSERT INTO html_pages (title, description, content, slug, page_group, page_order, grammar_topic_id, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
[
|
||||
trimmedTitle,
|
||||
pageDescription,
|
||||
@@ -130,6 +130,7 @@ export const POST = withAuth(
|
||||
pageOrder,
|
||||
grammarTopicId,
|
||||
req.user?.id ?? null,
|
||||
req.user?.id ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("POST /api/admin/pages", () => {
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const inserted = await DB.pool(
|
||||
`SELECT slug, title, description, page_order, grammar_topic_id
|
||||
`SELECT slug, title, description, page_order, grammar_topic_id, created_by
|
||||
FROM html_pages
|
||||
WHERE page_group = 'resources' AND slug = 'new-resource'`
|
||||
);
|
||||
@@ -53,6 +53,7 @@ describe("POST /api/admin/pages", () => {
|
||||
expect(inserted.rows[0].description).toBe("Resource summary");
|
||||
expect(inserted.rows[0].page_order).toBe(expectedNextOrder);
|
||||
expect(inserted.rows[0].grammar_topic_id).toBeNull();
|
||||
expect(inserted.rows[0].created_by).toBe(admin.id);
|
||||
});
|
||||
|
||||
it("creates a grammar page without topic when none is selected", async () => {
|
||||
@@ -69,7 +70,7 @@ describe("POST /api/admin/pages", () => {
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const inserted = await DB.pool(
|
||||
`SELECT slug, page_order, grammar_topic_id
|
||||
`SELECT slug, page_order, grammar_topic_id, created_by
|
||||
FROM html_pages
|
||||
WHERE page_group = 'grammar' AND slug = 'untopicked-grammar'`
|
||||
);
|
||||
@@ -77,6 +78,7 @@ describe("POST /api/admin/pages", () => {
|
||||
expect(inserted.rowCount).toBe(1);
|
||||
expect(inserted.rows[0].page_order).toBeNull();
|
||||
expect(inserted.rows[0].grammar_topic_id).toBeNull();
|
||||
expect(inserted.rows[0].created_by).toBe(admin.id);
|
||||
});
|
||||
|
||||
it("creates a grammar page with existing topicId", async () => {
|
||||
@@ -98,13 +100,14 @@ describe("POST /api/admin/pages", () => {
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const inserted = await DB.pool(
|
||||
`SELECT grammar_topic_id
|
||||
`SELECT grammar_topic_id, created_by
|
||||
FROM html_pages
|
||||
WHERE page_group = 'grammar' AND slug = 'syntax-kapitel'`
|
||||
);
|
||||
|
||||
expect(inserted.rowCount).toBe(1);
|
||||
expect(inserted.rows[0].grammar_topic_id).toBe(topic.rows[0].id);
|
||||
expect(inserted.rows[0].created_by).toBe(admin.id);
|
||||
});
|
||||
|
||||
it("creates and reuses topic by case-insensitive name", async () => {
|
||||
|
||||
@@ -6,9 +6,6 @@ export async function GET(request) {
|
||||
if (user) {
|
||||
return NextResponse.json({ loggedIn: true, user });
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ loggedIn: false, error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ loggedIn: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("GET /api/auth/session", () => {
|
||||
|
||||
expect(checkSession).toHaveBeenCalled();
|
||||
expect(response).toBeInstanceOf(NextResponse);
|
||||
expect(result).toEqual({ loggedIn: false, error: "Unauthorized" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(result).toEqual({ loggedIn: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function Login() {
|
||||
type="text"
|
||||
id="identifier"
|
||||
name="identifier"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
disabled={isLoading}
|
||||
@@ -85,6 +86,7 @@ export default function Login() {
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -79,9 +79,10 @@ export default function Register() {
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="username"
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
@@ -97,6 +98,7 @@ export default function Register() {
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
@@ -112,6 +114,7 @@ export default function Register() {
|
||||
type="email"
|
||||
id="confirmEmail"
|
||||
name="confirmEmail"
|
||||
autoComplete="email"
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
required
|
||||
@@ -127,6 +130,7 @@ export default function Register() {
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
@@ -142,6 +146,7 @@ export default function Register() {
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Row } from "@/components/ui/layout/container";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function AdminButtons({ type, slug, pageExists = true }) {
|
||||
const { auth } = useUser();
|
||||
@@ -48,10 +49,11 @@ export default function AdminButtons({ type, slug, pageExists = true }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await res.text();
|
||||
alert(
|
||||
`Fehler beim Löschen (HTTP ${res.status}): ${message || "Unbekannter Fehler"}`
|
||||
const message = await getDeleteErrorMessage(
|
||||
res,
|
||||
"Fehler beim Löschen."
|
||||
);
|
||||
alert(message);
|
||||
};
|
||||
|
||||
if (isSuperAdmin) {
|
||||
|
||||
@@ -53,9 +53,14 @@ export default function EditPassword() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label className="block mb-1">Aktuelles Passwort</label>
|
||||
<label className="block mb-1" htmlFor="currentPassword">
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="w-full border rounded px-3 py-2"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
@@ -63,9 +68,14 @@ export default function EditPassword() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1">Neues Passwort</label>
|
||||
<label className="block mb-1" htmlFor="newPassword">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="w-full border rounded px-3 py-2"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
@@ -73,9 +83,14 @@ export default function EditPassword() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1">Neues Passwort bestätigen</label>
|
||||
<label className="block mb-1" htmlFor="confirmPassword">
|
||||
Neues Passwort bestätigen
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="w-full border rounded px-3 py-2"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import useQuery from "@/shared/hooks/useQuery";
|
||||
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function ClickExercisesPage() {
|
||||
const {
|
||||
@@ -29,13 +30,21 @@ export default function ClickExercisesPage() {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Löschen der Übung.");
|
||||
const message = await getDeleteErrorMessage(
|
||||
response,
|
||||
"Fehler beim Löschen der Übung."
|
||||
);
|
||||
if (response.status === 403) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
refetch();
|
||||
} catch (deleteError) {
|
||||
console.error("Error deleting exercise:", deleteError);
|
||||
alert("Fehler beim Löschen der Übung.");
|
||||
alert(deleteError?.message || "Fehler beim Löschen der Übung.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import useQuery from "@/shared/hooks/useQuery";
|
||||
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function DndMatchExercisesPage() {
|
||||
const {
|
||||
@@ -26,10 +27,16 @@ export default function DndMatchExercisesPage() {
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error("Fehler beim Löschen der Übung.");
|
||||
if (!response.ok) {
|
||||
const message = await getDeleteErrorMessage(
|
||||
response,
|
||||
"Fehler beim Löschen der Übung."
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
refetch();
|
||||
} catch {
|
||||
alert("Fehler beim Löschen der Übung.");
|
||||
} catch (deleteError) {
|
||||
alert(deleteError?.message || "Fehler beim Löschen der Übung.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import useQuery from "@/shared/hooks/useQuery";
|
||||
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function DragdropExercisesPage() {
|
||||
const {
|
||||
@@ -29,13 +30,21 @@ export default function DragdropExercisesPage() {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Löschen der Übung.");
|
||||
const message = await getDeleteErrorMessage(
|
||||
response,
|
||||
"Fehler beim Löschen der Übung."
|
||||
);
|
||||
if (response.status === 403) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
refetch();
|
||||
} catch (deleteError) {
|
||||
console.error("Error deleting exercise:", deleteError);
|
||||
alert("Fehler beim Löschen der Übung.");
|
||||
alert(deleteError?.message || "Fehler beim Löschen der Übung.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import useQuery from "@/shared/hooks/useQuery";
|
||||
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function FillInTheGapExercisesPage() {
|
||||
const {
|
||||
@@ -29,13 +30,21 @@ export default function FillInTheGapExercisesPage() {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Löschen der Übung.");
|
||||
const message = await getDeleteErrorMessage(
|
||||
response,
|
||||
"Fehler beim Löschen der Übung."
|
||||
);
|
||||
if (response.status === 403) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
refetch();
|
||||
} catch (deleteError) {
|
||||
console.error("Error deleting exercise:", deleteError);
|
||||
alert("Fehler beim Löschen der Übung.");
|
||||
alert(deleteError?.message || "Fehler beim Löschen der Übung.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import useQuery from "@/shared/hooks/useQuery";
|
||||
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function FreeFormExercisesPage() {
|
||||
const {
|
||||
@@ -29,12 +30,20 @@ export default function FreeFormExercisesPage() {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Löschen der Übung.");
|
||||
const message = await getDeleteErrorMessage(
|
||||
response,
|
||||
"Fehler beim Löschen der Übung."
|
||||
);
|
||||
if (response.status === 403) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error("Error deleting exercise:", error);
|
||||
alert("Fehler beim Löschen der Übung.");
|
||||
alert(error?.message || "Fehler beim Löschen der Übung.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import useQuery from "@/shared/hooks/useQuery";
|
||||
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function JumbledSentenceExercisesPage() {
|
||||
const {
|
||||
@@ -26,13 +27,21 @@ export default function JumbledSentenceExercisesPage() {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Löschen der Übung.");
|
||||
const message = await getDeleteErrorMessage(
|
||||
response,
|
||||
"Fehler beim Löschen der Übung."
|
||||
);
|
||||
if (response.status === 403) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
refetch();
|
||||
} catch (deleteError) {
|
||||
console.error("Error deleting exercise:", deleteError);
|
||||
alert("Fehler beim Löschen der Übung.");
|
||||
alert(deleteError?.message || "Fehler beim Löschen der Übung.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import useQuery from "@/shared/hooks/useQuery";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function MemoryGameExercisesPage() {
|
||||
const {
|
||||
@@ -26,13 +27,21 @@ export default function MemoryGameExercisesPage() {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Löschen der Übung.");
|
||||
const message = await getDeleteErrorMessage(
|
||||
response,
|
||||
"Fehler beim Löschen der Übung."
|
||||
);
|
||||
if (response.status === 403) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
refetch();
|
||||
} catch (deleteError) {
|
||||
console.error("Error deleting exercise:", deleteError);
|
||||
alert("Fehler beim Löschen der Übung.");
|
||||
alert(deleteError?.message || "Fehler beim Löschen der Übung.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import useQuery from "@/shared/hooks/useQuery";
|
||||
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function MultichoiceExercisesPage() {
|
||||
const {
|
||||
@@ -29,13 +30,21 @@ export default function MultichoiceExercisesPage() {
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Löschen der Übung.");
|
||||
const message = await getDeleteErrorMessage(
|
||||
response,
|
||||
"Fehler beim Löschen der Übung."
|
||||
);
|
||||
if (response.status === 403) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
refetch();
|
||||
} catch (deleteError) {
|
||||
console.error("Error deleting exercise:", deleteError);
|
||||
alert("Fehler beim Löschen der Übung.");
|
||||
alert(deleteError?.message || "Fehler beim Löschen der Übung.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUser } from "@/context/user.context";
|
||||
import { Row } from "@/components/ui/layout/container";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import { getDeleteErrorMessage } from "@/shared/utils/deleteErrorMessage";
|
||||
|
||||
export default function AdminButtons({ type, slug, pageExists = true }) {
|
||||
const { setActAs } = useUser();
|
||||
@@ -92,10 +93,11 @@ export default function AdminButtons({ type, slug, pageExists = true }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await res.text();
|
||||
alert(
|
||||
`Fehler beim Löschen (HTTP ${res.status}): ${message || "Unbekannter Fehler"}`
|
||||
const message = await getDeleteErrorMessage(
|
||||
res,
|
||||
"Fehler beim Löschen."
|
||||
);
|
||||
alert(message);
|
||||
};
|
||||
|
||||
if (useIsAdmin()) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function canDeleteOwnedContent(user, ownerId) {
|
||||
if (user?.is_superadmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ownerId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return String(user.id) === String(ownerId);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canDeleteOwnedContent } from "./content-permissions";
|
||||
|
||||
describe("canDeleteOwnedContent", () => {
|
||||
it("allows superadmins to delete any content", () => {
|
||||
expect(
|
||||
canDeleteOwnedContent({ id: 1, is_admin: true, is_superadmin: true }, 99)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows admins to delete their own content", () => {
|
||||
expect(
|
||||
canDeleteOwnedContent({ id: 7, is_admin: true, is_superadmin: false }, 7)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects admins deleting someone else's content", () => {
|
||||
expect(
|
||||
canDeleteOwnedContent({ id: 7, is_admin: true, is_superadmin: false }, 8)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects admins deleting content without an owner", () => {
|
||||
expect(
|
||||
canDeleteOwnedContent({ id: 7, is_admin: true, is_superadmin: false }, null)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@
|
||||
background: var(--bg2);
|
||||
color: var(--fg1);
|
||||
padding: 10px 14px;
|
||||
font-size: var(--font-sm);
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
.navbar-search-input:focus {
|
||||
@@ -29,9 +29,9 @@
|
||||
}
|
||||
|
||||
.navbar-search-helper {
|
||||
margin-top: 4px;
|
||||
margin-left: 15px;
|
||||
color: var(--fg4);
|
||||
font-size: var(--font-xs);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.navbar-search-dropdown {
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
.navbar-search-item-label {
|
||||
color: var(--fg4);
|
||||
font-size: var(--font-xs);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.navbar-search-item-title {
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
.navbar-search-item-snippet {
|
||||
color: var(--fg3);
|
||||
font-size: var(--font-xs);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export default function ProfilePage() {
|
||||
|
||||
- The `UserProvider` is configured in the root layout (`app/layout.js`), so the context is available app-wide.
|
||||
- The context fetches session data from `/api/auth/session` and calls `/api/auth/logout` to end sessions.
|
||||
- `/api/auth/session` returns `200` in both states: `{ loggedIn: true, user }` when authenticated and `{ loggedIn: false }` when no session exists.
|
||||
- Session checks run on mount; prefer hooks (`useUser`, `useIsLoggedIn`) to access the state.
|
||||
|
||||
## `glossaryContext`
|
||||
|
||||
@@ -65,8 +65,7 @@ export function UserProvider({ children }) {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* checkUserSession - internal helper to call `/auth/session` and
|
||||
* update the `auth` state accordingly. Allows HTTP 401 responses to
|
||||
* be handled gracefully (treated as not logged in).
|
||||
* update the `auth` state accordingly.
|
||||
*
|
||||
* @returns {Promise<void>} resolves when the session check finishes.
|
||||
*/
|
||||
@@ -74,17 +73,9 @@ export function UserProvider({ children }) {
|
||||
try {
|
||||
const response = await makeRequest("/auth/session", undefined, {
|
||||
method: "GET",
|
||||
}).catch((e) => {
|
||||
// Allow 401s and treat them as "not logged in"
|
||||
const status = e?.response?.status ?? e?.status;
|
||||
|
||||
if (status !== 401) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { status: 401, data: null };
|
||||
});
|
||||
if (response.status === 200 && response.data) {
|
||||
|
||||
if (response.status === 200 && response.data?.loggedIn) {
|
||||
const user = response.data.user;
|
||||
setAuth((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export async function getDeleteErrorMessage(response, fallbackMessage) {
|
||||
if (response.status === 403) {
|
||||
return "Sie haben keine Berechtigung, diesen Inhalt zu löschen.";
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.";
|
||||
}
|
||||
|
||||
const message = await response.text();
|
||||
return message || fallbackMessage;
|
||||
}
|
||||
@@ -4,10 +4,10 @@
|
||||
"e05c51b8ad1b3eb66010-0e43830ad8f2dfd8d1e2",
|
||||
"c5b6e4261f23dff4104e-cb5330f948ccf9c8b61e",
|
||||
"c5b6e4261f23dff4104e-fd730098ea5d4ab30d2a",
|
||||
"d5ed79a6b333c2ba7209-ea64ca5a0854b7439e18",
|
||||
"d5ed79a6b333c2ba7209-3a1aa6a9369793a93f4b",
|
||||
"c5b6e4261f23dff4104e-4c06fad2b2a291767cec",
|
||||
"18e1443b7169cebccd21-4a0da125f055d790cf05",
|
||||
"cf4ffee829137744e3cd-e6406448e73a28c0ddd9",
|
||||
"d936625e1207b3e19d4b-67eb4c2dd9e8efb88e3d",
|
||||
"d8918302922cfb4b5ab2-53975d1f9ee0854667fa"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user