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 makeRequest = useRequest();
|
||||||
const [updatingId, setUpdatingId] = useState(null);
|
const [updatingId, setUpdatingId] = useState(null);
|
||||||
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
const [search, setSearch] = useState("");
|
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.
|
* 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" }}>{user.email}</td>
|
||||||
<td style={{ padding: "12px" }}>{getRoleLabel(user)}</td>
|
<td style={{ padding: "12px" }}>{getRoleLabel(user)}</td>
|
||||||
<td style={{ padding: "12px" }}>
|
<td style={{ padding: "12px" }}>
|
||||||
<Button
|
<Row gap="sm" align="center">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => handleRoleChange(user)}
|
size="sm"
|
||||||
disabled={
|
onClick={() => handleRoleChange(user)}
|
||||||
updatingId === user.id ||
|
disabled={
|
||||||
user.is_superadmin ||
|
updatingId === user.id ||
|
||||||
ownAccount
|
deletingId === user.id ||
|
||||||
}
|
user.is_superadmin ||
|
||||||
>
|
ownAccount
|
||||||
{ownAccount
|
}
|
||||||
? "Eigener Account"
|
>
|
||||||
: user.is_superadmin
|
{ownAccount
|
||||||
? "Administrator"
|
? "Eigener Account"
|
||||||
: updatingId === user.id
|
: user.is_superadmin
|
||||||
? "Wird aktualisiert..."
|
? "Administrator"
|
||||||
: user.is_admin
|
: updatingId === user.id
|
||||||
? "Zu Student machen"
|
? "Wird aktualisiert..."
|
||||||
: "Zu Lehrer machen"}
|
: user.is_admin
|
||||||
</Button>
|
? "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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -101,3 +101,78 @@ export const PATCH = withAuth(
|
|||||||
requireAuth: true,
|
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 {
|
.creditsList {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
|
||||||
margin-bottom: 1.5rem;
|
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 { Button } from "../button";
|
||||||
import { Container } from "../layout/container";
|
import { Container } from "../layout/container";
|
||||||
import { Column } from "@/components/ui/layout/container";
|
import { Column } from "@/components/ui/layout/container";
|
||||||
@@ -209,6 +209,14 @@ const WordSelectionExercise = ({
|
|||||||
);
|
);
|
||||||
}, [targetWords, targetSlotKeySet]);
|
}, [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) => {
|
const handleWordClick = (slotKey) => {
|
||||||
if (isSubmitted && !isPreviewMode) return;
|
if (isSubmitted && !isPreviewMode) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user