From 41a7fe8e7e121faa3ac8c3af6fa9756f18a00a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 11:35:10 +0200 Subject: [PATCH 01/11] fix: replace error cards with toast notifications and auto-reporting Submission errors now show inline toast notifications with a "Report" action that auto-files a support ticket, instead of navigating to a full-page error card. The submit button changes to a destructive "Retry Submission" state so users can correct and resubmit without losing form data. On the server side, a model validator strips project detail fields that do not match the selected project type, preventing stale data from causing validation failures when users switch between project types mid-form. Refs: #1 --- client/src/App.tsx | 2 + .../artemis-request/ArtemisRequestForm.tsx | 3 + .../support-request/SupportRequestForm.tsx | 24 +- .../tum-guest-request/TUMGuestRequestForm.tsx | 3 + client/src/components/ui/form-navigation.tsx | 18 +- client/src/components/ui/sonner.tsx | 40 ++ .../vm-access-request/VMAccessRequestForm.tsx | 3 + .../components/vm-request/VMRequestForm.tsx | 8 +- client/src/index.css | 12 + client/src/lib/submission-error.ts | 127 +++++ client/src/pages/ArtemisRequestPage.tsx | 62 ++- client/src/pages/SupportRequestPage.tsx | 62 ++- client/src/pages/TUMGuestRequestPage.tsx | 67 +-- client/src/pages/VMAccessRequestPage.tsx | 48 +- client/src/pages/VMRequestPage.tsx | 53 +- client/src/services/vm-requests.ts | 17 +- e2e/fixtures/test-data.ts | 16 + e2e/tests/vm-request-edge-cases.spec.ts | 457 ++++++++++++++++++ server/request_server/schemas/vm_request.py | 15 + server/uv.lock | 4 + 20 files changed, 919 insertions(+), 122 deletions(-) create mode 100644 client/src/components/ui/sonner.tsx create mode 100644 client/src/lib/submission-error.ts create mode 100644 e2e/tests/vm-request-edge-cases.spec.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 8e3aec5..9a27387 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import { ProtectedRoute } from "@/components/layout/ProtectedRoute"; import { AuthProvider } from "@/components/providers/AuthProvider"; +import { Toaster } from "@/components/ui/sonner"; import { AboutPage } from "@/pages/AboutPage"; import { ArtemisRequestPage } from "@/pages/ArtemisRequestPage"; import { ExternalLinksAdminPage } from "@/pages/ExternalLinksAdminPage"; @@ -58,6 +59,7 @@ function App() { } /> } /> + ); diff --git a/client/src/components/artemis-request/ArtemisRequestForm.tsx b/client/src/components/artemis-request/ArtemisRequestForm.tsx index f240d6f..dcc0251 100644 --- a/client/src/components/artemis-request/ArtemisRequestForm.tsx +++ b/client/src/components/artemis-request/ArtemisRequestForm.tsx @@ -24,11 +24,13 @@ import { ReviewStep } from "./steps/ReviewStep"; interface ArtemisRequestFormProps { onSubmit: (data: ArtemisRequest, githubUser?: GitHubUser) => Promise; isSubmitting: boolean; + submitFailed?: boolean; } export function ArtemisRequestForm({ onSubmit, isSubmitting, + submitFailed, }: ArtemisRequestFormProps) { const { isAuthenticated, user } = useAuth(); const [currentStep, setCurrentStep] = useState(1); @@ -217,6 +219,7 @@ export function ArtemisRequestForm({ currentStep={currentStep} totalSteps={steps.length} isSubmitting={isSubmitting} + submitFailed={submitFailed} isNextDisabled={isNextDisabled()} onPrevious={handlePrevious} onNext={handleNext} diff --git a/client/src/components/support-request/SupportRequestForm.tsx b/client/src/components/support-request/SupportRequestForm.tsx index 7729c5d..1f216ef 100644 --- a/client/src/components/support-request/SupportRequestForm.tsx +++ b/client/src/components/support-request/SupportRequestForm.tsx @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { CircleX, Loader2 } from "lucide-react"; import { FormProvider, useForm } from "react-hook-form"; import { RequesterInfo } from "@/components/shared/RequesterInfo"; import { Button } from "@/components/ui/button"; @@ -34,11 +35,13 @@ import { interface SupportRequestFormProps { onSubmit: (data: SupportRequest) => void; isSubmitting: boolean; + submitFailed?: boolean; } export function SupportRequestForm({ onSubmit, isSubmitting, + submitFailed, }: SupportRequestFormProps) { const { isAuthenticated, user } = useAuth(); @@ -211,8 +214,25 @@ export function SupportRequestForm({
-
diff --git a/client/src/components/tum-guest-request/TUMGuestRequestForm.tsx b/client/src/components/tum-guest-request/TUMGuestRequestForm.tsx index 246c8c3..ca89ea3 100644 --- a/client/src/components/tum-guest-request/TUMGuestRequestForm.tsx +++ b/client/src/components/tum-guest-request/TUMGuestRequestForm.tsx @@ -22,11 +22,13 @@ import { ReviewStep } from "./steps/ReviewStep"; interface TUMGuestRequestFormProps { onSubmit: (data: TUMGuestRequest) => Promise; isSubmitting: boolean; + submitFailed?: boolean; } export function TUMGuestRequestForm({ onSubmit, isSubmitting, + submitFailed, }: TUMGuestRequestFormProps) { const { isAuthenticated, login } = useAuth(); const [currentStep, setCurrentStep] = useState(1); @@ -208,6 +210,7 @@ export function TUMGuestRequestForm({ currentStep={currentStep} totalSteps={steps.length} isSubmitting={isSubmitting} + submitFailed={submitFailed} isNextDisabled={isNextDisabled} onPrevious={handlePrevious} onNext={handleNext} diff --git a/client/src/components/ui/form-navigation.tsx b/client/src/components/ui/form-navigation.tsx index 40dc093..44ad809 100644 --- a/client/src/components/ui/form-navigation.tsx +++ b/client/src/components/ui/form-navigation.tsx @@ -1,10 +1,12 @@ -import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react"; +import { ArrowLeft, ArrowRight, CircleX, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; interface FormNavigationProps { currentStep: number; totalSteps: number; isSubmitting: boolean; + submitFailed?: boolean; isNextDisabled?: boolean; onPrevious: () => void; onNext: () => void; @@ -15,6 +17,7 @@ export function FormNavigation({ currentStep, totalSteps, isSubmitting, + submitFailed = false, isNextDisabled = false, onPrevious, onNext, @@ -35,12 +38,23 @@ export function FormNavigation({ {isLastStep ? ( - - - - - - ); -} diff --git a/client/src/lib/submission-error.ts b/client/src/lib/submission-error.ts index 426f5b9..dd158b0 100644 --- a/client/src/lib/submission-error.ts +++ b/client/src/lib/submission-error.ts @@ -98,6 +98,34 @@ export function showSubmissionError( }); } +export function handleSubmissionFailure( + formType: string, + formData: unknown, + isAuthenticated: boolean, + setSubmitFailed: (v: boolean) => void, + failure: { apiError?: string } | { caughtError: unknown }, +) { + setSubmitFailed(true); + + const isCaughtError = "caughtError" in failure; + const errorMessage = isCaughtError + ? failure.caughtError instanceof Error + ? failure.caughtError.message + : "Unknown error" + : failure.apiError; + + const contactInfo = isAuthenticated + ? undefined + : extractContactInfo(formType, formData as Record); + + showSubmissionError( + isCaughtError + ? "An unexpected error occurred. Please try again later." + : "Please review your data and try again. If the problem persists, contact support.", + { formType, formData, errorMessage, isAuthenticated, contactInfo }, + ); +} + export function extractContactInfo( formType: string, formData: Record, diff --git a/client/src/pages/ArtemisRequestPage.tsx b/client/src/pages/ArtemisRequestPage.tsx index 37abb15..bc47fe1 100644 --- a/client/src/pages/ArtemisRequestPage.tsx +++ b/client/src/pages/ArtemisRequestPage.tsx @@ -13,10 +13,7 @@ import { } from "@/components/ui/card"; import { useAuth } from "@/hooks/useAuth"; import { submitArtemisRequest } from "@/lib/api"; -import { - extractContactInfo, - showSubmissionError, -} from "@/lib/submission-error"; +import { handleSubmissionFailure } from "@/lib/submission-error"; import type { ArtemisRequest, GitHubUser } from "@/types/artemis-request"; export function ArtemisRequestPage() { @@ -59,35 +56,21 @@ export function ArtemisRequestPage() { ticketUrl: response.data.ticketUrl, }); } else { - setSubmitFailed(true); - const errorOpts = { - formType: "Artemis Developer Access", - formData: data, - errorMessage: response.error, + handleSubmissionFailure( + "Artemis Developer Access", + data, isAuthenticated, - contactInfo: isAuthenticated - ? undefined - : extractContactInfo("Artemis Developer Access", data), - }; - showSubmissionError( - "Please review your data and try again. If the problem persists, contact support.", - errorOpts, + setSubmitFailed, + { apiError: response.error }, ); } } catch (error) { - setSubmitFailed(true); - const errorOpts = { - formType: "Artemis Developer Access", - formData: data, - errorMessage: error instanceof Error ? error.message : "Unknown error", + handleSubmissionFailure( + "Artemis Developer Access", + data, isAuthenticated, - contactInfo: isAuthenticated - ? undefined - : extractContactInfo("Artemis Developer Access", data), - }; - showSubmissionError( - "An unexpected error occurred. Please try again later.", - errorOpts, + setSubmitFailed, + { caughtError: error }, ); } finally { setIsSubmitting(false); diff --git a/client/src/pages/SupportRequestPage.tsx b/client/src/pages/SupportRequestPage.tsx index 03f1bf9..aed187c 100644 --- a/client/src/pages/SupportRequestPage.tsx +++ b/client/src/pages/SupportRequestPage.tsx @@ -13,10 +13,7 @@ import { } from "@/components/ui/card"; import { useAuth } from "@/hooks/useAuth"; import { submitSupportRequest } from "@/lib/api"; -import { - extractContactInfo, - showSubmissionError, -} from "@/lib/submission-error"; +import { handleSubmissionFailure } from "@/lib/submission-error"; import type { SupportRequest } from "@/types/support-request"; export function SupportRequestPage() { @@ -55,35 +52,21 @@ export function SupportRequestPage() { ticketUrl: response.data.ticketUrl, }); } else { - setSubmitFailed(true); - const errorOpts = { - formType: "Support Request", - formData: data, - errorMessage: response.error, + handleSubmissionFailure( + "Support Request", + data, isAuthenticated, - contactInfo: isAuthenticated - ? undefined - : extractContactInfo("Support Request", data), - }; - showSubmissionError( - "Please review your data and try again. If the problem persists, contact support.", - errorOpts, + setSubmitFailed, + { apiError: response.error }, ); } } catch (error) { - setSubmitFailed(true); - const errorOpts = { - formType: "Support Request", - formData: data, - errorMessage: error instanceof Error ? error.message : "Unknown error", + handleSubmissionFailure( + "Support Request", + data, isAuthenticated, - contactInfo: isAuthenticated - ? undefined - : extractContactInfo("Support Request", data), - }; - showSubmissionError( - "An unexpected error occurred. Please try again later.", - errorOpts, + setSubmitFailed, + { caughtError: error }, ); } finally { setIsSubmitting(false); diff --git a/client/src/pages/TUMGuestRequestPage.tsx b/client/src/pages/TUMGuestRequestPage.tsx index 2619bee..1584bdb 100644 --- a/client/src/pages/TUMGuestRequestPage.tsx +++ b/client/src/pages/TUMGuestRequestPage.tsx @@ -7,10 +7,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/useAuth"; import { submitTUMGuestRequest } from "@/lib/api"; -import { - extractContactInfo, - showSubmissionError, -} from "@/lib/submission-error"; +import { handleSubmissionFailure } from "@/lib/submission-error"; import type { TUMGuestRequest } from "@/types/tum-guest-request"; interface SubmitResult { @@ -56,35 +53,21 @@ export function TUMGuestRequestPage() { guestEmail: data.email, }); } else { - setSubmitFailed(true); - const errorOpts = { - formType: "TUM Guest Account", - formData: data, - errorMessage: response.error, + handleSubmissionFailure( + "TUM Guest Account", + data, isAuthenticated, - contactInfo: isAuthenticated - ? undefined - : extractContactInfo("TUM Guest Account", data), - }; - showSubmissionError( - "Please review your data and try again. If the problem persists, contact support.", - errorOpts, + setSubmitFailed, + { apiError: response.error }, ); } } catch (error) { - setSubmitFailed(true); - const errorOpts = { - formType: "TUM Guest Account", - formData: data, - errorMessage: error instanceof Error ? error.message : "Unknown error", + handleSubmissionFailure( + "TUM Guest Account", + data, isAuthenticated, - contactInfo: isAuthenticated - ? undefined - : extractContactInfo("TUM Guest Account", data), - }; - showSubmissionError( - "An unexpected error occurred. Please try again later.", - errorOpts, + setSubmitFailed, + { caughtError: error }, ); } finally { setIsSubmitting(false); diff --git a/client/src/pages/VMAccessRequestPage.tsx b/client/src/pages/VMAccessRequestPage.tsx index eb6c78f..c3ecb8d 100644 --- a/client/src/pages/VMAccessRequestPage.tsx +++ b/client/src/pages/VMAccessRequestPage.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { VMAccessRequestForm } from "@/components/vm-access-request/VMAccessRequestForm"; import { useAuth } from "@/hooks/useAuth"; import { submitVMAccessRequest } from "@/lib/api"; -import { showSubmissionError } from "@/lib/submission-error"; +import { handleSubmissionFailure } from "@/lib/submission-error"; import type { VMAccessRequest } from "@/types/vm-access-request"; export function VMAccessRequestPage() { @@ -43,29 +43,25 @@ export function VMAccessRequestPage() { ticketUrl: response.data.ticketUrl, }); } else { - setSubmitFailed(true); - const errorOpts = { - formType: "VM Access Request", - formData: data, - errorMessage: response.error, - isAuthenticated: true, - }; - showSubmissionError( - "Please review your data and try again. If the problem persists, contact support.", - errorOpts, + handleSubmissionFailure( + "VM Access Request", + data, + true, + setSubmitFailed, + { + apiError: response.error, + }, ); } } catch (error) { - setSubmitFailed(true); - const errorOpts = { - formType: "VM Access Request", - formData: data, - errorMessage: error instanceof Error ? error.message : "Unknown error", - isAuthenticated: true, - }; - showSubmissionError( - "An unexpected error occurred. Please try again later.", - errorOpts, + handleSubmissionFailure( + "VM Access Request", + data, + true, + setSubmitFailed, + { + caughtError: error, + }, ); } finally { setIsSubmitting(false); diff --git a/client/src/pages/VMRequestPage.tsx b/client/src/pages/VMRequestPage.tsx index cdabb34..2a1668d 100644 --- a/client/src/pages/VMRequestPage.tsx +++ b/client/src/pages/VMRequestPage.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { VMRequestForm } from "@/components/vm-request/VMRequestForm"; import { useAuth } from "@/hooks/useAuth"; import { submitVMRequest } from "@/lib/api"; -import { showSubmissionError } from "@/lib/submission-error"; +import { handleSubmissionFailure } from "@/lib/submission-error"; import type { VMRequest } from "@/types/vm-request"; export function VMRequestPage() { @@ -43,30 +43,14 @@ export function VMRequestPage() { ticketUrl: response.data.ticketUrl, }); } else { - setSubmitFailed(true); - const errorOpts = { - formType: "VM Request", - formData: data, - errorMessage: response.error, - isAuthenticated: true, - }; - showSubmissionError( - "Please review your data and try again. If the problem persists, contact support.", - errorOpts, - ); + handleSubmissionFailure("VM Request", data, true, setSubmitFailed, { + apiError: response.error, + }); } } catch (error) { - setSubmitFailed(true); - const errorOpts = { - formType: "VM Request", - formData: data, - errorMessage: error instanceof Error ? error.message : "Unknown error", - isAuthenticated: true, - }; - showSubmissionError( - "An unexpected error occurred. Please try again later.", - errorOpts, - ); + handleSubmissionFailure("VM Request", data, true, setSubmitFailed, { + caughtError: error, + }); } finally { setIsSubmitting(false); } diff --git a/e2e/tests/vm-request-edge-cases.spec.ts b/e2e/tests/vm-request-edge-cases.spec.ts index ac31433..800c82d 100644 --- a/e2e/tests/vm-request-edge-cases.spec.ts +++ b/e2e/tests/vm-request-edge-cases.spec.ts @@ -5,6 +5,33 @@ import { resetTestState, getLatestTicket } from "../helpers/debug-api"; import { fillVMRequestForm } from "../helpers/form-fillers"; import { SERVER_URL } from "../playwright.config"; +async function navigateToVMForm(page) { + await page.addInitScript(() => { + localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); + }); + await page.goto("/"); + await page.getByText("Request Forms").waitFor({ timeout: 10000 }); + await page.getByText("Request a New VM", { exact: true }).click(); + await page.getByText("Basic Information").waitFor(); +} + +async function clickNextAndWait(page, nextStepTitle: string) { + await page.getByRole("button", { name: "Next" }).click(); + await page.getByText(nextStepTitle).waitFor(); +} + +async function clickPreviousAndWait(page, prevStepTitle: string) { + await page.getByRole("button", { name: "Previous" }).click(); + await page.getByText(prevStepTitle).waitFor(); +} + +async function fillSSHKey(page) { + await page.locator('label[for="new"]').click(); + await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); + await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); + await expect(page.getByRole("button", { name: "Next" })).toBeEnabled({ timeout: 5000 }); +} + test.describe("VM Request - Issue #1 Reproduction", () => { test.beforeEach(async ({ request }) => { await resetTestState(request); @@ -14,13 +41,7 @@ test.describe("VM Request - Issue #1 Reproduction", () => { authenticatedPage: page, request, }) => { - await page.addInitScript(() => { - localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); - }); - await page.goto("/"); - await page.getByText("Request Forms").waitFor({ timeout: 10000 }); - await page.getByText("Request a New VM", { exact: true }).click(); - await page.waitForTimeout(500); + await navigateToVMForm(page); // Step 1: Fill basic info - FIRST select thesis (like the user likely did) await page.getByPlaceholder("my-vm-name").fill("e2e-issue1-vm"); @@ -30,30 +51,27 @@ test.describe("VM Request - Issue #1 Reproduction", () => { // Select thesis first and partially fill it await page.locator('label[for="thesis"]').click(); - await page.waitForTimeout(300); // Then switch to Chair Project (the user's final choice per the screenshot) await page.locator('label[for="chair_project"]').click(); - await page.waitForTimeout(300); + await page.getByPlaceholder("Enter project name").waitFor(); await page.getByPlaceholder("Enter project name").fill("ml-interpretability"); await page .getByPlaceholder("Describe the chair project") .fill("A study to compare different ML explanation methods."); await page.getByPlaceholder("Enter responsible person").fill("Jane Doe"); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Resource Configuration"); // Step 2: Resources - set CPU to 2 (matching the issue screenshot) const cpuInput = page.locator('input[type="number"]').first(); await cpuInput.fill("2"); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Firewall Configuration"); // Step 3: Firewall - add public ports 80 and 443 await page.getByRole("button", { name: /Add Port/i }).click(); - await page.waitForTimeout(200); + await page.getByPlaceholder("Why is this port needed?").first().waitFor(); const portInputs1 = page.locator('input[type="number"]'); await portInputs1.last().fill("80"); const protocolSelects1 = page.getByRole("combobox"); @@ -63,14 +81,14 @@ test.describe("VM Request - Issue #1 Reproduction", () => { await reasonInputs1.last().fill("Web server"); const publicCheckboxes1 = page.getByLabel("Publicly accessible"); await publicCheckboxes1.last().check(); - await page.waitForTimeout(200); const justificationInputs1 = page.getByPlaceholder( "Why does this port need to be publicly accessible?", ); + await justificationInputs1.last().waitFor(); await justificationInputs1.last().fill("Standard"); await page.getByRole("button", { name: /Add Port/i }).click(); - await page.waitForTimeout(200); + await expect(page.locator('input[type="number"]')).toHaveCount(3); const portInputs2 = page.locator('input[type="number"]'); await portInputs2.last().fill("443"); const protocolSelects2 = page.getByRole("combobox"); @@ -80,33 +98,27 @@ test.describe("VM Request - Issue #1 Reproduction", () => { await reasonInputs2.last().fill("HTTPS server"); const publicCheckboxes2 = page.getByLabel("Publicly accessible"); await publicCheckboxes2.last().check(); - await page.waitForTimeout(200); const justificationInputs2 = page.getByPlaceholder( "Why does this port need to be publicly accessible?", ); + await justificationInputs2.last().waitFor(); await justificationInputs2.last().fill("Standard"); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Additional User Accounts"); // Step 4: Users await page.getByRole("button", { name: /Add User/i }).click(); - await page.waitForTimeout(200); + await page.getByPlaceholder("Enter username").first().waitFor(); await page.getByPlaceholder("Enter username").last().fill("user-one"); await page.getByRole("button", { name: /Add User/i }).click(); - await page.waitForTimeout(200); + await expect(page.getByPlaceholder("Enter username")).toHaveCount(2); await page.getByPlaceholder("Enter username").last().fill("user-two"); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "SSH Key"); // Step 5: SSH Key - await page.locator('label[for="new"]').click(); - await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); - await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); - await page.waitForTimeout(500); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await fillSSHKey(page); + await clickNextAndWait(page, "Review Your Request"); // Step 6: Submit await page.getByRole("button", { name: "Submit Request" }).click(); @@ -151,14 +163,7 @@ test.describe("VM Request - Project Type Switching", () => { authenticatedPage: page, request, }) => { - // Dismiss "What's New" modal - await page.addInitScript(() => { - localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); - }); - await page.goto("/"); - await page.getByText("Request Forms").waitFor({ timeout: 10000 }); - await page.getByText("Request a New VM", { exact: true }).click(); - await page.waitForTimeout(500); + await navigateToVMForm(page); // Step 1: Fill basic info await page.getByPlaceholder("my-vm-name").fill("e2e-switch-to-thesis"); @@ -168,40 +173,32 @@ test.describe("VM Request - Project Type Switching", () => { // First select iPraktikum and fill its fields await page.locator('label[for="ipraktikum"]').click(); - await page.waitForTimeout(300); + await page.getByPlaceholder("Enter team name").waitFor(); await page.getByPlaceholder("Enter team name").fill("Team Switch"); await page.getByPlaceholder("Enter coach name").fill("Coach Switch"); await page.getByPlaceholder("Enter project lead name").fill("PL Switch"); // Now switch to thesis await page.locator('label[for="thesis"]').click(); - await page.waitForTimeout(300); + await page.getByPlaceholder("Enter thesis title").waitFor(); await page.locator('label[for="level-BA"]').click(); await page.getByPlaceholder("Enter thesis title").fill("Switched Thesis Title"); await page.getByPlaceholder("Enter advisor name").fill("Prof. Switched"); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Resource Configuration"); // Step 2: Resources (defaults) - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Firewall Configuration"); // Step 3: Firewall (defaults) - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Additional User Accounts"); // Step 4: Users (none) - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "SSH Key"); // Step 5: SSH Key - await page.locator('label[for="new"]').click(); - await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); - await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); - await page.waitForTimeout(500); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await fillSSHKey(page); + await clickNextAndWait(page, "Review Your Request"); // Step 6: Review & Submit await page.getByRole("button", { name: "Submit Request" }).click(); @@ -222,13 +219,7 @@ test.describe("VM Request - Project Type Switching", () => { authenticatedPage: page, request, }) => { - await page.addInitScript(() => { - localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); - }); - await page.goto("/"); - await page.getByText("Request Forms").waitFor({ timeout: 10000 }); - await page.getByText("Request a New VM", { exact: true }).click(); - await page.waitForTimeout(500); + await navigateToVMForm(page); await page.getByPlaceholder("my-vm-name").fill("e2e-switch-to-chair"); await page @@ -237,37 +228,29 @@ test.describe("VM Request - Project Type Switching", () => { // First select thesis and fill its fields await page.locator('label[for="thesis"]').click(); - await page.waitForTimeout(300); + await page.getByPlaceholder("Enter thesis title").waitFor(); await page.locator('label[for="level-MA"]').click(); await page.getByPlaceholder("Enter thesis title").fill("Abandoned Thesis"); await page.getByPlaceholder("Enter advisor name").fill("Prof. Abandoned"); // Switch to chair_project await page.locator('label[for="chair_project"]').click(); - await page.waitForTimeout(300); + await page.getByPlaceholder("Enter project name").waitFor(); await page.getByPlaceholder("Enter project name").fill("Final Chair Project"); await page .getByPlaceholder("Describe the chair project") .fill("This is the actual project after switching from thesis"); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Resource Configuration"); // Steps 2-5: defaults - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Firewall Configuration"); + await clickNextAndWait(page, "Additional User Accounts"); + await clickNextAndWait(page, "SSH Key"); // SSH Key - await page.locator('label[for="new"]').click(); - await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); - await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); - await page.waitForTimeout(500); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await fillSSHKey(page); + await clickNextAndWait(page, "Review Your Request"); // Submit await page.getByRole("button", { name: "Submit Request" }).click(); @@ -284,13 +267,7 @@ test.describe("VM Request - Project Type Switching", () => { authenticatedPage: page, request, }) => { - await page.addInitScript(() => { - localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); - }); - await page.goto("/"); - await page.getByText("Request Forms").waitFor({ timeout: 10000 }); - await page.getByText("Request a New VM", { exact: true }).click(); - await page.waitForTimeout(500); + await navigateToVMForm(page); await page.getByPlaceholder("my-vm-name").fill("e2e-switch-to-iprak"); await page @@ -299,7 +276,7 @@ test.describe("VM Request - Project Type Switching", () => { // First select chair_project await page.locator('label[for="chair_project"]').click(); - await page.waitForTimeout(300); + await page.getByPlaceholder("Enter project name").waitFor(); await page.getByPlaceholder("Enter project name").fill("Old Project"); await page .getByPlaceholder("Describe the chair project") @@ -307,29 +284,21 @@ test.describe("VM Request - Project Type Switching", () => { // Switch to ipraktikum await page.locator('label[for="ipraktikum"]').click(); - await page.waitForTimeout(300); + await page.getByPlaceholder("Enter team name").waitFor(); await page.getByPlaceholder("Enter team name").fill("Team Final"); await page.getByPlaceholder("Enter coach name").fill("Coach Final"); await page.getByPlaceholder("Enter project lead name").fill("PL Final"); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Resource Configuration"); // Steps 2-5: defaults - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Firewall Configuration"); + await clickNextAndWait(page, "Additional User Accounts"); + await clickNextAndWait(page, "SSH Key"); // SSH Key - await page.locator('label[for="new"]').click(); - await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); - await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); - await page.waitForTimeout(500); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await fillSSHKey(page); + await clickNextAndWait(page, "Review Your Request"); // Submit await page.getByRole("button", { name: "Submit Request" }).click(); @@ -352,13 +321,7 @@ test.describe("VM Request - Error Handling Preserves Form Data", () => { authenticatedPage: page, request, }) => { - await page.addInitScript(() => { - localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); - }); - await page.goto("/"); - await page.getByText("Request Forms").waitFor({ timeout: 10000 }); - await page.getByText("Request a New VM", { exact: true }).click(); - await page.waitForTimeout(500); + await navigateToVMForm(page); // Fill the full form await page.getByPlaceholder("my-vm-name").fill("e2e-error-retry"); @@ -367,27 +330,19 @@ test.describe("VM Request - Error Handling Preserves Form Data", () => { .fill("Testing error handling preserves form data"); await page.locator('label[for="ipraktikum"]').click(); - await page.waitForTimeout(300); + await page.getByPlaceholder("Enter team name").waitFor(); await page.getByPlaceholder("Enter team name").fill("Error Team"); await page.getByPlaceholder("Enter coach name").fill("Error Coach"); await page.getByPlaceholder("Enter project lead name").fill("Error PL"); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await clickNextAndWait(page, "Resource Configuration"); + await clickNextAndWait(page, "Firewall Configuration"); + await clickNextAndWait(page, "Additional User Accounts"); + await clickNextAndWait(page, "SSH Key"); // SSH Key - await page.locator('label[for="new"]').click(); - await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); - await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); - await page.waitForTimeout(500); - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); + await fillSSHKey(page); + await clickNextAndWait(page, "Review Your Request"); // Intercept the API call ONCE to simulate server error let intercepted = false; @@ -423,16 +378,11 @@ test.describe("VM Request - Error Handling Preserves Form Data", () => { ).toBeVisible(); // Navigate back to step 1 to verify data is preserved - await page.getByRole("button", { name: "Previous" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Previous" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Previous" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Previous" }).click(); - await page.waitForTimeout(300); - await page.getByRole("button", { name: "Previous" }).click(); - await page.waitForTimeout(300); + await clickPreviousAndWait(page, "SSH Key"); + await clickPreviousAndWait(page, "Additional User Accounts"); + await clickPreviousAndWait(page, "Firewall Configuration"); + await clickPreviousAndWait(page, "Resource Configuration"); + await clickPreviousAndWait(page, "Basic Information"); // Verify step 1 data is preserved await expect(page.getByPlaceholder("my-vm-name")).toHaveValue( @@ -441,10 +391,11 @@ test.describe("VM Request - Error Handling Preserves Form Data", () => { await expect(page.getByPlaceholder("Enter team name")).toHaveValue("Error Team"); // Navigate forward to review and retry submission - for (let i = 0; i < 5; i++) { - await page.getByRole("button", { name: "Next" }).click(); - await page.waitForTimeout(300); - } + await clickNextAndWait(page, "Resource Configuration"); + await clickNextAndWait(page, "Firewall Configuration"); + await clickNextAndWait(page, "Additional User Accounts"); + await clickNextAndWait(page, "SSH Key"); + await clickNextAndWait(page, "Review Your Request"); // Retry - should succeed now (route interceptor only blocks once) await page.getByRole("button", { name: "Retry Submission" }).click(); diff --git a/server/pyproject.toml b/server/pyproject.toml index b19ebb4..393b37f 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -43,6 +43,11 @@ ci = [ [project.scripts] retention-cleanup = "request_server.cli:run_retention" +[tool.uv] +# Avoid resolving packages published in the last week. This reduces exposure to compromised +# releases while keeping routine dependency updates practical. +exclude-newer = "7d" + [tool.uv.build-backend] module-root = "." From 6be8cfc0b290f9eb2e3adcc30b6798fce96ad56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 12:14:58 +0200 Subject: [PATCH 04/11] fix: disable shake animation for prefers-reduced-motion and fix CSSProperties import --- client/src/components/ui/sonner.tsx | 3 ++- client/src/index.css | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/components/ui/sonner.tsx b/client/src/components/ui/sonner.tsx index 909c9af..af7fa98 100644 --- a/client/src/components/ui/sonner.tsx +++ b/client/src/components/ui/sonner.tsx @@ -5,6 +5,7 @@ import { OctagonXIcon, TriangleAlertIcon, } from "lucide-react"; +import type { CSSProperties } from "react"; import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { @@ -30,7 +31,7 @@ const Toaster = ({ ...props }: ToasterProps) => { "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", "--border-radius": "var(--radius)", - } as React.CSSProperties + } as CSSProperties } {...props} /> diff --git a/client/src/index.css b/client/src/index.css index c1740b9..b368ac2 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -142,3 +142,9 @@ .animate-shake { animation: shake 0.4s ease-in-out; } + +@media (prefers-reduced-motion: reduce) { + .animate-shake { + animation: none; + } +} From 2e3c130bf584d29f50c68a85bf92378b831ebcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 12:27:52 +0200 Subject: [PATCH 05/11] fix: fix e2e tests --- e2e/tests/vm-request-edge-cases.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/tests/vm-request-edge-cases.spec.ts b/e2e/tests/vm-request-edge-cases.spec.ts index 800c82d..791e800 100644 --- a/e2e/tests/vm-request-edge-cases.spec.ts +++ b/e2e/tests/vm-request-edge-cases.spec.ts @@ -17,12 +17,12 @@ async function navigateToVMForm(page) { async function clickNextAndWait(page, nextStepTitle: string) { await page.getByRole("button", { name: "Next" }).click(); - await page.getByText(nextStepTitle).waitFor(); + await page.getByRole("heading", { name: nextStepTitle }).waitFor(); } async function clickPreviousAndWait(page, prevStepTitle: string) { await page.getByRole("button", { name: "Previous" }).click(); - await page.getByText(prevStepTitle).waitFor(); + await page.getByRole("heading", { name: prevStepTitle }).waitFor(); } async function fillSSHKey(page) { @@ -88,7 +88,7 @@ test.describe("VM Request - Issue #1 Reproduction", () => { await justificationInputs1.last().fill("Standard"); await page.getByRole("button", { name: /Add Port/i }).click(); - await expect(page.locator('input[type="number"]')).toHaveCount(3); + await expect(page.locator('input[type="number"]')).toHaveCount(2); const portInputs2 = page.locator('input[type="number"]'); await portInputs2.last().fill("443"); const protocolSelects2 = page.getByRole("combobox"); From a1485128928e62e9bbec0dbd3a931ba9725a63cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 12:40:01 +0200 Subject: [PATCH 06/11] fix: remove beforevalidator and add noopener and noreferrer --- client/src/lib/api.ts | 15 +++++++++++++-- client/src/lib/submission-error.ts | 3 ++- server/request_server/schemas/vm_request.py | 15 --------------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 2989c10..458a3a1 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -62,8 +62,19 @@ export async function submitVMRequest( request: VMRequestSubmission, ): Promise> { try { - // Extract just the VMRequest data (without user info which server gets from token) - const { user: _user, ...vmRequestData } = request; + const { user: _user, ipraktikum, thesis, chairProject, ...rest } = request; + const projectDetailsKey = { + ipraktikum: "ipraktikum", + thesis: "thesis", + chair_project: "chairProject", + }[rest.projectType] as "ipraktikum" | "thesis" | "chairProject"; + const projectDetails = { ipraktikum, thesis, chairProject }[ + projectDetailsKey + ]; + const vmRequestData = { + ...rest, + ...(projectDetails && { [projectDetailsKey]: projectDetails }), + }; const response = await vmRequestsService.create(vmRequestData); return { success: true, diff --git a/client/src/lib/submission-error.ts b/client/src/lib/submission-error.ts index dd158b0..2612251 100644 --- a/client/src/lib/submission-error.ts +++ b/client/src/lib/submission-error.ts @@ -74,7 +74,8 @@ async function submitReport(options: SubmissionErrorOptions) { ...(ticket_url && { action: { label: "View Ticket", - onClick: () => window.open(ticket_url, "_blank"), + onClick: () => + window.open(ticket_url, "_blank", "noopener,noreferrer"), }, }), }); diff --git a/server/request_server/schemas/vm_request.py b/server/request_server/schemas/vm_request.py index 90c9696..477252e 100644 --- a/server/request_server/schemas/vm_request.py +++ b/server/request_server/schemas/vm_request.py @@ -137,21 +137,6 @@ class VMRequestCreate(BaseModel): model_config = ConfigDict(populate_by_name=True) - @model_validator(mode="before") - @classmethod - def strip_non_matching_project_details(cls, data: dict) -> dict: - if isinstance(data, dict): - project_type = data.get("projectType") or data.get("project_type") - for key, pt in [ - ("ipraktikum", "ipraktikum"), - ("thesis", "thesis"), - ("chairProject", "chair_project"), - ("chair_project", "chair_project"), - ]: - if key in data and (project_type != pt or data[key] == {}): - data.pop(key) - return data - @field_validator("hostname") @classmethod def validate_hostname(cls, v: str) -> str: From 0953ed1a296ad02e899ef0579f156784deeb584c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 13:29:26 +0200 Subject: [PATCH 07/11] fix: fixes --- client/src/lib/api.ts | 23 +++++++++---------- client/src/lib/submission-error.ts | 28 ++++++++++++++++++------ client/src/pages/ArtemisRequestPage.tsx | 2 +- client/src/pages/SupportRequestPage.tsx | 2 +- client/src/pages/TUMGuestRequestPage.tsx | 2 +- client/src/pages/VMAccessRequestPage.tsx | 1 + client/src/pages/VMRequestPage.tsx | 1 + e2e/tests/vm-request-edge-cases.spec.ts | 9 ++++---- 8 files changed, 41 insertions(+), 27 deletions(-) diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 458a3a1..84dd8a6 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,3 +1,4 @@ +import { ApiError } from "@/services/api"; import { artemisDeveloperRequestsService } from "@/services/artemis-developer-requests"; import { sshKeysService } from "@/services/ssh-keys"; import { supportRequestsService } from "@/services/support-requests"; @@ -23,6 +24,7 @@ export interface APIResponse { success: boolean; data?: T; error?: string; + statusCode?: number; } export interface VMRequestSubmission extends VMRequest { @@ -50,6 +52,7 @@ export async function fetchSSHKeys(): Promise> { success: false, error: error instanceof Error ? error.message : "Failed to fetch SSH keys", + statusCode: error instanceof ApiError ? error.status : undefined, data: [], }; } @@ -62,19 +65,7 @@ export async function submitVMRequest( request: VMRequestSubmission, ): Promise> { try { - const { user: _user, ipraktikum, thesis, chairProject, ...rest } = request; - const projectDetailsKey = { - ipraktikum: "ipraktikum", - thesis: "thesis", - chair_project: "chairProject", - }[rest.projectType] as "ipraktikum" | "thesis" | "chairProject"; - const projectDetails = { ipraktikum, thesis, chairProject }[ - projectDetailsKey - ]; - const vmRequestData = { - ...rest, - ...(projectDetails && { [projectDetailsKey]: projectDetails }), - }; + const { user: _user, ...vmRequestData } = request; const response = await vmRequestsService.create(vmRequestData); return { success: true, @@ -89,6 +80,7 @@ export async function submitVMRequest( success: false, error: error instanceof Error ? error.message : "Failed to submit request", + statusCode: error instanceof ApiError ? error.status : undefined, }; } } @@ -111,6 +103,7 @@ export async function addSSHKey( return { success: false, error: error instanceof Error ? error.message : "Failed to add SSH key", + statusCode: error instanceof ApiError ? error.status : undefined, }; } } @@ -220,6 +213,7 @@ export async function submitArtemisRequest( success: false, error: error instanceof Error ? error.message : "Failed to submit request", + statusCode: error instanceof ApiError ? error.status : undefined, }; } } @@ -256,6 +250,7 @@ export async function submitVMAccessRequest( success: false, error: error instanceof Error ? error.message : "Failed to submit request", + statusCode: error instanceof ApiError ? error.status : undefined, }; } } @@ -292,6 +287,7 @@ export async function submitSupportRequest( success: false, error: error instanceof Error ? error.message : "Failed to submit request", + statusCode: error instanceof ApiError ? error.status : undefined, }; } } @@ -329,6 +325,7 @@ export async function submitTUMGuestRequest( success: false, error: error instanceof Error ? error.message : "Failed to submit request", + statusCode: error instanceof ApiError ? error.status : undefined, }; } } diff --git a/client/src/lib/submission-error.ts b/client/src/lib/submission-error.ts index 2612251..1aab8ed 100644 --- a/client/src/lib/submission-error.ts +++ b/client/src/lib/submission-error.ts @@ -104,7 +104,9 @@ export function handleSubmissionFailure( formData: unknown, isAuthenticated: boolean, setSubmitFailed: (v: boolean) => void, - failure: { apiError?: string } | { caughtError: unknown }, + failure: + | { apiError?: string; statusCode?: number } + | { caughtError: unknown }, ) { setSubmitFailed(true); @@ -115,16 +117,28 @@ export function handleSubmissionFailure( : "Unknown error" : failure.apiError; + let description: string; + if (isCaughtError) { + description = "An unexpected error occurred. Please try again later."; + } else if (failure.statusCode && failure.statusCode >= 400 && failure.statusCode < 500) { + description = + "Please review your data and try again. If the problem persists, contact support."; + } else { + description = + "Our server ran into a problem. Please try again later."; + } + const contactInfo = isAuthenticated ? undefined : extractContactInfo(formType, formData as Record); - showSubmissionError( - isCaughtError - ? "An unexpected error occurred. Please try again later." - : "Please review your data and try again. If the problem persists, contact support.", - { formType, formData, errorMessage, isAuthenticated, contactInfo }, - ); + showSubmissionError(description, { + formType, + formData, + errorMessage, + isAuthenticated, + contactInfo, + }); } export function extractContactInfo( diff --git a/client/src/pages/ArtemisRequestPage.tsx b/client/src/pages/ArtemisRequestPage.tsx index bc47fe1..b3c5113 100644 --- a/client/src/pages/ArtemisRequestPage.tsx +++ b/client/src/pages/ArtemisRequestPage.tsx @@ -61,7 +61,7 @@ export function ArtemisRequestPage() { data, isAuthenticated, setSubmitFailed, - { apiError: response.error }, + { apiError: response.error, statusCode: response.statusCode }, ); } } catch (error) { diff --git a/client/src/pages/SupportRequestPage.tsx b/client/src/pages/SupportRequestPage.tsx index aed187c..f122b66 100644 --- a/client/src/pages/SupportRequestPage.tsx +++ b/client/src/pages/SupportRequestPage.tsx @@ -57,7 +57,7 @@ export function SupportRequestPage() { data, isAuthenticated, setSubmitFailed, - { apiError: response.error }, + { apiError: response.error, statusCode: response.statusCode }, ); } } catch (error) { diff --git a/client/src/pages/TUMGuestRequestPage.tsx b/client/src/pages/TUMGuestRequestPage.tsx index 1584bdb..cc78b47 100644 --- a/client/src/pages/TUMGuestRequestPage.tsx +++ b/client/src/pages/TUMGuestRequestPage.tsx @@ -58,7 +58,7 @@ export function TUMGuestRequestPage() { data, isAuthenticated, setSubmitFailed, - { apiError: response.error }, + { apiError: response.error, statusCode: response.statusCode }, ); } } catch (error) { diff --git a/client/src/pages/VMAccessRequestPage.tsx b/client/src/pages/VMAccessRequestPage.tsx index c3ecb8d..3a7df4c 100644 --- a/client/src/pages/VMAccessRequestPage.tsx +++ b/client/src/pages/VMAccessRequestPage.tsx @@ -50,6 +50,7 @@ export function VMAccessRequestPage() { setSubmitFailed, { apiError: response.error, + statusCode: response.statusCode, }, ); } diff --git a/client/src/pages/VMRequestPage.tsx b/client/src/pages/VMRequestPage.tsx index 2a1668d..0ec7f0e 100644 --- a/client/src/pages/VMRequestPage.tsx +++ b/client/src/pages/VMRequestPage.tsx @@ -45,6 +45,7 @@ export function VMRequestPage() { } else { handleSubmissionFailure("VM Request", data, true, setSubmitFailed, { apiError: response.error, + statusCode: response.statusCode, }); } } catch (error) { diff --git a/e2e/tests/vm-request-edge-cases.spec.ts b/e2e/tests/vm-request-edge-cases.spec.ts index 791e800..ee4f680 100644 --- a/e2e/tests/vm-request-edge-cases.spec.ts +++ b/e2e/tests/vm-request-edge-cases.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from "@playwright/test"; import { test, expect } from "../fixtures/auth"; import { VM_REQUEST_CONFIGS } from "../fixtures/test-data"; import { TEST_SSH_KEY_NAME, TEST_SSH_PUBLIC_KEY } from "../fixtures/test-data"; @@ -5,7 +6,7 @@ import { resetTestState, getLatestTicket } from "../helpers/debug-api"; import { fillVMRequestForm } from "../helpers/form-fillers"; import { SERVER_URL } from "../playwright.config"; -async function navigateToVMForm(page) { +async function navigateToVMForm(page: Page) { await page.addInitScript(() => { localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); }); @@ -15,17 +16,17 @@ async function navigateToVMForm(page) { await page.getByText("Basic Information").waitFor(); } -async function clickNextAndWait(page, nextStepTitle: string) { +async function clickNextAndWait(page: Page, nextStepTitle: string) { await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("heading", { name: nextStepTitle }).waitFor(); } -async function clickPreviousAndWait(page, prevStepTitle: string) { +async function clickPreviousAndWait(page: Page, prevStepTitle: string) { await page.getByRole("button", { name: "Previous" }).click(); await page.getByRole("heading", { name: prevStepTitle }).waitFor(); } -async function fillSSHKey(page) { +async function fillSSHKey(page: Page) { await page.locator('label[for="new"]').click(); await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); From b636a698d0185c05a269fd3dc0812b7e2b6569e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 13:30:39 +0200 Subject: [PATCH 08/11] chore: linting --- client/src/lib/submission-error.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/lib/submission-error.ts b/client/src/lib/submission-error.ts index 1aab8ed..5da8bcc 100644 --- a/client/src/lib/submission-error.ts +++ b/client/src/lib/submission-error.ts @@ -120,12 +120,15 @@ export function handleSubmissionFailure( let description: string; if (isCaughtError) { description = "An unexpected error occurred. Please try again later."; - } else if (failure.statusCode && failure.statusCode >= 400 && failure.statusCode < 500) { + } else if ( + failure.statusCode && + failure.statusCode >= 400 && + failure.statusCode < 500 + ) { description = "Please review your data and try again. If the problem persists, contact support."; } else { - description = - "Our server ran into a problem. Please try again later."; + description = "Our server ran into a problem. Please try again later."; } const contactInfo = isAuthenticated From 4bcdfeab1ee49d1860db227913de042f23a5c50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 13:38:39 +0200 Subject: [PATCH 09/11] chore: fix expected error messages in e2e tests --- e2e/tests/vm-request-edge-cases.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/e2e/tests/vm-request-edge-cases.spec.ts b/e2e/tests/vm-request-edge-cases.spec.ts index ee4f680..41c19c4 100644 --- a/e2e/tests/vm-request-edge-cases.spec.ts +++ b/e2e/tests/vm-request-edge-cases.spec.ts @@ -368,9 +368,7 @@ test.describe("VM Request - Error Handling Preserves Form Data", () => { timeout: 10000, }); await expect( - page.getByText( - "Please review your data and try again. If the problem persists, contact support.", - ), + page.getByText("Our server ran into a problem. Please try again later."), ).toBeVisible(); // Verify the form is still visible (review step should still be showing) From 857d18d4794aeea6f4018564d3683ec44f8b0cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 13:45:05 +0200 Subject: [PATCH 10/11] chore: fix mutiple smaller things --- client/src/lib/submission-error.ts | 6 +++ client/src/pages/VMRequestPage.tsx | 4 +- client/src/services/vm-requests.ts | 2 +- e2e/fixtures/auth.ts | 30 +++++++------- e2e/helpers/form-fillers.ts | 6 +-- e2e/tests/vm-request-edge-cases.spec.ts | 40 ++++++++----------- .../services/descriptions/blocks.py | 8 ++-- 7 files changed, 48 insertions(+), 48 deletions(-) diff --git a/client/src/lib/submission-error.ts b/client/src/lib/submission-error.ts index 5da8bcc..7da2628 100644 --- a/client/src/lib/submission-error.ts +++ b/client/src/lib/submission-error.ts @@ -35,7 +35,11 @@ function buildDescription( return parts.join("\n"); } +let isReporting = false; + async function submitReport(options: SubmissionErrorOptions) { + if (isReporting) return; + isReporting = true; const { formType, formData, errorMessage, isAuthenticated, contactInfo } = options; @@ -83,6 +87,8 @@ async function submitReport(options: SubmissionErrorOptions) { toast.error("Could not submit report", { description: "Please contact support directly.", }); + } finally { + isReporting = false; } } diff --git a/client/src/pages/VMRequestPage.tsx b/client/src/pages/VMRequestPage.tsx index 0ec7f0e..6a036ca 100644 --- a/client/src/pages/VMRequestPage.tsx +++ b/client/src/pages/VMRequestPage.tsx @@ -7,6 +7,7 @@ import { VMRequestForm } from "@/components/vm-request/VMRequestForm"; import { useAuth } from "@/hooks/useAuth"; import { submitVMRequest } from "@/lib/api"; import { handleSubmissionFailure } from "@/lib/submission-error"; +import { cleanProjectDetails } from "@/services/vm-requests"; import type { VMRequest } from "@/types/vm-request"; export function VMRequestPage() { @@ -20,9 +21,10 @@ export function VMRequestPage() { ticketUrl?: string | null; } | null>(null); - const handleSubmit = async (data: VMRequest) => { + const handleSubmit = async (rawData: VMRequest) => { if (!user) return; + const data = cleanProjectDetails(rawData); setIsSubmitting(true); setSubmitFailed(false); try { diff --git a/client/src/services/vm-requests.ts b/client/src/services/vm-requests.ts index ca3e7c4..0a69976 100644 --- a/client/src/services/vm-requests.ts +++ b/client/src/services/vm-requests.ts @@ -34,7 +34,7 @@ export interface VMRequestListItem { created_at: string; } -function cleanProjectDetails(data: VMRequest): VMRequest { +export function cleanProjectDetails(data: VMRequest): VMRequest { const { ipraktikum, thesis, chairProject, ...rest } = data; return { ...rest, diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index d3a8334..9bdbc4c 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -83,8 +83,8 @@ async function setupOidcInterception(page: Page): Promise { // Intercept OIDC discovery await page.route( `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration`, - (route) => { - route.fulfill({ + async (route) => { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(MOCK_OIDC_CONFIG), @@ -95,8 +95,8 @@ async function setupOidcInterception(page: Page): Promise { // Intercept JWKS endpoint await page.route( `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs`, - (route) => { - route.fulfill({ + async (route) => { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ keys: [] }), @@ -105,8 +105,8 @@ async function setupOidcInterception(page: Page): Promise { ); // Intercept session iframe - await page.route("**/login-status-iframe.html**", (route) => { - route.fulfill({ + await page.route("**/login-status-iframe.html**", async (route) => { + await route.fulfill({ status: 200, contentType: "text/html", body: "", @@ -116,8 +116,8 @@ async function setupOidcInterception(page: Page): Promise { // Intercept token endpoint (for silent renew) await page.route( `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, - (route) => { - route.fulfill({ + async (route) => { + await route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "invalid_grant", error_description: "Session not active" }), @@ -128,8 +128,8 @@ async function setupOidcInterception(page: Page): Promise { // Intercept userinfo endpoint await page.route( `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/userinfo`, - (route) => { - route.fulfill({ + async (route) => { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ @@ -146,7 +146,7 @@ async function setupOidcInterception(page: Page): Promise { ); // Block any other requests to Keycloak to prevent hangs - await page.route(`${KEYCLOAK_URL}/**`, (route) => { + await page.route(`${KEYCLOAK_URL}/**`, async (route) => { const url = route.request().url(); if ( url.includes(".well-known/openid-configuration") || @@ -155,10 +155,10 @@ async function setupOidcInterception(page: Page): Promise { url.includes("/token") || url.includes("/userinfo") ) { - route.fallback(); + await route.fallback(); return; } - route.fulfill({ + await route.fulfill({ status: 200, contentType: "text/html", body: "mocked", @@ -167,10 +167,10 @@ async function setupOidcInterception(page: Page): Promise { } async function setupGitHubMock(page: Page): Promise { - await page.route("**/api.github.com/users/**", (route) => { + await page.route("**/api.github.com/users/**", async (route) => { const url = route.request().url(); const username = url.split("/users/")[1]; - route.fulfill({ + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ diff --git a/e2e/helpers/form-fillers.ts b/e2e/helpers/form-fillers.ts index c651760..223e4f2 100644 --- a/e2e/helpers/form-fillers.ts +++ b/e2e/helpers/form-fillers.ts @@ -14,7 +14,7 @@ import { SERVER_URL } from "../playwright.config"; // ── Navigation helpers ──────────────────────────────────────────────────── -async function navigateFromHome(page: Page, cardTitle: string): Promise { +export async function navigateFromHome(page: Page, cardTitle: string): Promise { await page.addInitScript(() => { localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); }); @@ -26,7 +26,7 @@ async function navigateFromHome(page: Page, cardTitle: string): Promise { // ── Shared helpers ──────────────────────────────────────────────────────── -async function clickNext(page: Page): Promise { +export async function clickNext(page: Page): Promise { await page.getByRole("button", { name: "Next" }).click(); // Wait for step transition await page.waitForTimeout(300); @@ -57,7 +57,7 @@ async function selectShadcnOption( await page.getByRole("option", { name: optionText, exact: true }).click(); } -async function fillNewSSHKey(page: Page): Promise { +export async function fillNewSSHKey(page: Page): Promise { await selectRadioCard(page, "new"); await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); diff --git a/e2e/tests/vm-request-edge-cases.spec.ts b/e2e/tests/vm-request-edge-cases.spec.ts index 41c19c4..2c582ad 100644 --- a/e2e/tests/vm-request-edge-cases.spec.ts +++ b/e2e/tests/vm-request-edge-cases.spec.ts @@ -1,23 +1,22 @@ import type { Page } from "@playwright/test"; import { test, expect } from "../fixtures/auth"; import { VM_REQUEST_CONFIGS } from "../fixtures/test-data"; -import { TEST_SSH_KEY_NAME, TEST_SSH_PUBLIC_KEY } from "../fixtures/test-data"; import { resetTestState, getLatestTicket } from "../helpers/debug-api"; -import { fillVMRequestForm } from "../helpers/form-fillers"; +import { + clickNext, + fillNewSSHKey, + fillVMRequestForm, + navigateFromHome, +} from "../helpers/form-fillers"; import { SERVER_URL } from "../playwright.config"; async function navigateToVMForm(page: Page) { - await page.addInitScript(() => { - localStorage.setItem("aet-request.whats-new.v1.dismissed", "true"); - }); - await page.goto("/"); - await page.getByText("Request Forms").waitFor({ timeout: 10000 }); - await page.getByText("Request a New VM", { exact: true }).click(); + await navigateFromHome(page, "Request a New VM"); await page.getByText("Basic Information").waitFor(); } async function clickNextAndWait(page: Page, nextStepTitle: string) { - await page.getByRole("button", { name: "Next" }).click(); + await clickNext(page); await page.getByRole("heading", { name: nextStepTitle }).waitFor(); } @@ -26,13 +25,6 @@ async function clickPreviousAndWait(page: Page, prevStepTitle: string) { await page.getByRole("heading", { name: prevStepTitle }).waitFor(); } -async function fillSSHKey(page: Page) { - await page.locator('label[for="new"]').click(); - await page.getByPlaceholder("e.g., My Laptop, Work Desktop").fill(TEST_SSH_KEY_NAME); - await page.getByPlaceholder("ssh-ed25519 AAAA").fill(TEST_SSH_PUBLIC_KEY); - await expect(page.getByRole("button", { name: "Next" })).toBeEnabled({ timeout: 5000 }); -} - test.describe("VM Request - Issue #1 Reproduction", () => { test.beforeEach(async ({ request }) => { await resetTestState(request); @@ -118,7 +110,7 @@ test.describe("VM Request - Issue #1 Reproduction", () => { await clickNextAndWait(page, "SSH Key"); // Step 5: SSH Key - await fillSSHKey(page); + await fillNewSSHKey(page); await clickNextAndWait(page, "Review Your Request"); // Step 6: Submit @@ -198,7 +190,7 @@ test.describe("VM Request - Project Type Switching", () => { await clickNextAndWait(page, "SSH Key"); // Step 5: SSH Key - await fillSSHKey(page); + await fillNewSSHKey(page); await clickNextAndWait(page, "Review Your Request"); // Step 6: Review & Submit @@ -250,7 +242,7 @@ test.describe("VM Request - Project Type Switching", () => { await clickNextAndWait(page, "SSH Key"); // SSH Key - await fillSSHKey(page); + await fillNewSSHKey(page); await clickNextAndWait(page, "Review Your Request"); // Submit @@ -298,7 +290,7 @@ test.describe("VM Request - Project Type Switching", () => { await clickNextAndWait(page, "SSH Key"); // SSH Key - await fillSSHKey(page); + await fillNewSSHKey(page); await clickNextAndWait(page, "Review Your Request"); // Submit @@ -342,21 +334,21 @@ test.describe("VM Request - Error Handling Preserves Form Data", () => { await clickNextAndWait(page, "SSH Key"); // SSH Key - await fillSSHKey(page); + await fillNewSSHKey(page); await clickNextAndWait(page, "Review Your Request"); // Intercept the API call ONCE to simulate server error let intercepted = false; - await page.route(`${SERVER_URL}/api/v1/vm-requests`, (route) => { + await page.route(`${SERVER_URL}/api/v1/vm-requests`, async (route) => { if (!intercepted && route.request().method() === "POST") { intercepted = true; - route.fulfill({ + await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "Internal Server Error" }), }); } else { - route.continue(); + await route.continue(); } }); diff --git a/server/request_server/services/descriptions/blocks.py b/server/request_server/services/descriptions/blocks.py index d521084..8d388b5 100644 --- a/server/request_server/services/descriptions/blocks.py +++ b/server/request_server/services/descriptions/blocks.py @@ -151,10 +151,10 @@ def account_info_block( """ content = ( f"{username}:\n" - f"\tname: {name or 'N/A'}\n" - f"\temail: {email or 'N/A'}\n" - f"\tpk: {public_key or 'N/A'}\n" - f"\tpw: {generate_sha512_password()}" + f" name: {name or 'N/A'}\n" + f" email: {email or 'N/A'}\n" + f" pk: {public_key or 'N/A'}\n" + f" pw: {generate_sha512_password()}" ) return code_block(content) From eb2da72ef8d83c70e20f3209f95bdc7d021807be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20K=C3=BChne?= Date: Fri, 1 May 2026 13:55:44 +0200 Subject: [PATCH 11/11] chore: fix --- client/src/lib/submission-error.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/lib/submission-error.ts b/client/src/lib/submission-error.ts index 7da2628..5cff90e 100644 --- a/client/src/lib/submission-error.ts +++ b/client/src/lib/submission-error.ts @@ -64,6 +64,7 @@ async function submitReport(options: SubmissionErrorOptions) { category: "bug", }; } else { + isReporting = false; toast.error("Could not submit report", { description: "Please sign in or contact support directly.", });