diff --git a/gradesa/src/app/admin/users/page.js b/gradesa/src/app/admin/users/page.js
index f518cc7..63b34af 100644
--- a/gradesa/src/app/admin/users/page.js
+++ b/gradesa/src/app/admin/users/page.js
@@ -27,6 +27,7 @@ export default function UsersPage() {
const makeRequest = useRequest();
const [updatingId, setUpdatingId] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
const [search, setSearch] = useState("");
/**
@@ -108,6 +109,28 @@ export default function UsersPage() {
}
};
+ const handleDeleteUser = async (user) => {
+ if (!confirm(`Möchten Sie ${user.username} wirklich löschen?`)) {
+ return;
+ }
+
+ try {
+ setDeletingId(user.id);
+
+ await makeRequest(null, null, {
+ method: "DELETE",
+ url: `/admin/users?id=${encodeURIComponent(user.id)}`,
+ });
+
+ await refetch();
+ } catch (error) {
+ console.error("Error deleting user:", error);
+ alert(error.message || "Fehler beim Löschen des Benutzers");
+ } finally {
+ setDeletingId(null);
+ }
+ };
+
/**
* Return the display role used in the current UI model.
*/
@@ -171,26 +194,50 @@ export default function UsersPage() {
{user.email} |
{getRoleLabel(user)} |
-
+
+
+
+
|
);
diff --git a/gradesa/src/app/api/admin/users/route.js b/gradesa/src/app/api/admin/users/route.js
index 42ae294..c27d90d 100644
--- a/gradesa/src/app/api/admin/users/route.js
+++ b/gradesa/src/app/api/admin/users/route.js
@@ -101,3 +101,78 @@ export const PATCH = withAuth(
requireAuth: true,
}
);
+
+/**
+ * DELETE /api/admin/users?id=
+ *
+ * Deletes a user from the system.
+ *
+ * Before deleting the user row, any exercises created by that user are detached
+ * by clearing `exercises.created_by`. Most answer tables already cascade on
+ * user deletion, so this keeps the delete operation reliable for old and active
+ * accounts alike.
+ */
+export const DELETE = withAuth(
+ async (req) => {
+ if (!req.user?.is_superadmin) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(req.url);
+ const id = searchParams.get("id");
+
+ if (!id) {
+ return NextResponse.json(
+ { error: "User id is required" },
+ { status: 400 }
+ );
+ }
+
+ if (String(req.user.id) === String(id)) {
+ return NextResponse.json(
+ { error: "You cannot delete your own account" },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const deletedUser = await DB.transaction(async (tx) => {
+ await tx.query(
+ `
+ UPDATE exercises
+ SET created_by = NULL
+ WHERE created_by = $1
+ `,
+ [id]
+ );
+
+ const result = await tx.query(
+ `
+ DELETE FROM users
+ WHERE id = $1
+ RETURNING id, username, email, is_admin, is_superadmin
+ `,
+ [id]
+ );
+
+ return result.rows[0] || null;
+ });
+
+ if (!deletedUser) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({ success: true, user: deletedUser });
+ } catch (error) {
+ console.error("Error deleting user:", error);
+ return NextResponse.json(
+ { error: "Failed to delete user" },
+ { status: 500 }
+ );
+ }
+ },
+ {
+ requireAdmin: true,
+ requireAuth: true,
+ }
+);
diff --git a/gradesa/src/app/api/admin/users/route.test.js b/gradesa/src/app/api/admin/users/route.test.js
new file mode 100644
index 0000000..7790ee7
--- /dev/null
+++ b/gradesa/src/app/api/admin/users/route.test.js
@@ -0,0 +1,87 @@
+import { describe, it, expect } from "vitest";
+import { DELETE } from "./route";
+import { useTestDatabase } from "@/backend/test/testdb";
+import { useTestRequest } from "@/backend/test/mock-request";
+import { TestFactory } from "@/backend/test/testfactory";
+import { DB } from "@/backend/db";
+
+describe("DELETE /api/admin/users", () => {
+ useTestDatabase();
+
+ it("should delete an existing user", async () => {
+ const superadmin = await TestFactory.user({
+ is_admin: true,
+ is_superadmin: true,
+ });
+ const user = await TestFactory.user();
+ const { mockDelete } = useTestRequest(superadmin);
+
+ const response = await DELETE(mockDelete(`/api/admin/users?id=${user.id}`));
+
+ expect(response.status).toBe(200);
+
+ const json = await response.json();
+ expect(json.success).toBe(true);
+ expect(String(json.user.id)).toBe(String(user.id));
+
+ const result = await DB.pool("SELECT id FROM users WHERE id = $1", [
+ user.id,
+ ]);
+ expect(result.rowCount).toBe(0);
+ });
+
+ it("should clear created_by before deleting the user", async () => {
+ const superadmin = await TestFactory.user({
+ is_admin: true,
+ is_superadmin: true,
+ });
+ const author = await TestFactory.user();
+ const exercise = await TestFactory.exercise({
+ category: "freeform",
+ created_by: author.id,
+ });
+ const { mockDelete } = useTestRequest(superadmin);
+
+ const response = await DELETE(
+ mockDelete(`/api/admin/users?id=${author.id}`)
+ );
+
+ expect(response.status).toBe(200);
+
+ const exerciseResult = await DB.pool(
+ "SELECT created_by FROM exercises WHERE id = $1",
+ [exercise.id]
+ );
+ expect(exerciseResult.rows[0].created_by).toBeNull();
+ });
+
+ it("should reject deleting the current superadmin account", async () => {
+ const superadmin = await TestFactory.user({
+ is_admin: true,
+ is_superadmin: true,
+ });
+ const { mockDelete } = useTestRequest(superadmin);
+
+ const response = await DELETE(
+ mockDelete(`/api/admin/users?id=${superadmin.id}`)
+ );
+
+ expect(response.status).toBe(400);
+
+ const json = await response.json();
+ expect(json.error).toBe("You cannot delete your own account");
+ });
+
+ it("should return 401 for non-superadmin users", async () => {
+ const admin = await TestFactory.user({
+ is_admin: true,
+ is_superadmin: false,
+ });
+ const user = await TestFactory.user();
+ const { mockDelete } = useTestRequest(admin);
+
+ const response = await DELETE(mockDelete(`/api/admin/users?id=${user.id}`));
+
+ expect(response.status).toBe(401);
+ });
+});
diff --git a/gradesa/src/app/page.module.css b/gradesa/src/app/page.module.css
index f9be738..350ed6c 100644
--- a/gradesa/src/app/page.module.css
+++ b/gradesa/src/app/page.module.css
@@ -72,7 +72,6 @@
.creditsList {
display: grid;
- gap: 0.9rem;
margin-bottom: 1.5rem;
}
diff --git a/gradesa/src/components/ui/click/click.js b/gradesa/src/components/ui/click/click.js
index ef57f3f..844b92e 100644
--- a/gradesa/src/components/ui/click/click.js
+++ b/gradesa/src/components/ui/click/click.js
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import { Button } from "../button";
import { Container } from "../layout/container";
import { Column } from "@/components/ui/layout/container";
@@ -209,6 +209,14 @@ const WordSelectionExercise = ({
);
}, [targetWords, targetSlotKeySet]);
+ // Initialize selected words in admin preview mode with the target words
+ // so they display as pre-selected when editing an exercise
+ useEffect(() => {
+ if (isPreviewMode && targetSlotKeys.length > 0) {
+ setSelectedSlotKeys(targetSlotKeys);
+ }
+ }, [targetSlotKeys, isPreviewMode]);
+
const handleWordClick = (slotKey) => {
if (isSubmitted && !isPreviewMode) return;