mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
fixed 2 bugs that was left for the new team
This commit is contained in:
@@ -3,10 +3,9 @@
|
||||
import { Column, Row } from "@/components/ui/layout/container";
|
||||
import useQuery from "@/shared/hooks/useQuery";
|
||||
import { useRequest } from "@/shared/hooks/useRequest";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIsSuperAdmin, useUser } from "@/context/user.context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useUser } from "@/context/user.context";
|
||||
|
||||
/**
|
||||
* UsersPage
|
||||
@@ -19,30 +18,24 @@ import { useRouter } from "next/navigation";
|
||||
* - only superadmins may access it
|
||||
*/
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const { auth } = useUser();
|
||||
const isSuperAdmin = useIsSuperAdmin();
|
||||
const canViewUsers =
|
||||
auth.isAuthResolved && auth.isLoggedIn && auth.user?.is_superadmin;
|
||||
|
||||
const { data: users, error, isLoading, refetch } = useQuery("/admin/users");
|
||||
const {
|
||||
data: users,
|
||||
error,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery("/admin/users", undefined, {
|
||||
enabled: canViewUsers,
|
||||
});
|
||||
|
||||
const makeRequest = useRequest();
|
||||
const [updatingId, setUpdatingId] = useState(null);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
/**
|
||||
* Redirect unauthorized users away from this page.
|
||||
*
|
||||
* The `useIsSuperAdmin` hook already performs route protection logic,
|
||||
* but this extra redirect keeps the page behavior explicit and prevents
|
||||
* the user from remaining on `/admin/users` after losing access.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin === false) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [isSuperAdmin, router]);
|
||||
|
||||
/**
|
||||
* Filter users by username or email.
|
||||
*
|
||||
@@ -62,11 +55,15 @@ export default function UsersPage() {
|
||||
});
|
||||
}, [users, search]);
|
||||
|
||||
// Wait until the superadmin check has resolved before rendering the page.
|
||||
if (isSuperAdmin === undefined) {
|
||||
// Wait until the auth check has resolved before rendering the page.
|
||||
if (!auth.isAuthResolved) {
|
||||
return <p>Wird geladen...</p>;
|
||||
}
|
||||
|
||||
if (!auth.isLoggedIn || !auth.user?.is_superadmin) {
|
||||
return <p className="error">Sie haben keinen Zugriff auf diese Seite.</p>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p>Wird geladen...</p>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DB } from "@/backend/db";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
export const GET = withAuth(async (request, { params }) => {
|
||||
const { dnd_id } = await params;
|
||||
|
||||
if (!dnd_id || isNaN(parseInt(dnd_id, 10))) {
|
||||
@@ -64,4 +65,4 @@ export async function GET(request, { params }) {
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("freeform answers", () => {
|
||||
expect(result.similarity).toBe(1);
|
||||
expect(result.perfectAnswer).toBe(true);
|
||||
expect(result.feedback).toBe("Great job!");
|
||||
expect(result.answer).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should evaluate similar but not exact answers", async () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { POST } from "./route";
|
||||
|
||||
vi.mock("@/backend/auth/session", () => ({
|
||||
checkSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/backend/db", () => ({
|
||||
DB: {
|
||||
pool: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { checkSession } from "@/backend/auth/session";
|
||||
import { DB } from "@/backend/db";
|
||||
|
||||
function createRequest(body) {
|
||||
return {
|
||||
url: "http://localhost/api/exercises/freeform/answers",
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
method: "POST",
|
||||
cache: "default",
|
||||
credentials: "same-origin",
|
||||
integrity: "",
|
||||
mode: "cors",
|
||||
redirect: "follow",
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
describe("freeform answer response shape", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not echo the canonical answer", async () => {
|
||||
checkSession.mockResolvedValue({ id: 7, is_admin: false });
|
||||
DB.pool
|
||||
.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
answer: "This is the correct answer",
|
||||
is_correct: true,
|
||||
feedback: "Great job!",
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const response = await POST(
|
||||
createRequest({
|
||||
freeFormExerciseId: "12",
|
||||
freeFormQuestionId: "34",
|
||||
answer: "This is the correct answer",
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.answer).toBeUndefined();
|
||||
expect(result.is_correct).toBe(true);
|
||||
expect(result.perfectAnswer).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -260,7 +260,6 @@ export const POST = withAuth(
|
||||
|
||||
return NextResponse.json({
|
||||
is_correct: false,
|
||||
answer: "",
|
||||
feedback:
|
||||
"Your answer doesn't match any of the expected answers. Please try again.",
|
||||
similarity: bestMatch.similarity,
|
||||
@@ -307,7 +306,6 @@ export const POST = withAuth(
|
||||
|
||||
return NextResponse.json({
|
||||
is_correct: isAnswerCorrect,
|
||||
answer: bestMatch.answer,
|
||||
similarity: bestMatch.similarity,
|
||||
perfectAnswer,
|
||||
comparisonDetails,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
|
||||
import { DB } from "@/backend/db";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
export const GET = withAuth(async (request, { params }) => {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const exRes = await DB.pool(
|
||||
@@ -29,9 +30,10 @@ export async function GET(request, { params }) {
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function PUT(request, { params }) {
|
||||
export const PUT = withAuth(
|
||||
async (request, { params }) => {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parseResult = jumbledSentenceExerciseSchema.safeParse(body);
|
||||
@@ -71,9 +73,12 @@ export async function PUT(request, { params }) {
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
},
|
||||
{ requireAdmin: true }
|
||||
);
|
||||
|
||||
export async function DELETE(request, { params }) {
|
||||
export const DELETE = withAuth(
|
||||
async (request, { params }) => {
|
||||
const { id } = await params;
|
||||
try {
|
||||
await DB.transaction(async (client) => {
|
||||
@@ -90,4 +95,6 @@ export async function DELETE(request, { params }) {
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
},
|
||||
{ requireAdmin: true }
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
export const GET = withAuth(async (request, { params }) => {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id || Number.isNaN(Number(id))) {
|
||||
@@ -48,4 +49,4 @@ export async function GET(request, { params }) {
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export const POST = withAuth(async (request, { params }) => {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const answers = Array.isArray(body?.answers) ? body.answers : [];
|
||||
|
||||
if (!id || Number.isNaN(Number(id))) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid exercise ID." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (answers.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Answers are required." },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows: contentRows } = await DB.pool(
|
||||
`SELECT id, content_type, correct_answer
|
||||
FROM multichoice_content
|
||||
WHERE multichoice_exercise_id = $1
|
||||
ORDER BY content_order`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (contentRows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Exercise not found." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const answerMap = new Map(
|
||||
answers
|
||||
.filter((entry) => entry && entry.contentId != null)
|
||||
.map((entry) => [String(entry.contentId), String(entry.answer || "")])
|
||||
);
|
||||
|
||||
const results = contentRows
|
||||
.filter(
|
||||
(item) =>
|
||||
item.content_type === "multichoice" || item.content_type === "gap"
|
||||
)
|
||||
.map((item) => {
|
||||
const submitted = (answerMap.get(String(item.id)) || "").trim();
|
||||
const expected = String(item.correct_answer || "").trim();
|
||||
const isCorrect =
|
||||
submitted.length > 0 &&
|
||||
submitted.toLowerCase() === expected.toLowerCase();
|
||||
|
||||
return {
|
||||
contentId: item.id,
|
||||
isCorrect,
|
||||
answered: submitted.length > 0,
|
||||
};
|
||||
});
|
||||
|
||||
const hasMissing = results.some((entry) => !entry.answered);
|
||||
const allCorrect =
|
||||
results.length > 0 && results.every((entry) => entry.isCorrect);
|
||||
|
||||
return NextResponse.json({
|
||||
results,
|
||||
hasMissing,
|
||||
allCorrect,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error validating multichoice answers:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DB } from "@/backend/db";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
// --- FIX IS HERE ---
|
||||
export const GET = withAuth(async (request, { params }) => {
|
||||
// In Next.js 15, params is a Promise, so you must await it.
|
||||
const { id } = await params;
|
||||
|
||||
@@ -23,7 +23,15 @@ export async function GET(request, { params }) {
|
||||
|
||||
// Fetch exercise content
|
||||
const contentResult = await DB.pool(
|
||||
"SELECT * FROM multichoice_content WHERE multichoice_exercise_id = $1 ORDER BY content_order",
|
||||
`SELECT
|
||||
id,
|
||||
multichoice_exercise_id,
|
||||
content_type,
|
||||
content_value,
|
||||
content_order
|
||||
FROM multichoice_content
|
||||
WHERE multichoice_exercise_id = $1
|
||||
ORDER BY content_order`,
|
||||
[id]
|
||||
);
|
||||
const content = contentResult.rows;
|
||||
@@ -61,4 +69,4 @@ export async function GET(request, { params }) {
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DB } from "@/backend/db";
|
||||
import { withAuth } from "@/backend/middleware/withAuth";
|
||||
|
||||
export async function GET() {
|
||||
export const GET = withAuth(async () => {
|
||||
try {
|
||||
// Fetch multichoice exercises
|
||||
const { rows: exercises } = await DB.pool(`
|
||||
@@ -30,8 +31,7 @@ export async function GET() {
|
||||
mc.multichoice_exercise_id,
|
||||
mc.content_type,
|
||||
mc.content_value,
|
||||
mc.content_order,
|
||||
mc.correct_answer
|
||||
mc.content_order
|
||||
FROM
|
||||
multichoice_content mc
|
||||
WHERE
|
||||
@@ -83,4 +83,4 @@ export async function GET() {
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { checkSession } from "@/backend/auth/session";
|
||||
import { GET as getClick } from "./click/route";
|
||||
import { GET as getDragdrop } from "./dragdrop/[dnd_id]/route";
|
||||
import { GET as getMultichoiceList } from "./multichoice/route";
|
||||
import { GET as getMultichoiceDetail } from "./multichoice/[id]/route";
|
||||
import { GET as getMemoryGame } from "./memory-game/[id]/route";
|
||||
import {
|
||||
GET as getJumbledSentence,
|
||||
PUT as putJumbledSentence,
|
||||
DELETE as deleteJumbledSentence,
|
||||
} from "./jumbled-sentence/[id]/route";
|
||||
|
||||
vi.mock("@/backend/auth/session", () => ({
|
||||
checkSession: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("exercise API auth guards", () => {
|
||||
it("rejects unauthenticated access to answer-bearing routes", async () => {
|
||||
checkSession.mockResolvedValue(null);
|
||||
|
||||
const cases = [
|
||||
[getClick, {}],
|
||||
[getDragdrop, { params: Promise.resolve({ dnd_id: "1" }) }],
|
||||
[getMultichoiceList, {}],
|
||||
[getMultichoiceDetail, { params: Promise.resolve({ id: "1" }) }],
|
||||
[getMemoryGame, { params: Promise.resolve({ id: "1" }) }],
|
||||
[getJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
|
||||
[putJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
|
||||
[deleteJumbledSentence, { params: Promise.resolve({ id: "1" }) }],
|
||||
];
|
||||
|
||||
for (const [handler, context] of cases) {
|
||||
const response = await handler({}, context);
|
||||
expect(response.status).toBe(401);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { Row } from "../layout/container";
|
||||
import "./multichoice.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useQuery from "@/shared/hooks/useQuery";
|
||||
import { useRequest } from "@/shared/hooks/useRequest";
|
||||
|
||||
function shuffleOptions(array) {
|
||||
return array
|
||||
@@ -15,6 +16,7 @@ function shuffleOptions(array) {
|
||||
}
|
||||
|
||||
export default function MultichoicePage({ exerciseId }) {
|
||||
const makeRequest = useRequest();
|
||||
// Fetch exercise data using the useQuery hook
|
||||
const {
|
||||
data: exerciseData,
|
||||
@@ -75,37 +77,65 @@ export default function MultichoicePage({ exerciseId }) {
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const hasMissingAnswers = userAnswers.some((answer) => answer === "");
|
||||
const newCheckedAnswers = shuffledExerciseData.content.map(
|
||||
(item, index) => {
|
||||
(async () => {
|
||||
try {
|
||||
const answerEntries = shuffledExerciseData.content
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter(
|
||||
({ item }) =>
|
||||
item.content_type === "multichoice" || item.content_type === "gap"
|
||||
)
|
||||
.map(({ item, index }) => ({
|
||||
contentId: item.id,
|
||||
answer: userAnswers[index] || "",
|
||||
}));
|
||||
|
||||
const response = await makeRequest(
|
||||
`/exercises/multichoice/${exerciseId}/answers`,
|
||||
{
|
||||
answers: answerEntries,
|
||||
}
|
||||
);
|
||||
|
||||
const resultByContentId = new Map(
|
||||
(response.data?.results || []).map((entry) => [
|
||||
String(entry.contentId),
|
||||
entry,
|
||||
])
|
||||
);
|
||||
|
||||
const newCheckedAnswers = shuffledExerciseData.content.map((item) => {
|
||||
if (
|
||||
item.content_type === "multichoice" ||
|
||||
item.content_type === "gap"
|
||||
) {
|
||||
return (
|
||||
userAnswers[index]?.trim().toLowerCase() ===
|
||||
item.correct_answer.toLowerCase()
|
||||
);
|
||||
return !!resultByContentId.get(String(item.id))?.isCorrect;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const hasIncorrectAnswers = newCheckedAnswers.some((isCorrect, index) => {
|
||||
const contentType = shuffledExerciseData.content[index].content_type;
|
||||
const hasMissingAnswers = !!response.data?.hasMissing;
|
||||
const hasIncorrectAnswers = newCheckedAnswers.some(
|
||||
(isCorrect, index) => {
|
||||
const contentType =
|
||||
shuffledExerciseData.content[index].content_type;
|
||||
const isAnswerField =
|
||||
contentType === "multichoice" || contentType === "gap";
|
||||
|
||||
return isAnswerField && !isCorrect;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Set error state if there are missing or incorrect answers
|
||||
setHasErrors(hasMissingAnswers || hasIncorrectAnswers);
|
||||
setCheckedAnswers(newCheckedAnswers);
|
||||
setIsSubmitted(true);
|
||||
|
||||
// Mark all dropdowns as submitted
|
||||
setDropdownSubmittedStates(newCheckedAnswers.map(() => true));
|
||||
} catch (submitError) {
|
||||
console.error("Error validating multichoice answers:", submitError);
|
||||
setHasErrors(true);
|
||||
setIsSubmitted(true);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
|
||||
@@ -183,6 +183,8 @@ export function useIsAdmin() {
|
||||
const { auth, actAs } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.isAuthResolved) return;
|
||||
|
||||
if (!auth.user?.id) return;
|
||||
|
||||
const isAllowedAdminView =
|
||||
@@ -194,7 +196,9 @@ export function useIsAdmin() {
|
||||
}
|
||||
}, [auth, router, pathname]);
|
||||
|
||||
return auth?.user?.id ? auth.user.is_admin : undefined;
|
||||
if (!auth.isAuthResolved) return undefined;
|
||||
|
||||
return auth.isLoggedIn && !!auth.user?.is_admin;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AUTH_COOKIE_NAME } from "@/shared/const";
|
||||
const authRequired = ["/grammar/exercises", "/edit_info", "/contact"];
|
||||
|
||||
// Protected paths that should send logged-out users to registration first.
|
||||
const registerFirstPaths = ["/contact"];
|
||||
const registerFirstPaths = ["/contact", "/grammar/exercises"];
|
||||
|
||||
// Restricted paths logged-in users cannot access
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { middleware } from "./middleware";
|
||||
|
||||
function createRequest(pathname, hasCookie = false) {
|
||||
return {
|
||||
url: `http://localhost/gradesa${pathname}`,
|
||||
nextUrl: {
|
||||
pathname: `/gradesa${pathname}`,
|
||||
},
|
||||
cookies: {
|
||||
get: () => (hasCookie ? { value: "session-cookie" } : undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("middleware", () => {
|
||||
it("redirects logged-out users from exercises to registration", async () => {
|
||||
const response = await middleware(createRequest("/grammar/exercises"));
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toContain("/auth/register");
|
||||
expect(response.headers.get("location")).toContain(
|
||||
"redirect=%2Fgrammar%2Fexercises"
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects logged-out users from contact to registration", async () => {
|
||||
const response = await middleware(createRequest("/contact"));
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toContain("/auth/register");
|
||||
});
|
||||
|
||||
it("redirects other protected paths to login", async () => {
|
||||
const response = await middleware(createRequest("/edit_info"));
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toContain("/auth/login");
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,6 @@ For the future:
|
||||
- The following things could be improved in the GitHub Actions pipeline:
|
||||
- Make the tests faster by for example making e2e-tests run at the same time as other tests.
|
||||
- Modify the tests so that the e2e-tests test the built container, not just the code. This way the tests catch if there are problems with for example dependencies marked only as dev-dependencies (since they are removed when the image is built).
|
||||
- Fix this bug: If you go to the front page and select 'Übungen' from the 'Grammatik' box while you are not logged in you are brought to the login page. If you input valid credentials there and try to log in, you will see that nothing happens.
|
||||
- Fix this: If you want to see the correct answers to some exercises, you can get the correct answers straight from the API by for example opening https://gradesa-ohtuprojekti-staging.ext.ocp-prod-0.k8s.it.helsinki.fi/api/exercises/dragdrop/1 in your browser. You do not even have to be logged in.
|
||||
|
||||
!!! ADHERE TO THE DEFINITION OF DONE !!!
|
||||
|
||||
|
||||
Reference in New Issue
Block a user