From 9654ba2c13e97aadd14af5af2bb662fd885fae2a Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:10:44 +0800 Subject: [PATCH] fix auth setup redirects (#3844) --- backend/app/gateway/routers/auth.py | 7 +- backend/tests/test_initialize_admin.py | 17 +++- frontend/src/app/(auth)/login/page.tsx | 88 +++++++++++++++------ frontend/src/app/(auth)/setup/page.tsx | 17 ++-- frontend/src/core/auth/setup.ts | 34 ++++++++ frontend/src/core/i18n/locales/en-US.ts | 4 + frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 4 + frontend/tests/unit/core/auth/setup.test.ts | 78 ++++++++++++++++++ 9 files changed, 223 insertions(+), 29 deletions(-) create mode 100644 frontend/src/core/auth/setup.ts create mode 100644 frontend/tests/unit/core/auth/setup.test.ts diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index 9c5fdf18c..a456fd7e7 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -513,7 +513,12 @@ async def initialize_admin(request: Request, response: Response, body: Initializ try: user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False) except ValueError: - # DB unique-constraint race: another concurrent request beat us. + admin_count = await get_local_provider().count_admin_users() + if admin_count == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already registered").model_dump(), + ) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(), diff --git a/backend/tests/test_initialize_admin.py b/backend/tests/test_initialize_admin.py index 514ee6df3..841dfc88c 100644 --- a/backend/tests/test_initialize_admin.py +++ b/backend/tests/test_initialize_admin.py @@ -110,6 +110,21 @@ def test_initialize_register_does_not_block_initialization(client): assert resp.json()["system_role"] == "admin" +def test_initialize_existing_regular_user_email_reports_email_conflict(client): + """With no admin, reusing a regular user's email is an email conflict, not initialized.""" + client.post("/api/v1/auth/register", json={"email": "regular@example.com", "password": "Tr0ub4dor3a"}) + + resp = client.post( + "/api/v1/auth/initialize", + json={**_init_payload(), "email": "regular@example.com"}, + ) + + assert resp.status_code == 400 + body = resp.json() + assert body["detail"]["code"] == "email_already_exists" + assert client.get("/api/v1/auth/setup-status").json()["needs_setup"] is True + + # ── Endpoint is public (no cookie required) ─────────────────────────────── @@ -162,7 +177,7 @@ def test_setup_status_after_initialization(client): assert resp.json()["needs_setup"] is False -def test_setup_status_false_when_only_regular_user_exists(client): +def test_setup_status_true_when_only_regular_user_exists(client): """setup-status returns needs_setup=True even when regular users exist (no admin).""" client.post("/api/v1/auth/register", json={"email": "regular@example.com", "password": "Tr0ub4dor3a"}) resp = client.get("/api/v1/auth/setup-status") diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 7971331b4..3d6c63e55 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -9,6 +9,11 @@ import { Button } from "@/components/ui/button"; import { FlickeringGrid } from "@/components/ui/flickering-grid"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/core/auth/AuthProvider"; +import { + canCreateRegularAccount, + fetchSetupStatus, + type SetupStatusResponse, +} from "@/core/auth/setup"; import { parseAuthError } from "@/core/auth/types"; import { useI18n } from "@/core/i18n/hooks"; @@ -58,6 +63,10 @@ export default function LoginPage() { const [ssoProviders, setSsoProviders] = useState< { id: string; display_name: string; type: string }[] >([]); + const [setupStatus, setSetupStatus] = useState( + null, + ); + const [setupStatusChecked, setSetupStatusChecked] = useState(false); // Extract error from query params (e.g., ?error=sso_failed) const errorParam = searchParams.get("error"); @@ -77,6 +86,11 @@ export default function LoginPage() { // Get next parameter for validated redirect const nextParam = searchParams.get("next"); const redirectPath = validateNextParam(nextParam) ?? "/workspace"; + const regularSignupAllowed = canCreateRegularAccount({ + checked: setupStatusChecked, + status: setupStatus, + }); + const systemNeedsAdminSetup = setupStatus?.needs_setup === true; // Redirect if already authenticated (client-side, post-login) useEffect(() => { @@ -85,22 +99,29 @@ export default function LoginPage() { } }, [isAuthenticated, redirectPath, router]); - // Redirect to setup if the system has no users yet + // Fetch setup state and SSO providers useEffect(() => { let cancelled = false; - void fetch("/api/v1/auth/setup-status") - .then((r) => r.json()) - .then((data: { needs_setup?: boolean }) => { - if (!cancelled && data.needs_setup) { - router.push("/setup"); + void fetchSetupStatus() + .then((data) => { + if (cancelled) return; + setSetupStatus(data); + if (data.needs_setup) { + setIsLogin(true); } }) .catch(() => { - // Ignore errors; user stays on login page + if (!cancelled) { + setSetupStatus(null); + } + }) + .finally(() => { + if (!cancelled) { + setSetupStatusChecked(true); + } }); - // Fetch SSO providers void fetch("/api/v1/auth/providers") .then((r) => r.json()) .then( @@ -119,7 +140,7 @@ export default function LoginPage() { return () => { cancelled = true; }; - }, [router]); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -127,6 +148,12 @@ export default function LoginPage() { setShowSsoHint(false); setLoading(true); + if (!isLogin && !regularSignupAllowed) { + setError(t.login.adminSetupRequiredDescription); + setLoading(false); + return; + } + try { const endpoint = isLogin ? "/api/v1/auth/login/local" @@ -187,6 +214,21 @@ export default function LoginPage() {

+ {systemNeedsAdminSetup && ( +
+

{t.login.adminSetupRequiredTitle}

+

+ {t.login.adminSetupRequiredDescription} +

+ + {t.login.createAdminAccount} + +
+ )} +
)} -
- -
+ {regularSignupAllowed && ( +
+ +
+ )}
diff --git a/frontend/src/app/(auth)/setup/page.tsx b/frontend/src/app/(auth)/setup/page.tsx index 4f1d21eae..71f16cb2e 100644 --- a/frontend/src/app/(auth)/setup/page.tsx +++ b/frontend/src/app/(auth)/setup/page.tsx @@ -9,6 +9,10 @@ import { FlickeringGrid } from "@/components/ui/flickering-grid"; import { Input } from "@/components/ui/input"; import { getCsrfHeaders } from "@/core/api/fetcher"; import { useAuth } from "@/core/auth/AuthProvider"; +import { + fetchSetupStatus, + isSystemAlreadyInitializedError, +} from "@/core/auth/setup"; import { parseAuthError } from "@/core/auth/types"; type SetupMode = "loading" | "init_admin" | "change_password"; @@ -36,23 +40,22 @@ export default function SetupPage() { setMode("change_password"); } else if (!isAuthenticated) { // Check if the system has no users yet - void fetch("/api/v1/auth/setup-status") - .then((r) => r.json()) + void fetchSetupStatus() .then((data: { needs_setup?: boolean }) => { if (cancelled) return; if (data.needs_setup) { setMode("init_admin"); } else { // System already set up and user is not logged in — go to login - router.push("/login"); + router.replace("/login"); } }) .catch(() => { - if (!cancelled) router.push("/login"); + if (!cancelled) router.replace("/login"); }); } else { // Authenticated but needs_setup is false — already set up - router.push("/workspace"); + router.replace("/workspace"); } return () => { @@ -84,6 +87,10 @@ export default function SetupPage() { if (!res.ok) { const data = await res.json(); + if (isSystemAlreadyInitializedError(data)) { + router.replace("/login"); + return; + } const authError = parseAuthError(data); setError(authError.message); return; diff --git a/frontend/src/core/auth/setup.ts b/frontend/src/core/auth/setup.ts new file mode 100644 index 000000000..aafa13a47 --- /dev/null +++ b/frontend/src/core/auth/setup.ts @@ -0,0 +1,34 @@ +import { parseAuthError } from "./types"; + +export type SetupStatusResponse = { + needs_setup?: boolean; +}; + +export type SetupStatusCheck = { + checked: boolean; + status: SetupStatusResponse | null; +}; + +export const setupStatusFetchInit = { + cache: "no-store", + credentials: "include", +} 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}`); + } + return (await response.json()) as SetupStatusResponse; +} + +export function isSystemAlreadyInitializedError(data: unknown): boolean { + return parseAuthError(data).code === "system_already_initialized"; +} + +export function canCreateRegularAccount(check: SetupStatusCheck): boolean { + return check.checked && check.status?.needs_setup !== true; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index a6fc0e79a..3628243d4 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -567,6 +567,10 @@ export const enUS: Translations = { pleaseWait: "Please wait...", signIn: "Sign In", createAccount: "Create Account", + createAdminAccount: "Create admin account", + adminSetupRequiredTitle: "Administrator setup is required", + adminSetupRequiredDescription: + "DeerFlow needs an administrator account before new regular accounts can be created.", orContinueWith: "Or continue with", ssoHint: "If your account uses single sign-on, sign in with the option below instead.", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 62ab73405..61ff46bc7 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -472,6 +472,9 @@ export interface Translations { pleaseWait: string; signIn: string; createAccount: string; + createAdminAccount: string; + adminSetupRequiredTitle: string; + adminSetupRequiredDescription: string; orContinueWith: string; ssoHint: string; continueWith: (provider: string) => string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 879f13c08..d1845349e 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -545,6 +545,10 @@ export const zhCN: Translations = { pleaseWait: "请稍候...", signIn: "登录", createAccount: "创建账号", + createAdminAccount: "创建管理员账号", + adminSetupRequiredTitle: "需要先完成管理员初始化", + adminSetupRequiredDescription: + "DeerFlow 需要先创建管理员账号,然后才能创建新的普通账号。", orContinueWith: "或使用以下方式登录", ssoHint: "如果你的账号使用单点登录(SSO),请改用下方的选项登录。", continueWith: (provider: string) => `使用 ${provider} 登录`, diff --git a/frontend/tests/unit/core/auth/setup.test.ts b/frontend/tests/unit/core/auth/setup.test.ts new file mode 100644 index 000000000..5b0607b47 --- /dev/null +++ b/frontend/tests/unit/core/auth/setup.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, rs, test } from "@rstest/core"; + +import { + canCreateRegularAccount, + fetchSetupStatus, + isSystemAlreadyInitializedError, + setupStatusFetchInit, +} from "@/core/auth/setup"; + +describe("auth setup helpers", () => { + afterEach(() => { + rs.unstubAllGlobals(); + }); + + test("setup-status requests bypass browser caches", () => { + expect(setupStatusFetchInit).toMatchObject({ + cache: "no-store", + credentials: "include", + }); + }); + + test("fetchSetupStatus uses the shared no-store request options", async () => { + const fetchMock = rs.fn(() => + Promise.resolve( + new Response(JSON.stringify({ needs_setup: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + rs.stubGlobal("fetch", fetchMock); + + await expect(fetchSetupStatus()).resolves.toEqual({ needs_setup: true }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/auth/setup-status", + setupStatusFetchInit, + ); + }); + + test("regular sign-up is disabled only while setup is required or unknown", () => { + expect(canCreateRegularAccount({ checked: false, status: null })).toBe( + false, + ); + expect( + canCreateRegularAccount({ + checked: true, + status: { needs_setup: true }, + }), + ).toBe(false); + expect( + canCreateRegularAccount({ + checked: true, + status: { needs_setup: false }, + }), + ).toBe(true); + expect(canCreateRegularAccount({ checked: true, status: null })).toBe(true); + }); + + test("detects already-initialized setup conflicts", () => { + expect( + isSystemAlreadyInitializedError({ + detail: { + code: "system_already_initialized", + message: "System already initialized", + }, + }), + ).toBe(true); + + expect( + isSystemAlreadyInitializedError({ + detail: { + code: "invalid_credentials", + message: "Wrong password", + }, + }), + ).toBe(false); + }); +});