diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 9a8b30a0e..7480fa27b 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -54,6 +54,12 @@ jobs:
env:
SKIP_ENV_VALIDATION: '1'
+ - name: Run auth recovery E2E tests
+ working-directory: frontend
+ run: pnpm exec playwright test -c playwright.auth.config.ts
+ env:
+ SKIP_ENV_VALIDATION: '1'
+
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
diff --git a/frontend/playwright.auth.config.ts b/frontend/playwright.auth.config.ts
new file mode 100644
index 000000000..9d53b807a
--- /dev/null
+++ b/frontend/playwright.auth.config.ts
@@ -0,0 +1,49 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const frontendPort = process.env.E2E_AUTH_FRONTEND_PORT ?? "3001";
+const frontendURL =
+ process.env.PLAYWRIGHT_AUTH_BASE_URL ?? `http://localhost:${frontendPort}`;
+const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === "1";
+
+/**
+ * Auth-enabled E2E coverage for the login and setup pages. The default config
+ * runs with auth disabled so workspace tests can skip a real session; that
+ * would SSR-redirect these pages before page.route() can exercise recovery.
+ */
+export default defineConfig({
+ testDir: "./tests/e2e-auth",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: process.env.CI ? "github" : "html",
+ timeout: 30_000,
+
+ use: {
+ baseURL: frontendURL,
+ locale: "en-US",
+ trace: "on-first-retry",
+ },
+
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+
+ webServer: skipWebServer
+ ? undefined
+ : {
+ command: `pnpm build && pnpm exec next start -p ${frontendPort}`,
+ url: frontendURL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 240_000,
+ env: {
+ SKIP_ENV_VALIDATION: "1",
+ DEER_FLOW_AUTH_DISABLED: "0",
+ DEER_FLOW_INTERNAL_GATEWAY_BASE_URL: "http://127.0.0.1:65535",
+ DEER_FLOW_TRUSTED_ORIGINS: frontendURL,
+ },
+ },
+});
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index 0e28b58c6..b7acbc309 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -72,7 +72,10 @@ export default function LoginPage() {
const [setupStatus, setSetupStatus] = useState(
null,
);
- const [setupStatusChecked, setSetupStatusChecked] = useState(false);
+ const [setupStatusPhase, setSetupStatusPhase] = useState<
+ "checking" | "ready" | "unavailable"
+ >("checking");
+ const [setupStatusAttempt, setSetupStatusAttempt] = useState(0);
// Extract error from query params (e.g., ?error=sso_failed)
const errorParam = searchParams.get("error");
@@ -93,10 +96,15 @@ export default function LoginPage() {
const nextParam = searchParams.get("next");
const redirectPath = validateNextParam(nextParam) ?? "/workspace";
const regularSignupAllowed = canCreateRegularAccount({
- checked: setupStatusChecked,
+ // A failed probe must not expose registration while the system's setup
+ // state is unknown. Existing users can still sign in normally.
+ checked: setupStatusPhase === "ready",
status: setupStatus,
});
const systemNeedsAdminSetup = setupStatus?.needs_setup === true;
+ const showSetupStatusUnavailable =
+ setupStatusPhase === "unavailable" ||
+ (setupStatusAttempt > 0 && setupStatusPhase === "checking");
// Redirect if already authenticated (client-side, post-login)
useEffect(() => {
@@ -113,14 +121,17 @@ export default function LoginPage() {
}
}, []);
- // Fetch setup state and SSO providers
+ // Fetch setup state independently so retrying a slow Gateway does not also
+ // refetch unrelated auth-provider configuration.
useEffect(() => {
let cancelled = false;
+ setSetupStatusPhase("checking");
void fetchSetupStatus()
.then((data) => {
if (cancelled) return;
setSetupStatus(data);
+ setSetupStatusPhase("ready");
if (data.needs_setup) {
setIsLogin(true);
}
@@ -128,14 +139,20 @@ export default function LoginPage() {
.catch(() => {
if (!cancelled) {
setSetupStatus(null);
- }
- })
- .finally(() => {
- if (!cancelled) {
- setSetupStatusChecked(true);
+ setSetupStatusPhase("unavailable");
}
});
+ return () => {
+ cancelled = true;
+ };
+ }, [setupStatusAttempt]);
+
+ // SSO providers are static for the page lifetime and should not be coupled to
+ // setup-status retries.
+ useEffect(() => {
+ let cancelled = false;
+
void fetch("/api/v1/auth/providers")
.then((r) => r.json())
.then(
@@ -234,6 +251,34 @@ export default function LoginPage() {
+ {showSetupStatusUnavailable && (
+
+
{t.login.serviceUnavailableTitle}
+
+ {t.login.serviceUnavailableDescription}
+
+
+
+ )}
+
{systemNeedsAdminSetup && (
{t.login.adminSetupRequiredTitle}
diff --git a/frontend/src/app/(auth)/setup/page.tsx b/frontend/src/app/(auth)/setup/page.tsx
index 28ce8cc1f..d2120257f 100644
--- a/frontend/src/app/(auth)/setup/page.tsx
+++ b/frontend/src/app/(auth)/setup/page.tsx
@@ -16,14 +16,17 @@ import {
isSystemAlreadyInitializedError,
} from "@/core/auth/setup";
import { parseAuthError } from "@/core/auth/types";
+import { useI18n } from "@/core/i18n/hooks";
-type SetupMode = "loading" | "init_admin" | "change_password";
+type SetupMode = "loading" | "init_admin" | "change_password" | "unavailable";
export default function SetupPage() {
const router = useRouter();
const { user, isAuthenticated } = useAuth();
const { theme, resolvedTheme } = useTheme();
+ const { t } = useI18n();
const [mode, setMode] = useState
("loading");
+ const [setupStatusAttempt, setSetupStatusAttempt] = useState(0);
// --- Shared state ---
const [email, setEmail] = useState("");
@@ -44,7 +47,9 @@ export default function SetupPage() {
if (isAuthenticated && user?.needs_setup) {
setMode("change_password");
} else if (!isAuthenticated) {
- // Check if the system has no users yet
+ // Check if the system has no users yet. A slow Gateway must not leave the
+ // setup page in an infinite loading state or silently redirect away.
+ setMode("loading");
void fetchSetupStatus()
.then((data: { needs_setup?: boolean }) => {
if (cancelled) return;
@@ -56,7 +61,7 @@ export default function SetupPage() {
}
})
.catch(() => {
- if (!cancelled) router.replace("/login");
+ if (!cancelled) setMode("unavailable");
});
} else {
// Authenticated but needs_setup is false — already set up
@@ -66,7 +71,7 @@ export default function SetupPage() {
return () => {
cancelled = true;
};
- }, [isAuthenticated, user, router]);
+ }, [isAuthenticated, user, router, setupStatusAttempt]);
// ── Init-admin handler ─────────────────────────────────────────────
const handleInitAdmin = async (e: React.SubmitEvent) => {
@@ -166,6 +171,41 @@ export default function SetupPage() {
);
}
+ if (mode === "unavailable") {
+ return (
+
+
+
+
+ {t.login.serviceUnavailableTitle}
+
+
+ {t.login.serviceUnavailableDescription}
+
+
+
+
+
+
+
+
+ );
+ }
+
// ── Admin initialization form ──────────────────────────────────────
if (mode === "init_admin") {
return (
diff --git a/frontend/src/core/auth/constants.ts b/frontend/src/core/auth/constants.ts
new file mode 100644
index 000000000..5b29b2701
--- /dev/null
+++ b/frontend/src/core/auth/constants.ts
@@ -0,0 +1 @@
+export const AUTH_REQUEST_TIMEOUT_MS = 5_000;
diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts
index 198ef087c..622f52e94 100644
--- a/frontend/src/core/auth/server.ts
+++ b/frontend/src/core/auth/server.ts
@@ -3,12 +3,11 @@ import { cookies } from "next/headers";
import { isStaticWebsiteOnly } from "../static-mode";
import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user";
+import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { getGatewayConfig } from "./gateway-config";
import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types";
-const SSR_AUTH_TIMEOUT_MS = 5_000;
-
/**
* Fetch the authenticated user from the gateway using the request's cookies.
* Returns a tagged AuthResult — callers use exhaustive switch, no try/catch.
@@ -43,7 +42,7 @@ export async function getServerSideUser(): Promise {
const setupController = new AbortController();
const setupTimeout = setTimeout(
() => setupController.abort(),
- SSR_AUTH_TIMEOUT_MS,
+ AUTH_REQUEST_TIMEOUT_MS,
);
try {
const setupRes = await fetch(
@@ -68,7 +67,7 @@ export async function getServerSideUser(): Promise {
}
const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS);
+ const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS);
try {
const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, {
diff --git a/frontend/src/core/auth/setup.ts b/frontend/src/core/auth/setup.ts
index 055dc05c2..1c484dcac 100644
--- a/frontend/src/core/auth/setup.ts
+++ b/frontend/src/core/auth/setup.ts
@@ -1,3 +1,4 @@
+import { AUTH_REQUEST_TIMEOUT_MS } from "./constants";
import { parseAuthError } from "./types";
export type SetupStatusResponse = {
@@ -16,14 +17,21 @@ export const setupStatusFetchInit = {
} satisfies RequestInit;
export async function fetchSetupStatus(): Promise {
- const response = await fetch(
- "/api/v1/auth/setup-status",
- setupStatusFetchInit,
- );
- if (!response.ok) {
- throw new Error(`setup-status failed: ${response.status}`);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), AUTH_REQUEST_TIMEOUT_MS);
+
+ try {
+ const response = await fetch("/api/v1/auth/setup-status", {
+ ...setupStatusFetchInit,
+ signal: controller.signal,
+ });
+ if (!response.ok) {
+ throw new Error(`setup-status failed: ${response.status}`);
+ }
+ return (await response.json()) as SetupStatusResponse;
+ } finally {
+ clearTimeout(timeout);
}
- return (await response.json()) as SetupStatusResponse;
}
export function isSystemAlreadyInitializedError(data: unknown): boolean {
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index d81ef11db..ca81ba209 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -1092,6 +1092,10 @@ export const enUS: Translations = {
haveAccountSignIn: "Already have an account? Sign in",
backToHome: "← Back to home",
networkError: "Network error. Please try again.",
+ serviceUnavailableTitle: "Service temporarily unavailable",
+ serviceUnavailableDescription:
+ "The Gateway is taking too long to respond. Check that it is running, then try again.",
+ retry: "Try again",
authFailed: "Authentication failed.",
errors: {
sso_failed: "SSO login failed. Please try again or use email login.",
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 918d172a0..9fc9a1824 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -862,6 +862,9 @@ export interface Translations {
haveAccountSignIn: string;
backToHome: string;
networkError: string;
+ serviceUnavailableTitle: string;
+ serviceUnavailableDescription: string;
+ retry: string;
authFailed: string;
errors: {
sso_failed: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index eb996cabf..6d6e30209 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -1043,6 +1043,10 @@ export const zhCN: Translations = {
haveAccountSignIn: "已有账号?立即登录",
backToHome: "← 返回首页",
networkError: "网络错误,请重试。",
+ serviceUnavailableTitle: "服务暂时不可用",
+ serviceUnavailableDescription:
+ "网关响应时间过长。请确认服务正在运行,然后重试。",
+ retry: "重试",
authFailed: "身份验证失败。",
errors: {
sso_failed: "SSO 登录失败,请重试或使用邮箱登录。",
diff --git a/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts b/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts
new file mode 100644
index 000000000..d6a725bc7
--- /dev/null
+++ b/frontend/tests/e2e-auth/auth-setup-recovery.spec.ts
@@ -0,0 +1,102 @@
+import { expect, test, type Page } from "@playwright/test";
+
+const SETUP_STATUS_URL = "**/api/v1/auth/setup-status";
+const SERVICE_UNAVAILABLE_TITLE = "Service temporarily unavailable";
+
+async function mockSetupStatusRecovery(
+ page: Page,
+ recoveredStatus: {
+ needs_setup: boolean;
+ registration_enabled?: boolean;
+ },
+) {
+ let requestCount = 0;
+
+ await page.route(SETUP_STATUS_URL, (route) => {
+ if (route.request().method() !== "GET") {
+ return route.fallback();
+ }
+
+ requestCount += 1;
+ if (requestCount === 1) {
+ return route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Gateway unavailable" }),
+ });
+ }
+
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(recoveredStatus),
+ });
+ });
+
+ return () => requestCount;
+}
+
+test.describe("auth setup-status recovery", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.route("**/api/v1/auth/providers", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ providers: [] }),
+ }),
+ );
+ });
+
+ test("login restores registration after setup-status retry", async ({
+ page,
+ }) => {
+ const getRequestCount = await mockSetupStatusRecovery(page, {
+ needs_setup: false,
+ registration_enabled: true,
+ });
+
+ await page.goto("/login");
+
+ await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeVisible();
+ await expect(page.getByRole("button", { name: "Sign In" })).toBeEnabled();
+ await expect(
+ page.getByRole("button", { name: /Don't have an account/i }),
+ ).toHaveCount(0);
+ expect(getRequestCount()).toBe(1);
+
+ await page.getByRole("button", { name: "Try again" }).click();
+
+ await expect
+ .poll(getRequestCount, { message: "setup-status should be retried" })
+ .toBe(2);
+ await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeHidden();
+ await expect(
+ page.getByRole("button", { name: /Don't have an account/i }),
+ ).toBeVisible();
+ });
+
+ test("setup restores the administrator form after setup-status retry", async ({
+ page,
+ }) => {
+ const getRequestCount = await mockSetupStatusRecovery(page, {
+ needs_setup: true,
+ });
+
+ await page.goto("/setup");
+
+ await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeVisible();
+ await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
+ expect(getRequestCount()).toBe(1);
+
+ await page.getByRole("button", { name: "Try again" }).click();
+
+ await expect
+ .poll(getRequestCount, { message: "setup-status should be retried" })
+ .toBe(2);
+ await expect(page.getByText(SERVICE_UNAVAILABLE_TITLE)).toBeHidden();
+ await expect(
+ page.getByRole("button", { name: "Create Admin Account" }),
+ ).toBeVisible();
+ await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible();
+ });
+});
diff --git a/frontend/tests/unit/core/auth/setup.test.ts b/frontend/tests/unit/core/auth/setup.test.ts
index 2937fc2a2..acd31a8c7 100644
--- a/frontend/tests/unit/core/auth/setup.test.ts
+++ b/frontend/tests/unit/core/auth/setup.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, rs, test } from "@rstest/core";
+import { AUTH_REQUEST_TIMEOUT_MS } from "@/core/auth/constants";
import {
canCreateRegularAccount,
fetchSetupStatus,
@@ -7,8 +8,32 @@ import {
setupStatusFetchInit,
} from "@/core/auth/setup";
+function pendingUntilAborted(signal: AbortSignal): Promise {
+ return new Promise((_resolve, reject) => {
+ signal.addEventListener(
+ "abort",
+ () => {
+ reject(
+ signal.reason instanceof Error
+ ? signal.reason
+ : new Error("The request was aborted"),
+ );
+ },
+ { once: true },
+ );
+ });
+}
+
+function requestSignal(init?: RequestInit): AbortSignal {
+ if (!init?.signal) {
+ throw new Error("Expected fetch to receive an AbortSignal");
+ }
+ return init.signal;
+}
+
describe("auth setup helpers", () => {
afterEach(() => {
+ rs.useRealTimers();
rs.unstubAllGlobals();
});
@@ -20,7 +45,7 @@ describe("auth setup helpers", () => {
});
test("fetchSetupStatus uses the shared no-store request options", async () => {
- const fetchMock = rs.fn(() =>
+ const fetchMock = rs.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve(
new Response(JSON.stringify({ needs_setup: true }), {
status: 200,
@@ -33,7 +58,90 @@ describe("auth setup helpers", () => {
await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true });
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/auth/setup-status",
- setupStatusFetchInit,
+ expect.objectContaining(setupStatusFetchInit),
+ );
+ expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
+ });
+
+ test("aborts a setup-status request that remains pending", async () => {
+ rs.useFakeTimers();
+ const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) =>
+ pendingUntilAborted(requestSignal(init)),
+ );
+ rs.stubGlobal("fetch", fetchMock);
+
+ const request = fetchSetupStatus();
+ const rejection = expect(request).rejects.toMatchObject({
+ name: "AbortError",
+ });
+
+ await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
+ await rejection;
+
+ expect(fetchMock.mock.calls[0]![1]!.signal!.aborted).toBe(true);
+ });
+
+ test("clears the timeout after a successful setup-status response", async () => {
+ rs.useFakeTimers();
+ const signals: AbortSignal[] = [];
+ const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) => {
+ signals.push(requestSignal(init));
+ return Promise.resolve(
+ new Response(JSON.stringify({ needs_setup: false }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ });
+ rs.stubGlobal("fetch", fetchMock);
+
+ await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: false });
+ await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
+
+ expect(signals[0]!.aborted).toBe(false);
+ });
+
+ test("a retry uses a fresh signal after the first request times out", async () => {
+ rs.useFakeTimers();
+ const signals: AbortSignal[] = [];
+ const fetchMock = rs.fn((_input: RequestInfo | URL, init?: RequestInit) => {
+ const signal = requestSignal(init);
+ signals.push(signal);
+ if (signals.length === 1) {
+ return pendingUntilAborted(signal);
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ needs_setup: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ });
+ rs.stubGlobal("fetch", fetchMock);
+
+ const firstRequest = fetchSetupStatus();
+ const firstRejection = expect(firstRequest).rejects.toMatchObject({
+ name: "AbortError",
+ });
+ await rs.advanceTimersByTimeAsync(AUTH_REQUEST_TIMEOUT_MS);
+ await firstRejection;
+
+ await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true });
+
+ expect(signals).toHaveLength(2);
+ expect(signals[1]!).not.toBe(signals[0]!);
+ expect(signals[0]!.aborted).toBe(true);
+ expect(signals[1]!.aborted).toBe(false);
+ });
+
+ test("preserves setup-status HTTP errors", async () => {
+ rs.stubGlobal(
+ "fetch",
+ rs.fn(() => Promise.resolve(new Response(null, { status: 503 }))),
+ );
+
+ await expect(fetchSetupStatus()).rejects.toThrow(
+ "setup-status failed: 503",
);
});