mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
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
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:
parent
22290c1616
commit
9654ba2c13
9 changed files with 223 additions and 29 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
34
frontend/src/core/auth/setup.ts
Normal file
34
frontend/src/core/auth/setup.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -545,6 +545,10 @@ export const zhCN: Translations = {
|
|||
pleaseWait: "请稍候...",
|
||||
signIn: "登录",
|
||||
createAccount: "创建账号",
|
||||
createAdminAccount: "创建管理员账号",
|
||||
adminSetupRequiredTitle: "需要先完成管理员初始化",
|
||||
adminSetupRequiredDescription:
|
||||
"DeerFlow 需要先创建管理员账号,然后才能创建新的普通账号。",
|
||||
orContinueWith: "或使用以下方式登录",
|
||||
ssoHint: "如果你的账号使用单点登录(SSO),请改用下方的选项登录。",
|
||||
continueWith: (provider: string) => `使用 ${provider} 登录`,
|
||||
|
|
|
|||
78
frontend/tests/unit/core/auth/setup.test.ts
Normal file
78
frontend/tests/unit/core/auth/setup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue