diff --git a/ui/app/maintenance/maintenance-view.test.tsx b/ui/app/maintenance/maintenance-view.test.tsx
new file mode 100644
index 0000000000..e7f2beb35d
--- /dev/null
+++ b/ui/app/maintenance/maintenance-view.test.tsx
@@ -0,0 +1,77 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { MaintenanceView } from "./maintenance-view";
+
+describe("MaintenanceView", () => {
+ const reloadMock = vi.fn();
+ const fetchMock = vi.fn();
+
+ beforeEach(() => {
+ vi.stubGlobal("fetch", fetchMock);
+ // jsdom's `window.location.reload` is a no-op that throws if called; replace
+ // the whole `location` so we can observe reloads without navigation.
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { ...window.location, reload: reloadMock },
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ });
+
+ it("renders the given message and refresh notice", () => {
+ // When
+ render();
+
+ // Then
+ expect(screen.getByText("Under maintenance")).toBeInTheDocument();
+ expect(screen.getByText("Scheduled DB maintenance.")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "This page refreshes automatically once maintenance is complete.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("never makes a cross-origin fetch from the browser", () => {
+ // When
+ render();
+
+ // Then
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("reloads the page on an interval so the edge gate can redirect away", () => {
+ // Given
+ vi.useFakeTimers();
+
+ // When
+ render();
+ expect(reloadMock).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(15000); // one interval
+ expect(reloadMock).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(15000); // second interval
+ expect(reloadMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("stops reloading after unmount", () => {
+ // Given
+ vi.useFakeTimers();
+
+ // When
+ const { unmount } = render(
+ ,
+ );
+ unmount();
+ vi.advanceTimersByTime(15000);
+
+ // Then
+ expect(reloadMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/app/maintenance/maintenance-view.tsx b/ui/app/maintenance/maintenance-view.tsx
new file mode 100644
index 0000000000..e410070b5c
--- /dev/null
+++ b/ui/app/maintenance/maintenance-view.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useEffect } from "react";
+
+import { ProwlerExtended } from "@/components/icons";
+import { ThemeSwitch } from "@/components/ThemeSwitch";
+
+// How often the page reloads itself while maintenance is on. On each reload the
+// edge gate in `proxy.ts` re-evaluates maintenance state server-side (over the
+// compose network, so no browser CORS) and redirects `/maintenance` → `/` as
+// soon as the operator turns MM off. No client-side API call is made from here.
+const RELOAD_INTERVAL_MS = 15000;
+
+export interface MaintenanceViewProps {
+ message: string;
+}
+
+export function MaintenanceView({ message }: MaintenanceViewProps) {
+ useEffect(() => {
+ const interval = setInterval(() => {
+ window.location.reload();
+ }, RELOAD_INTERVAL_MS);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }, []);
+
+ return (
+
+
+ {/* Background Pattern — mirrors the sign-in screen (AuthLayout) */}
+
+
+ {/* Prowler Logo */}
+
+
+
+
+ {/* Maintenance Card — same shell as the auth form container */}
+
+ {/* Header with Title and Theme Toggle */}
+
+
Under maintenance
+
+
+
+
{message}
+
+
+ This page refreshes automatically once maintenance is complete.
+
+
+
+
+ );
+}
diff --git a/ui/app/maintenance/page.test.tsx b/ui/app/maintenance/page.test.tsx
new file mode 100644
index 0000000000..c9a28361fc
--- /dev/null
+++ b/ui/app/maintenance/page.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/react";
+import { headers } from "next/headers";
+import { describe, expect, it, vi } from "vitest";
+
+import MaintenancePage from "./page";
+
+vi.mock("next/headers", () => ({
+ headers: vi.fn(),
+}));
+
+const mockHeaders = (entries: Record) => {
+ vi.mocked(headers).mockResolvedValue(
+ new Headers(entries) as unknown as Awaited>,
+ );
+};
+
+describe("MaintenancePage", () => {
+ it("renders the message forwarded by the proxy gate via x-maintenance-message", async () => {
+ // Given
+ mockHeaders({ "x-maintenance-message": "Scheduled DB maintenance." });
+
+ // When
+ render(await MaintenancePage());
+
+ // Then
+ expect(screen.getByText("Scheduled DB maintenance.")).toBeInTheDocument();
+ });
+
+ it("falls back to the default message when the header is missing", async () => {
+ // Given
+ mockHeaders({});
+
+ // When
+ render(await MaintenancePage());
+
+ // Then
+ expect(
+ screen.getByText(
+ "Prowler is currently undergoing scheduled maintenance. We will be back shortly.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("falls back to the default message when the header is empty", async () => {
+ // Given
+ mockHeaders({ "x-maintenance-message": "" });
+
+ // When
+ render(await MaintenancePage());
+
+ // Then
+ expect(
+ screen.getByText(
+ "Prowler is currently undergoing scheduled maintenance. We will be back shortly.",
+ ),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/ui/app/maintenance/page.tsx b/ui/app/maintenance/page.tsx
new file mode 100644
index 0000000000..ad20011466
--- /dev/null
+++ b/ui/app/maintenance/page.tsx
@@ -0,0 +1,21 @@
+import { headers } from "next/headers";
+
+import { MaintenanceView } from "./maintenance-view";
+
+const DEFAULT_MESSAGE =
+ "Prowler is currently undergoing scheduled maintenance. We will be back shortly.";
+
+/**
+ * Server Component: reads the ops-set message forwarded by the `proxy.ts`
+ * maintenance gate as a request header (see `lib/maintenance.ts`
+ * `maintenanceResponse`'s rewrite branch) and passes it down to the client
+ * view. Falls back to `DEFAULT_MESSAGE` when the header is missing or empty
+ * — e.g. when this route is hit directly in dev without going through the
+ * gate.
+ */
+export default async function MaintenancePage() {
+ const headerList = await headers();
+ const message = headerList.get("x-maintenance-message") || DEFAULT_MESSAGE;
+
+ return ;
+}
diff --git a/ui/changelog.d/maintenance-mode-page.added.md b/ui/changelog.d/maintenance-mode-page.added.md
new file mode 100644
index 0000000000..50fecff069
--- /dev/null
+++ b/ui/changelog.d/maintenance-mode-page.added.md
@@ -0,0 +1 @@
+Maintenance mode landing page, gated to Prowler Cloud via `NEXT_PUBLIC_IS_CLOUD_ENV`
diff --git a/ui/lib/maintenance.test.ts b/ui/lib/maintenance.test.ts
new file mode 100644
index 0000000000..27e6b705b6
--- /dev/null
+++ b/ui/lib/maintenance.test.ts
@@ -0,0 +1,238 @@
+import { NextRequest } from "next/server";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ fetchMaintenanceStatus,
+ maintenanceResponse,
+ type MaintenanceStatus,
+} from "./maintenance";
+
+const API_BASE_URL = "https://api.example.com/api/v1";
+
+const fetchMock = vi.fn();
+
+const jsonResponse = (body: unknown, status = 200) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+
+const makeRequest = (path: string) =>
+ new NextRequest(new URL(`https://app.prowler.com${path}`));
+
+describe("fetchMaintenanceStatus", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it("returns the parsed status when MM is enabled", async () => {
+ // Given
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({
+ enabled: true,
+ message: "Scheduled DB maintenance.",
+ started_at: "2026-06-17T10:00:00Z",
+ }),
+ );
+
+ // When
+ const status = await fetchMaintenanceStatus(API_BASE_URL);
+
+ // Then
+ expect(status).toEqual({
+ enabled: true,
+ message: "Scheduled DB maintenance.",
+ started_at: "2026-06-17T10:00:00Z",
+ });
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe(`${API_BASE_URL}/maintenance`);
+ expect(init).toMatchObject({
+ headers: { Accept: "application/json" },
+ next: { revalidate: 15 },
+ });
+ });
+
+ it("returns MM off when the endpoint reports disabled", async () => {
+ // Given
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ enabled: false, message: null, started_at: null }),
+ );
+
+ // When
+ const status = await fetchMaintenanceStatus(API_BASE_URL);
+
+ // Then
+ expect(status.enabled).toBe(false);
+ });
+
+ it("fails open when the API base URL is missing", async () => {
+ // When
+ const status = await fetchMaintenanceStatus(undefined);
+
+ // Then
+ expect(status).toEqual({
+ enabled: false,
+ message: null,
+ started_at: null,
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("fails open on a non-200 response", async () => {
+ // Given
+ fetchMock.mockResolvedValueOnce(jsonResponse({ enabled: true }, 500));
+
+ // When
+ const status = await fetchMaintenanceStatus(API_BASE_URL);
+
+ // Then
+ expect(status.enabled).toBe(false);
+ });
+
+ it("fails open on a network error / timeout", async () => {
+ // Given
+ fetchMock.mockRejectedValueOnce(new Error("network down"));
+
+ // When
+ const status = await fetchMaintenanceStatus(API_BASE_URL);
+
+ // Then
+ expect(status.enabled).toBe(false);
+ });
+
+ it("coerces a malformed body to MM off", async () => {
+ // Given
+ fetchMock.mockResolvedValueOnce(jsonResponse({ unexpected: "shape" }));
+
+ // When
+ const status = await fetchMaintenanceStatus(API_BASE_URL);
+
+ // Then
+ expect(status).toEqual({
+ enabled: false,
+ message: null,
+ started_at: null,
+ });
+ });
+});
+
+describe("maintenanceResponse", () => {
+ const enabled: MaintenanceStatus = {
+ enabled: true,
+ message: "Down for maintenance.",
+ started_at: "2026-06-17T10:00:00Z",
+ };
+ const disabled: MaintenanceStatus = {
+ enabled: false,
+ message: null,
+ started_at: null,
+ };
+
+ it("rewrites to /maintenance when MM is enabled and not already there", () => {
+ // When
+ const response = maintenanceResponse(makeRequest("/scans"), enabled);
+
+ // Then
+ expect(response).not.toBeNull();
+ expect(response?.headers.get("x-middleware-rewrite")).toBe(
+ "https://app.prowler.com/maintenance",
+ );
+ });
+
+ it("forwards the ops-set message and started_at as request headers on rewrite", () => {
+ // When
+ const response = maintenanceResponse(makeRequest("/scans"), enabled);
+
+ // Then
+ expect(response?.headers.get("x-middleware-override-headers")).toContain(
+ "x-maintenance-message",
+ );
+ expect(
+ response?.headers.get("x-middleware-request-x-maintenance-message"),
+ ).toBe("Down for maintenance.");
+ expect(
+ response?.headers.get("x-middleware-request-x-maintenance-started-at"),
+ ).toBe("2026-06-17T10:00:00Z");
+ });
+
+ it("forwards empty-string headers (not the literal string 'null') when message/started_at are null", () => {
+ // Given
+ const enabledNoMessage: MaintenanceStatus = {
+ enabled: true,
+ message: null,
+ started_at: null,
+ };
+
+ // When
+ const response = maintenanceResponse(
+ makeRequest("/scans"),
+ enabledNoMessage,
+ );
+
+ // Then
+ expect(
+ response?.headers.get("x-middleware-request-x-maintenance-message"),
+ ).toBe("");
+ expect(
+ response?.headers.get("x-middleware-request-x-maintenance-started-at"),
+ ).toBe("");
+ });
+
+ it("returns a terminal NextResponse.next() when MM is enabled and already on /maintenance (no auth fallthrough)", () => {
+ // When
+ const response = maintenanceResponse(makeRequest("/maintenance"), enabled);
+
+ // Then: must be truthy so `proxy()` returns it directly instead of
+ // falling through into `authProxy`, which would redirect an
+ // unauthenticated visitor to /sign-in.
+ expect(response).not.toBeNull();
+ expect(response?.headers.get("x-middleware-next")).toBe("1");
+ // And it must NOT be a redirect/rewrite — this is the actual page render.
+ expect(response?.headers.get("location")).toBeNull();
+ expect(response?.headers.get("x-middleware-rewrite")).toBeNull();
+ });
+
+ it("redirects /maintenance back to / when MM is disabled", () => {
+ // When
+ const response = maintenanceResponse(makeRequest("/maintenance"), disabled);
+
+ // Then
+ expect(response?.status).toBe(307);
+ expect(response?.headers.get("location")).toBe("https://app.prowler.com/");
+ });
+
+ it("is a no-op (fail-open) when MM is disabled on a normal route", () => {
+ // When
+ const response = maintenanceResponse(makeRequest("/scans"), disabled);
+
+ // Then
+ expect(response).toBeNull();
+ });
+});
+
+describe("proxy.ts matcher config", () => {
+ it("no longer excludes /maintenance from the gate", async () => {
+ // The gate must run on /maintenance too, otherwise its terminal branch
+ // (MM on + already on /maintenance) never executes and the request falls
+ // through to authProxy, which redirects unauthenticated visitors to
+ // /sign-in instead of serving the maintenance page. Read the raw matcher
+ // pattern to guard against a regression re-adding the exclusion.
+ const path = await import("node:path");
+ const fs = await import("node:fs/promises");
+ const proxyModule = await fs.readFile(
+ path.resolve(process.cwd(), "proxy.ts"),
+ "utf-8",
+ );
+ const matcherLine = proxyModule
+ .split("\n")
+ .find((line) => line.includes("api|_next/static"));
+
+ expect(matcherLine).toBeDefined();
+ expect(matcherLine).not.toContain("maintenance|");
+ });
+});
diff --git a/ui/lib/maintenance.ts b/ui/lib/maintenance.ts
new file mode 100644
index 0000000000..f26c466bb0
--- /dev/null
+++ b/ui/lib/maintenance.ts
@@ -0,0 +1,122 @@
+import { NextRequest, NextResponse } from "next/server";
+
+/**
+ * Shape of the public, unauthenticated `GET /api/v1/maintenance`
+ * response. Flat JSON (NOT JSON:API) by design — the endpoint must answer
+ * even when the DB is down, so it reads only from Redis.
+ */
+export interface MaintenanceStatus {
+ enabled: boolean;
+ message: string | null;
+ started_at: string | null;
+}
+
+export const MAINTENANCE_PATH = "/maintenance";
+
+const MAINTENANCE_STATUS_PATH = "/maintenance";
+
+/**
+ * Short timeout so a slow/hung status endpoint never blocks every request on
+ * the edge. On timeout we fail-open (treat MM as off).
+ */
+const STATUS_FETCH_TIMEOUT_MS = 2000;
+
+/**
+ * Edge cache window for the status probe. Keeps the per-request fetch cheap
+ * while still recovering within ~15s of an operator toggling MM on/off.
+ */
+const STATUS_REVALIDATE_SECONDS = 15;
+
+/**
+ * Fetch the public maintenance status from the API.
+ *
+ * Fail-open contract: ANY error (network, timeout, non-200, malformed body)
+ * resolves to `{ enabled: false }`. A status blip must never lock users out —
+ * the API itself is the enforcement layer (it returns 503 when MM is really
+ * on); the UI gate is purely cosmetic, so erring toward "off" is safe.
+ */
+export const fetchMaintenanceStatus = async (
+ // `@/lib`'s `apiBaseUrl` resolves via `readEnv`, which returns `string |
+ // null` (not `undefined`) when unset.
+ apiBaseUrl: string | null | undefined,
+): Promise => {
+ const fallback: MaintenanceStatus = {
+ enabled: false,
+ message: null,
+ started_at: null,
+ };
+
+ if (!apiBaseUrl) {
+ return fallback;
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), STATUS_FETCH_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(`${apiBaseUrl}${MAINTENANCE_STATUS_PATH}`, {
+ headers: { Accept: "application/json" },
+ signal: controller.signal,
+ next: { revalidate: STATUS_REVALIDATE_SECONDS },
+ });
+
+ if (!response.ok) {
+ return fallback;
+ }
+
+ const data = (await response.json()) as Partial;
+
+ return {
+ enabled: data?.enabled === true,
+ message: typeof data?.message === "string" ? data.message : null,
+ started_at: typeof data?.started_at === "string" ? data.started_at : null,
+ };
+ } catch {
+ // Network error or aborted-by-timeout → fail open.
+ return fallback;
+ } finally {
+ clearTimeout(timeout);
+ }
+};
+
+/**
+ * Decide what the maintenance gate should do for a given request, given the
+ * current status. Pure and side-effect free so it can be unit-tested without
+ * the Next runtime:
+ *
+ * - MM on, not already on `/maintenance` → rewrite to `/maintenance`,
+ * forwarding the ops-set message/started_at as request headers so the page
+ * can render them (preserve the URL so the user lands back where they were
+ * on recovery).
+ * - MM on, already on `/maintenance` → TERMINAL `NextResponse.next()`. Must
+ * be truthy (not null) so `proxy()` returns it directly instead of falling
+ * through into `authProxy`, which would redirect an unauthenticated
+ * visitor to `/sign-in` instead of letting the maintenance page render.
+ * - MM off, currently on `/maintenance` → redirect to `/`.
+ * - Otherwise → no-op (let the request continue / the next handler run).
+ */
+export const maintenanceResponse = (
+ request: NextRequest,
+ status: MaintenanceStatus,
+): NextResponse | null => {
+ const { pathname } = request.nextUrl;
+ const onMaintenancePage = pathname.startsWith(MAINTENANCE_PATH);
+
+ if (status.enabled) {
+ if (onMaintenancePage) {
+ return NextResponse.next();
+ }
+
+ const url = new URL(MAINTENANCE_PATH, request.url);
+ const headers = new Headers(request.headers);
+ headers.set("x-maintenance-message", status.message ?? "");
+ headers.set("x-maintenance-started-at", status.started_at ?? "");
+ return NextResponse.rewrite(url, { request: { headers } });
+ }
+
+ if (onMaintenancePage) {
+ return NextResponse.redirect(new URL("/", request.url));
+ }
+
+ return null;
+};
diff --git a/ui/lib/server-actions-helper.test.ts b/ui/lib/server-actions-helper.test.ts
index bfb9fb5ced..4438bcd824 100644
--- a/ui/lib/server-actions-helper.test.ts
+++ b/ui/lib/server-actions-helper.test.ts
@@ -1,11 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-const { captureExceptionMock, captureMessageMock, revalidatePathMock } =
- vi.hoisted(() => ({
- captureExceptionMock: vi.fn(),
- captureMessageMock: vi.fn(),
- revalidatePathMock: vi.fn(),
- }));
+const {
+ captureExceptionMock,
+ captureMessageMock,
+ revalidatePathMock,
+ redirectMock,
+ fetchMaintenanceStatusMock,
+} = vi.hoisted(() => ({
+ captureExceptionMock: vi.fn(),
+ captureMessageMock: vi.fn(),
+ revalidatePathMock: vi.fn(),
+ redirectMock: vi.fn(() => {
+ // Mirror Next's real `redirect()`: it throws to unwind the call stack via
+ // the NEXT_REDIRECT digest rather than returning normally.
+ throw new Error("NEXT_REDIRECT");
+ }),
+ fetchMaintenanceStatusMock: vi.fn(),
+}));
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
@@ -16,6 +27,19 @@ vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
+vi.mock("next/navigation", () => ({
+ redirect: redirectMock,
+}));
+
+vi.mock("@/lib", () => ({
+ apiBaseUrl: "http://api:8000/api/v1",
+}));
+
+vi.mock("@/lib/maintenance", () => ({
+ MAINTENANCE_PATH: "/maintenance",
+ fetchMaintenanceStatus: fetchMaintenanceStatusMock,
+}));
+
vi.mock("./helper", () => ({
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
@@ -51,4 +75,83 @@ describe("server action error handling", () => {
"Server is temporarily unavailable. Please try again in a few minutes.",
);
});
+
+ describe('on Cloud (NEXT_PUBLIC_IS_CLOUD_ENV="true")', () => {
+ beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ });
+
+ it("redirects to /maintenance on a 503 when maintenance mode is actually on", async () => {
+ // Given
+ fetchMaintenanceStatusMock.mockResolvedValueOnce({
+ enabled: true,
+ message: "Down for maintenance.",
+ started_at: "2026-06-17T10:00:00Z",
+ });
+ const response = new Response(null, { status: 503 });
+
+ // When / Then
+ const result = handleApiResponse(response);
+ await expect(result).rejects.toThrow("NEXT_REDIRECT");
+ expect(redirectMock).toHaveBeenCalledWith("/maintenance");
+ });
+
+ it("does NOT redirect on a 503 when maintenance mode is off (transient error, or the status probe itself fails open) — falls through to normal 5xx handling instead", async () => {
+ // Given: `fetchMaintenanceStatus` fails open (2s timeout → `enabled:
+ // false`) on any error, so a status-check blip resolves identically to a
+ // confirmed "MM off" here — this asserts the helper's behavior on that
+ // resolved value regardless of why it came back `false`. Falling through
+ // means the normal 5xx path takes over and throws the generic server
+ // error rather than redirecting to /maintenance.
+ fetchMaintenanceStatusMock.mockResolvedValueOnce({
+ enabled: false,
+ message: null,
+ started_at: null,
+ });
+ const response = new Response("Service Unavailable", {
+ status: 503,
+ statusText: "Service Unavailable",
+ });
+
+ // When / Then
+ const result = handleApiResponse(response);
+ await expect(result).rejects.toThrow(
+ "Server is temporarily unavailable. Please try again in a few minutes.",
+ );
+ expect(redirectMock).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('self-hosted (NEXT_PUBLIC_IS_CLOUD_ENV is not "true")', () => {
+ it("is a no-op on a 503: never probes maintenance status and never redirects, falling straight through to normal 5xx handling", async () => {
+ // Given: Maintenance Mode is a Cloud-only feature (see
+ // lib/maintenance.ts) — self-hosted deployments have no MM status
+ // endpoint, so a 503 there is always a normal server error.
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+ const response = new Response("Service Unavailable", {
+ status: 503,
+ statusText: "Service Unavailable",
+ });
+
+ // When / Then
+ const result = handleApiResponse(response);
+ await expect(result).rejects.toThrow(
+ "Server is temporarily unavailable. Please try again in a few minutes.",
+ );
+ expect(fetchMaintenanceStatusMock).not.toHaveBeenCalled();
+ expect(redirectMock).not.toHaveBeenCalled();
+ });
+
+ it("is also a no-op when NEXT_PUBLIC_IS_CLOUD_ENV is unset", async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", undefined);
+ const response = new Response(null, { status: 503 });
+
+ // When / Then
+ const result = handleApiResponse(response);
+ await expect(result).rejects.toThrow();
+ expect(fetchMaintenanceStatusMock).not.toHaveBeenCalled();
+ expect(redirectMock).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/ui/lib/server-actions-helper.ts b/ui/lib/server-actions-helper.ts
index ae677f2ed2..44e7b03f9a 100644
--- a/ui/lib/server-actions-helper.ts
+++ b/ui/lib/server-actions-helper.ts
@@ -1,6 +1,10 @@
import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { apiBaseUrl } from "@/lib";
+import { fetchMaintenanceStatus, MAINTENANCE_PATH } from "@/lib/maintenance";
+import { isCloud } from "@/lib/shared/env";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
import {
@@ -20,6 +24,30 @@ export const handleApiResponse = async (
parse = true,
) => {
if (!response.ok) {
+ // Maintenance Mode is Cloud-only (see lib/maintenance.ts): self-hosted
+ // has no MM status endpoint, so a 503 there is always a normal server
+ // error, never a maintenance redirect — skip the check entirely.
+ //
+ // On Cloud: when MM flips on between the proxy check and a server action
+ // firing, the API returns 503 for every endpoint. Redirect to the
+ // full-screen /maintenance landing page instead of surfacing a generic
+ // server error. `redirect()` throws NEXT_REDIRECT, which Next turns into
+ // a client navigation, so this short-circuits before the
+ // Sentry-capturing 5xx branch below.
+ //
+ // A 503 alone isn't proof of MM though — any transient upstream error
+ // (DB blip, deploy rollout) also returns 503 and must NOT be conflated
+ // with real maintenance. Confirm against the status endpoint first;
+ // `fetchMaintenanceStatus` fails open (2s timeout → `enabled: false`), so
+ // a status-check blip falls through to normal error handling instead of
+ // redirecting.
+ if (isCloud() && response.status === 503) {
+ const status = await fetchMaintenanceStatus(apiBaseUrl);
+ if (status.enabled) {
+ redirect(MAINTENANCE_PATH);
+ }
+ }
+
// Read error body safely; prefer JSON, fallback to plain text
const rawErrorText = await response.text().catch(() => "");
const contentType = response.headers.get("content-type")?.toLowerCase();
diff --git a/ui/proxy.test.ts b/ui/proxy.test.ts
new file mode 100644
index 0000000000..5c629df979
--- /dev/null
+++ b/ui/proxy.test.ts
@@ -0,0 +1,104 @@
+import type { NextFetchEvent } from "next/server";
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const {
+ authHandlerSpy,
+ authMock,
+ fetchMaintenanceStatusMock,
+ maintenanceResponseMock,
+} = vi.hoisted(() => ({
+ authHandlerSpy: vi.fn(),
+ // Minimal stand-in for next-auth's `auth()` wrapper: it takes the route
+ // handler and returns a function with the (req, ctx) signature `proxy()`
+ // calls `authProxy` with. The real handler reads `req.auth`, which a
+ // plain NextRequest doesn't have, so `user`/`sessionError` resolve to
+ // `undefined` here — irrelevant to what this suite asserts (the MM gate
+ // itself), it only needs to observe whether authProxy ran.
+ authMock: vi.fn(
+ (handler: (req: unknown) => unknown) => (req: unknown, _ctx: unknown) => {
+ authHandlerSpy(req);
+ return handler(req);
+ },
+ ),
+ fetchMaintenanceStatusMock: vi.fn(),
+ maintenanceResponseMock: vi.fn(),
+}));
+
+vi.mock("@/auth.config", () => ({
+ auth: authMock,
+}));
+
+vi.mock("@/lib", () => ({
+ apiBaseUrl: "http://api:8000/api/v1",
+}));
+
+vi.mock("@/lib/maintenance", () => ({
+ fetchMaintenanceStatus: fetchMaintenanceStatusMock,
+ maintenanceResponse: maintenanceResponseMock,
+ MAINTENANCE_PATH: "/maintenance",
+}));
+
+import proxy from "./proxy";
+
+const makeRequest = (path: string) =>
+ new NextRequest(new URL(`https://app.prowler.com${path}`));
+
+describe("proxy() Maintenance Mode gate (isCloud gating)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('runs the maintenance status check when NEXT_PUBLIC_IS_CLOUD_ENV is "true"', async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
+ fetchMaintenanceStatusMock.mockResolvedValueOnce({
+ enabled: false,
+ message: null,
+ started_at: null,
+ });
+ maintenanceResponseMock.mockReturnValueOnce(null);
+
+ // When
+ const response = await proxy(
+ makeRequest("/scans") as any,
+ {} as NextFetchEvent,
+ );
+
+ // Then: the gate ran (and, since it returned null here, fell through to
+ // the auth-wrapped handler, which redirects unauthenticated visitors).
+ expect(fetchMaintenanceStatusMock).toHaveBeenCalledTimes(1);
+ expect(maintenanceResponseMock).toHaveBeenCalledTimes(1);
+ expect(authHandlerSpy).toHaveBeenCalledTimes(1);
+ expect(response.status).toBe(307);
+ });
+
+ it('is a no-op in self-hosted (NEXT_PUBLIC_IS_CLOUD_ENV is not "true"): the status fetch is never made and the request falls straight through to auth', async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
+
+ // When
+ const response = await proxy(
+ makeRequest("/scans") as any,
+ {} as NextFetchEvent,
+ );
+
+ // Then
+ expect(fetchMaintenanceStatusMock).not.toHaveBeenCalled();
+ expect(maintenanceResponseMock).not.toHaveBeenCalled();
+ expect(authHandlerSpy).toHaveBeenCalledTimes(1);
+ expect(response.status).toBe(307);
+ });
+
+ it("is also a no-op when NEXT_PUBLIC_IS_CLOUD_ENV is unset", async () => {
+ // Given
+ vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", undefined);
+
+ // When
+ await proxy(makeRequest("/scans") as any, {} as NextFetchEvent);
+
+ // Then
+ expect(fetchMaintenanceStatusMock).not.toHaveBeenCalled();
+ expect(maintenanceResponseMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/proxy.ts b/ui/proxy.ts
index d3bb8eb20e..efd6bda5c4 100644
--- a/ui/proxy.ts
+++ b/ui/proxy.ts
@@ -1,7 +1,10 @@
-import { NextResponse } from "next/server";
+import { NextFetchEvent, NextResponse } from "next/server";
import type { NextAuthRequest } from "next-auth";
import { auth } from "@/auth.config";
+import { apiBaseUrl } from "@/lib";
+import { fetchMaintenanceStatus, maintenanceResponse } from "@/lib/maintenance";
+import { isCloud } from "@/lib/shared/env";
const publicRoutes = [
"/sign-in",
@@ -17,8 +20,13 @@ const isPublicRoute = (pathname: string): boolean => {
return publicRoutes.some((route) => pathname.startsWith(route));
};
-// NextAuth's auth() wrapper - renamed from middleware to proxy
-export default auth((req: NextAuthRequest) => {
+// NextAuth's auth() wrapper - renamed from middleware to proxy.
+//
+// Maintenance Mode (MM) is a Cloud-only feature (see `lib/maintenance.ts`).
+// Its gate runs from the exported `proxy()` wrapper below, guarded by
+// `isCloud()`, so self-hosted deployments never issue the status fetch and
+// this handler behaves exactly as it did before MM existed.
+const authProxy = auth((req: NextAuthRequest) => {
const { pathname } = req.nextUrl;
const user = req.auth?.user;
@@ -56,6 +64,34 @@ export default auth((req: NextAuthRequest) => {
return NextResponse.next();
});
+export default async function proxy(
+ req: NextAuthRequest,
+ ctx: NextFetchEvent,
+): Promise {
+ // Maintenance Mode is Cloud-only: self-hosted has no MM status endpoint,
+ // so skip the fetch entirely rather than issue it on every request only to
+ // fail open. Fail-open contract for the Cloud path itself: any error
+ // fetching the status is treated as MM off (never lock users out on a
+ // status blip). When MM is on, every matched request is rewritten to the
+ // dependency-free /maintenance landing page; when MM is off, /maintenance
+ // redirects back to /.
+ if (isCloud()) {
+ const status = await fetchMaintenanceStatus(apiBaseUrl);
+ const gate = maintenanceResponse(req, status);
+ if (gate) {
+ return gate;
+ }
+ }
+
+ // Delegate to the NextAuth-wrapped handler for normal auth/permission flow.
+ // Next passes a NextFetchEvent as the middleware context; next-auth's
+ // `auth()` wrapper types the param as AppRouteHandlerFnContext, so bridge it.
+ return (await authProxy(
+ req,
+ ctx as unknown as Parameters[1],
+ )) as NextResponse;
+}
+
export const config = {
matcher: [
/*
@@ -65,6 +101,13 @@ export const config = {
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - *.png, *.jpg, *.jpeg, *.svg, *.ico (image files)
+ *
+ * /maintenance IS matched (not excluded): when the Cloud MM gate is
+ * active it must run there too so its terminal branch (MM on + already
+ * on /maintenance) can return NextResponse.next() itself instead of
+ * falling through to authProxy, which would redirect an unauthenticated
+ * visitor to /sign-in. Self-hosted never reaches that branch (gated by
+ * isCloud() above), but the route stays unexcluded either way.
*/
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|ico|css|js)$).*)",
],