Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion frontend/SENTRY_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,24 @@ Reference `.env.example` for all required variables.
After setting the DSN, Sentry will automatically:
- Initialize on client and server
- Capture unhandled exceptions
- Record session replays for errors
- Report API failures with context

## Sampling Defaults (issue #1987)

- **Traces**: `NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE` defaults to `1.0` in
development but only `0.1` in production, so one forgotten deployment
cannot exhaust the quota. Set the variable explicitly to change this
(values outside [0, 1] fall back to the default). The server and edge
configs share the same helper (`lib/sentry-sampling.ts`).
- **Session replays are opt-in only**: both
`NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE` and
`NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE` default to `0`. Enable
them explicitly (e.g. `0.1` / `1.0`) if you want recording; the replay
integration still masks all text and blocks all media whenever it runs.
- **Non-Error rejections** never crash the `beforeSend` hook anymore: thrown
strings, `undefined` or plain objects are reported as `NonError` events
with a stringified message instead of being dropped.

## Error Context
Errors are tagged with:
- `errorBoundary`: Which error boundary caught the error (global/wallet)
Expand Down
15 changes: 12 additions & 3 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/lib/providers/providers";
import { OfflineBanner } from "@/components/admin/offline-banner";
Expand All @@ -14,15 +14,24 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});

/**
* Since Next.js 14 the viewport must be exported separately (issue #1986);
* leaving it inside `metadata` produces build warnings and is ignored in
* newer releases. `app/manifest.ts` uses the same theme colour.
*/
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#0f172a",
};

export const metadata: Metadata = {
viewport: "width=device-width, initial-scale=1",
title: {
default: "ManageHub - Smart Hub & Workspace Management",
template: "%s | ManageHub",
},
description:
"Smart Hub & Workspace Management System for modern teams. Streamline operations, manage resources, and boost productivity with our comprehensive management platform.",
themeColor: "#0f172a",
keywords: [
"workspace management",
"hub management",
Expand Down
163 changes: 159 additions & 4 deletions frontend/components/auth/auth-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,34 @@ vi.mock("@/lib/stores/session-store", () => ({
) => selector({ setSession: mocks.setSession }),
}));

vi.mock("@/lib/auth-api", () => ({
login: mocks.login,
register: mocks.register,
}));
// The mocked module must expose the same AuthApiError class the component
// uses for its instanceof checks (issue #1988), so thrown errors are
// recognised as structured field failures.
vi.mock("@/lib/auth-api", () => {
class MockAuthApiError extends Error {
status: number;
fieldErrors: Record<string, string>;

constructor(
status: number,
fieldErrors: Record<string, string>,
message: string,
) {
super(message);
this.name = "AuthApiError";
this.status = status;
this.fieldErrors = fieldErrors;
}
}

return {
AuthApiError: MockAuthApiError,
login: mocks.login,
register: mocks.register,
};
});

import { AuthApiError } from "@/lib/auth-api";
import { AuthForm } from "./auth-form";

function renderForm(mode: "login" | "register") {
Expand Down Expand Up @@ -82,3 +105,135 @@ describe("AuthForm registration confirmation", () => {
expect(mocks.push).toHaveBeenCalledWith("/wallet");
});
});

describe("AuthForm per-field API errors (issue #1988)", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("renders 400 validation messages next to their own field", async () => {
const user = userEvent.setup();
mocks.register.mockRejectedValue(
new AuthApiError(
400,
{
email: "email must be an email",
password: "password must be longer than or equal to 8 characters",
},
"Bad Request",
),
);
renderForm("register");

await user.type(screen.getByLabelText("Email"), "not-an-email");
await user.type(screen.getByLabelText("Password"), "short");
await user.type(screen.getByLabelText("Confirm password"), "short");
await user.click(screen.getByRole("button", { name: "Create account" }));

const emailError = await screen.findByText("email must be an email");
expect(emailError).toHaveAttribute("id", "email-error");
expect(screen.getByLabelText("Email")).toHaveAttribute(
"aria-invalid",
"true",
);
expect(screen.getByLabelText("Email")).toHaveAttribute(
"aria-describedby",
"email-error",
);

const passwordError = screen.getByText(
"password must be longer than or equal to 8 characters",
);
expect(passwordError).toHaveAttribute("id", "password-error");
expect(screen.getByLabelText("Password")).toHaveAttribute(
"aria-invalid",
"true",
);

// The generic form-level banner is silent when the fields carry the
// detail - the message is already next to the offending input.
expect(screen.queryByText("Bad Request")).not.toBeInTheDocument();
// The first invalid field receives focus for keyboard users.
expect(document.activeElement).toBe(screen.getByLabelText("Email"));
});

it("maps a 409 taken-email failure onto the email field", async () => {
const user = userEvent.setup();
mocks.register.mockRejectedValue(
new AuthApiError(
409,
{ email: "Email is already registered" },
"Conflict",
),
);
renderForm("register");

await user.type(screen.getByLabelText("Email"), "taken@example.com");
await user.type(screen.getByLabelText("Password"), "password-one");
await user.type(screen.getByLabelText("Confirm password"), "password-one");
await user.click(screen.getByRole("button", { name: "Create account" }));

expect(
await screen.findByText("Email is already registered"),
).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toHaveAttribute(
"aria-invalid",
"true",
);
});

it("shows a single form-level message when rate limited (429)", async () => {
const user = userEvent.setup();
mocks.login.mockRejectedValue(
new AuthApiError(429, {}, "Too many attempts, try again in 60 seconds"),
);
renderForm("login");

await user.type(screen.getByLabelText("Email"), "person@example.com");
await user.type(screen.getByLabelText("Password"), "whatever");
await user.click(screen.getByRole("button", { name: "Sign in" }));

const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(
"Too many attempts. Please wait a few minutes and try again.",
);
expect(screen.queryByText(/seconds/)).not.toBeInTheDocument();
expect(screen.getByLabelText("Email")).not.toHaveAttribute(
"aria-invalid",
);
});

it("keeps the top banner for failures without field detail (401)", async () => {
const user = userEvent.setup();
mocks.login.mockRejectedValue(new AuthApiError(401, {}, "Invalid credentials"));
renderForm("login");

await user.type(screen.getByLabelText("Email"), "person@example.com");
await user.type(screen.getByLabelText("Password"), "wrong-password");
await user.click(screen.getByRole("button", { name: "Sign in" }));

expect(
await screen.findByText("Invalid credentials"),
).toBeInTheDocument();
expect(screen.getByLabelText("Email")).not.toHaveAttribute(
"aria-invalid",
);
});

it("clears a field's error when the user edits that field", async () => {
const user = userEvent.setup();
mocks.register.mockRejectedValue(
new AuthApiError(400, { email: "email must be an email" }, "Bad Request"),
);
renderForm("register");

await user.type(screen.getByLabelText("Email"), "nope");
await user.type(screen.getByLabelText("Password"), "password-one");
await user.type(screen.getByLabelText("Confirm password"), "password-one");
await user.click(screen.getByRole("button", { name: "Create account" }));
await screen.findByText("email must be an email");

await user.type(screen.getByLabelText("Email"), "x");
expect(screen.queryByText("email must be an email")).not.toBeInTheDocument();
});
});
73 changes: 70 additions & 3 deletions frontend/components/auth/auth-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { login, register } from "@/lib/auth-api";
import { login, register, AuthApiError } from "@/lib/auth-api";
import { getSafeReturnTo } from "@/lib/auth-redirect";
import { useSessionStore } from "@/lib/stores/session-store";

Expand All @@ -17,14 +17,29 @@ export function AuthForm({ mode }: { mode: Mode }) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
// Per-field validation errors from the API (issue #1988): keys are
// "email" / "password", values are backend messages for that field.
const [fieldErrors, setFieldErrors] = useState<
Record<string, string> | null
>(null);
const [confirmationError, setConfirmationError] = useState(false);
const [submitting, setSubmitting] = useState(false);

const isLogin = mode === "login";

function clearFieldError(field: string) {
setFieldErrors((prev) => {
if (!prev || !(field in prev)) return prev;
const next = { ...prev };
delete next[field];
return next;
});
}

async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setFieldErrors(null);
if (!isLogin && password !== confirmPassword) {
setConfirmationError(true);
return;
Expand All @@ -43,7 +58,29 @@ export function AuthForm({ mode }: { mode: Mode }) {
);
router.push(returnTo);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
// Rate limiting (#1988): show a single, calm form-level message and
// make it clear the problem is not a specific field.
if (err instanceof AuthApiError && err.status === 429) {
setFieldErrors(null);
setError("Too many attempts. Please wait a few minutes and try again.");
return;
}

const fieldErrors =
err instanceof AuthApiError ? { ...err.fieldErrors } : {};
const firstInvalidField = Object.entries(fieldErrors)[0];

if (firstInvalidField) {
// Render each message next to its field, announce them to screen
// readers (aria-live via role="alert") and focus the first invalid
// input so keyboard users land straight on what to fix.
setFieldErrors(fieldErrors);
setError(null);
document.getElementById(firstInvalidField[0])?.focus();
} else {
setFieldErrors(null);
setError(err instanceof Error ? err.message : "Something went wrong");
}
} finally {
setSubmitting(false);
}
Expand Down Expand Up @@ -74,9 +111,25 @@ export function AuthForm({ mode }: { mode: Mode }) {
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => {
setEmail(e.target.value);
clearFieldError("email");
}}
aria-invalid={
fieldErrors?.email ? true : undefined
}
aria-describedby={fieldErrors?.email ? "email-error" : undefined}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-transparent px-3 py-2 text-sm"
/>
{fieldErrors?.email && (
<p
id="email-error"
className="mt-1 text-sm text-red-600 dark:text-red-400"
role="alert"
>
{fieldErrors.email}
</p>
)}
</div>

<div>
Expand All @@ -96,9 +149,23 @@ export function AuthForm({ mode }: { mode: Mode }) {
onChange={(e) => {
setPassword(e.target.value);
setConfirmationError(false);
clearFieldError("password");
}}
aria-invalid={fieldErrors?.password ? true : undefined}
aria-describedby={
fieldErrors?.password ? "password-error" : undefined
}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-transparent px-3 py-2 text-sm"
/>
{fieldErrors?.password && (
<p
id="password-error"
className="mt-1 text-sm text-red-600 dark:text-red-400"
role="alert"
>
{fieldErrors.password}
</p>
)}
</div>

{!isLogin && (
Expand Down
Loading
Loading