Merge pull request #507 from erzabytyci/user-tiers

feat: add remove user option, improve Click edit functionality, UI
This commit is contained in:
erzabytyci
2026-05-14 23:43:07 +03:00
committed by GitHub
5 changed files with 238 additions and 22 deletions
+67 -20
View File
@@ -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() {
<td style={{ padding: "12px" }}>{user.email}</td>
<td style={{ padding: "12px" }}>{getRoleLabel(user)}</td>
<td style={{ padding: "12px" }}>
<Button
variant="outline"
size="sm"
onClick={() => handleRoleChange(user)}
disabled={
updatingId === user.id ||
user.is_superadmin ||
ownAccount
}
>
{ownAccount
? "Eigener Account"
: user.is_superadmin
? "Administrator"
: updatingId === user.id
? "Wird aktualisiert..."
: user.is_admin
? "Zu Student machen"
: "Zu Lehrer machen"}
</Button>
<Row gap="sm" align="center">
<Button
variant="outline"
size="sm"
onClick={() => handleRoleChange(user)}
disabled={
updatingId === user.id ||
deletingId === user.id ||
user.is_superadmin ||
ownAccount
}
>
{ownAccount
? "Eigener Account"
: user.is_superadmin
? "Administrator"
: updatingId === user.id
? "Wird aktualisiert..."
: user.is_admin
? "Zu Student machen"
: "Zu Lehrer machen"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteUser(user)}
disabled={
deletingId === user.id ||
updatingId === user.id ||
ownAccount
}
style={{
color: "#fff5f5",
borderColor: "#d85a5a",
backgroundColor: "#d85a5a",
}}
>
{ownAccount
? "Nicht löschbar"
: deletingId === user.id
? "Löschen..."
: "Löschen"}
</Button>
</Row>
</td>
</tr>
);
+75
View File
@@ -101,3 +101,78 @@ export const PATCH = withAuth(
requireAuth: true,
}
);
/**
* DELETE /api/admin/users?id=<userId>
*
* 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,
}
);
@@ -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);
});
});
-1
View File
@@ -72,7 +72,6 @@
.creditsList {
display: grid;
gap: 0.9rem;
margin-bottom: 1.5rem;
}
+9 -1
View File
@@ -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;