mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Merge pull request #507 from erzabytyci/user-tiers
feat: add remove user option, improve Click edit functionality, UI
This commit is contained in:
@@ -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,12 +194,14 @@ export default function UsersPage() {
|
||||
<td style={{ padding: "12px" }}>{user.email}</td>
|
||||
<td style={{ padding: "12px" }}>{getRoleLabel(user)}</td>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<Row gap="sm" align="center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRoleChange(user)}
|
||||
disabled={
|
||||
updatingId === user.id ||
|
||||
deletingId === user.id ||
|
||||
user.is_superadmin ||
|
||||
ownAccount
|
||||
}
|
||||
@@ -191,6 +216,28 @@ export default function UsersPage() {
|
||||
? "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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,6 @@
|
||||
|
||||
.creditsList {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user