From 0ba8202cf5558dd1d3d14ca4b7f97a34afd32abb Mon Sep 17 00:00:00 2001 From: Erza Bytyci Date: Thu, 14 May 2026 20:36:45 +0000 Subject: [PATCH] feat: add remove user option, improve Click edit functionality, UI --- gradesa/src/app/admin/users/page.js | 87 ++++++++++++++----- gradesa/src/app/api/admin/users/route.js | 75 ++++++++++++++++ gradesa/src/app/api/admin/users/route.test.js | 87 +++++++++++++++++++ gradesa/src/app/page.module.css | 1 - gradesa/src/components/ui/click/click.js | 10 ++- 5 files changed, 238 insertions(+), 22 deletions(-) create mode 100644 gradesa/src/app/api/admin/users/route.test.js 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;