fix auth setup redirects (#3844)
Some checks are pending
Backend Blocking IO / backend-blocking-io (push) Waiting to run
Unit Tests / backend-unit-tests (push) Waiting to run
E2E Tests / e2e-tests (push) Waiting to run
Frontend Unit Tests / frontend-unit-tests (push) Waiting to run
Lint Check / lint-backend (push) Waiting to run
Lint Check / lint-frontend (push) Waiting to run
Replay E2E (front-back contract) / Layer 1 — backend golden (no API key) (push) Waiting to run
Replay E2E (front-back contract) / Layer 2 — full-stack render (no API key) (push) Waiting to run

This commit is contained in:
DanielWalnut 2026-06-28 16:10:44 +08:00 committed by GitHub
parent 22290c1616
commit 9654ba2c13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 223 additions and 29 deletions

View file

@ -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(),

View file

@ -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")

View file

@ -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<SetupStatusResponse | null>(
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() {
</p>
</div>
{systemNeedsAdminSetup && (
<div className="border-l-2 border-blue-500 ps-3 text-sm">
<p className="font-medium">{t.login.adminSetupRequiredTitle}</p>
<p className="text-muted-foreground mt-1">
{t.login.adminSetupRequiredDescription}
</p>
<Link
href="/setup"
className="mt-2 inline-block font-medium text-blue-500 hover:underline"
>
{t.login.createAdminAccount}
</Link>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-2">
<div className="flex flex-col space-y-1">
<label htmlFor="email" className="text-sm font-medium">
@ -263,19 +305,21 @@ export default function LoginPage() {
</div>
)}
<div className="text-center text-sm">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError("");
setShowSsoHint(false);
}}
className="text-blue-500 hover:underline"
>
{isLogin ? t.login.noAccountSignUp : t.login.haveAccountSignIn}
</button>
</div>
{regularSignupAllowed && (
<div className="text-center text-sm">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError("");
setShowSsoHint(false);
}}
className="text-blue-500 hover:underline"
>
{isLogin ? t.login.noAccountSignUp : t.login.haveAccountSignIn}
</button>
</div>
)}
<div className="text-muted-foreground text-center text-xs">
<Link href="/" className="hover:underline">

View file

@ -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;

View file

@ -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<SetupStatusResponse> {
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;
}

View file

@ -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.",

View file

@ -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;

View file

@ -545,6 +545,10 @@ export const zhCN: Translations = {
pleaseWait: "请稍候...",
signIn: "登录",
createAccount: "创建账号",
createAdminAccount: "创建管理员账号",
adminSetupRequiredTitle: "需要先完成管理员初始化",
adminSetupRequiredDescription:
"DeerFlow 需要先创建管理员账号,然后才能创建新的普通账号。",
orContinueWith: "或使用以下方式登录",
ssoHint: "如果你的账号使用单点登录SSO请改用下方的选项登录。",
continueWith: (provider: string) => `使用 ${provider} 登录`,

View file

@ -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);
});
});