feat(authz): enforce owner-only delete permissions for pages and exercises, UI of search tool

This commit is contained in:
Erza Bytyci
2026-07-19 14:42:37 +00:00
parent 4153142eb0
commit 5951d66c4f
38 changed files with 412 additions and 80 deletions
@@ -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 $$;
+6 -1
View File
@@ -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
+1
View File
@@ -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);
});
});
+2 -1
View File
@@ -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 () => {
+1 -4
View File
@@ -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 });
});
});
+2
View File
@@ -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}
+6 -1
View File
@@ -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
+5 -3
View File
@@ -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;
}
+1
View File
@@ -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`
+3 -12
View File
@@ -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;
}
+2 -2
View File
@@ -4,10 +4,10 @@
"e05c51b8ad1b3eb66010-0e43830ad8f2dfd8d1e2",
"c5b6e4261f23dff4104e-cb5330f948ccf9c8b61e",
"c5b6e4261f23dff4104e-fd730098ea5d4ab30d2a",
"d5ed79a6b333c2ba7209-ea64ca5a0854b7439e18",
"d5ed79a6b333c2ba7209-3a1aa6a9369793a93f4b",
"c5b6e4261f23dff4104e-4c06fad2b2a291767cec",
"18e1443b7169cebccd21-4a0da125f055d790cf05",
"cf4ffee829137744e3cd-e6406448e73a28c0ddd9",
"d936625e1207b3e19d4b-67eb4c2dd9e8efb88e3d",
"d8918302922cfb4b5ab2-53975d1f9ee0854667fa"
]
}