fix(frontend): return to the opened workspace page after signing in (#6681)

* fix(frontend): return to the opened workspace page after signing in

Opening a workspace page without a valid session sent the user to a bare
/login: the workspace layout called redirect("/login"), and a layout
cannot read the request URL, so the path and query were both lost. A
scheduled-task notification link (/workspace/scheduled-tasks?task_id=X)
opened after the session expired therefore landed on the default
workspace after sign-in.

For an unauthenticated verdict the layout now renders
WorkspaceLoginRedirect, which replaces the page with
/login?next=<path+query> from the browser. It renders nothing else, so
the workspace pages under it never mount.

AuthProvider.refreshUser and useRequireAuth kept the path but dropped
the query. They now share currentReturnPath() with the fetcher, which
already kept both.

* docs(changelog): reference #6681

* fix(frontend): keep the fragment in the sign-in return path

currentReturnPath() kept the path and query but not location.hash, so
an anchored deep link (/workspace/chats/x#section) lost its anchor across
the login round trip. It is now the single source for all four bounce
sites, so include the fragment. The server never receives the fragment,
so only the browser-side bounce can keep it. validateAuthNextPath, the
backend's validate_next_param and its quote() into /auth/callback all
carry a '#' through unchanged.

The models API test stubbed window.location without hash; a real
Location always has one, so the stub now carries it.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-11 20:05:03 +09:00 committed by GitHub
parent ca81ace2dc
commit 3f71b2e13f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 245 additions and 15 deletions

View File

@ -4184,6 +4184,16 @@ This release closes that milestone with **439 merged pull requests**.
word order cannot be recovered. Passing both `--print` and `--json` now exits word order cannot be recovered. Passing both `--print` and `--json` now exits
2 instead of running `--print` and dropping the `--json` message. ([#6636]) 2 instead of running `--print` and dropping the `--json` message. ([#6636])
- **frontend:** Signing in after a workspace session ends now returns to the
page that was open, query string and fragment included. Opening a workspace
page without a valid session (for example a scheduled-task notification link,
`/workspace/scheduled-tasks?task_id=...`, after the session expired) sent the
user to a bare `/login`, so signing in landed on the default workspace; the
workspace layout cannot read the request URL, so it now hands the redirect to
the browser, which keeps the full address in `next`. The client-side session
checks (`AuthProvider.refreshUser`, `useRequireAuth`) kept the path but
dropped the query string, and the shared fetcher dropped the fragment; all of
them now keep the full address. ([#6681])
- **sandbox:** The AIO, E2B and OpenSandbox providers and ACP agents no longer - **sandbox:** The AIO, E2B and OpenSandbox providers and ACP agents no longer
resolve `sandbox.environment` / `acp_agents.<name>.env` values a second time. resolve `sandbox.environment` / `acp_agents.<name>.env` values a second time.
`config.yaml` loading already replaces each `$VAR`, so a resolved secret that `config.yaml` loading already replaces each `$VAR`, so a resolved secret that
@ -9605,3 +9615,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6636]: https://github.com/bytedance/deer-flow/pull/6636 [#6636]: https://github.com/bytedance/deer-flow/pull/6636
[#6666]: https://github.com/bytedance/deer-flow/pull/6666 [#6666]: https://github.com/bytedance/deer-flow/pull/6666
[#6673]: https://github.com/bytedance/deer-flow/pull/6673 [#6673]: https://github.com/bytedance/deer-flow/pull/6673
[#6681]: https://github.com/bytedance/deer-flow/pull/6681

View File

@ -3213,6 +3213,12 @@
由于词序无法还原,会以退出码 2 退出并提示加引号。同时传入 `--print` 和 `--json` 由于词序无法还原,会以退出码 2 退出并提示加引号。同时传入 `--print` 和 `--json`
现在会以退出码 2 退出,而不是运行 `--print` 并丢弃 `--json` 的消息。([#6636]) 现在会以退出码 2 退出,而不是运行 `--print` 并丢弃 `--json` 的消息。([#6636])
- **前端:** 工作区会话失效后重新登录,现在会回到原来打开的页面,并保留查询参数和锚点。
此前在没有有效会话时打开工作区页面(例如会话过期后点开定时任务通知链接
`/workspace/scheduled-tasks?task_id=...`)会跳到不带参数的 `/login`,登录后只能
回到默认工作区;工作区布局无法读取请求 URL,因此现在改由浏览器发起跳转,在
`next` 中保留完整地址。客户端会话检查(`AuthProvider.refreshUser`、
`useRequireAuth`)此前保留了路径但丢掉了查询参数,共享 fetcher 则丢掉了锚点;现在都保留完整地址。([#6681])
- **沙箱:** AIO、E2B、OpenSandbox 提供者和 ACP agent 不再对 - **沙箱:** AIO、E2B、OpenSandbox 提供者和 ACP agent 不再对
`sandbox.environment` / `acp_agents.<name>.env` 的值做第二次解析。加载 `sandbox.environment` / `acp_agents.<name>.env` 的值做第二次解析。加载
`config.yaml` 时已替换每个 `$VAR`,因此本身以 `$` 开头的已解析密钥(如 `config.yaml` 时已替换每个 `$VAR`,因此本身以 `$` 开头的已解析密钥(如
@ -7720,3 +7726,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6636]: https://github.com/bytedance/deer-flow/pull/6636 [#6636]: https://github.com/bytedance/deer-flow/pull/6636
[#6666]: https://github.com/bytedance/deer-flow/pull/6666 [#6666]: https://github.com/bytedance/deer-flow/pull/6666
[#6673]: https://github.com/bytedance/deer-flow/pull/6673 [#6673]: https://github.com/bytedance/deer-flow/pull/6673
[#6681]: https://github.com/bytedance/deer-flow/pull/6681

View File

@ -318,6 +318,10 @@ Direct auth-page visits with a live suspended-session cookie get the SSR
verdict via `AuthDisabledContext`, so the login form states the known reason verdict via `AuthDisabledContext`, so the login form states the known reason
without waiting for a deliberately generic login failure. without waiting for a deliberately generic login failure.
Other sign-in bounces keep path, query and fragment via `currentReturnPath()`. The
workspace layout cannot read the URL, so `unauthenticated` renders
`WorkspaceLoginRedirect` (never `children`), not `redirect()`.
### Model reasoning capabilities ### Model reasoning capabilities
`/api/models` projects a per-model `reasoning` contract (issue #5073) beside the `/api/models` projects a per-model `reasoning` contract (issue #5073) beside the

View File

@ -4,6 +4,7 @@ import "streamdown/styles.css";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback"; import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback";
import { WorkspaceLoginRedirect } from "@/components/workspace/workspace-login-redirect";
import { AuthProvider } from "@/core/auth/AuthProvider"; import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server"; import { getServerSideUser } from "@/core/auth/server";
import { ACCOUNT_DISABLED_LOGIN_URL, assertNever } from "@/core/auth/types"; import { ACCOUNT_DISABLED_LOGIN_URL, assertNever } from "@/core/auth/types";
@ -35,7 +36,10 @@ export default async function WorkspaceLayout({
case "system_setup_required": case "system_setup_required":
redirect("/setup"); redirect("/setup");
case "unauthenticated": case "unauthenticated":
redirect("/login"); // Not redirect(): a layout cannot read the request URL, so the browser
// carries the page it is on into `next`.
content = <WorkspaceLoginRedirect />;
break;
case "account_disabled": case "account_disabled":
// The login page renders the disabled-account reason from the error // The login page renders the disabled-account reason from the error
// param instead of a plain sign-in form the user cannot complete. // param instead of a plain sign-in form the user cannot complete.

View File

@ -0,0 +1,25 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { buildLoginUrl, currentReturnPath } from "@/core/auth/types";
/**
* Sends a signed-out visitor to the login page with this page as `next`.
*
* The workspace layout learns the session is missing on the server, but a
* layout cannot read the request URL, so a server redirect there would drop
* the page the user opened (e.g. a `?task_id=` link from a notification).
* The browser still has it. `replace` keeps this empty page out of history,
* as the server redirect did.
*/
export function WorkspaceLoginRedirect() {
const router = useRouter();
useEffect(() => {
router.replace(buildLoginUrl(currentReturnPath()));
}, [router]);
return null;
}

View File

@ -1,6 +1,7 @@
import { import {
ACCOUNT_DISABLED_LOGIN_URL, ACCOUNT_DISABLED_LOGIN_URL,
buildLoginUrl, buildLoginUrl,
currentReturnPath,
parseAuthError, parseAuthError,
} from "@/core/auth/types"; } from "@/core/auth/types";
import { isStaticWebsiteOnly } from "@/core/static-mode"; import { isStaticWebsiteOnly } from "@/core/static-mode";
@ -115,12 +116,7 @@ export async function fetch(
window.location.href = ACCOUNT_DISABLED_LOGIN_URL; window.location.href = ACCOUNT_DISABLED_LOGIN_URL;
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
// Include the search string: routes that carry their target in the query window.location.href = buildLoginUrl(currentReturnPath());
// (e.g. the standalone artifact viewer) are otherwise unrecoverable after
// login, which lands on the default workspace instead.
window.location.href = buildLoginUrl(
`${window.location.pathname}${window.location.search}`,
);
throw new UnauthorizedError(); throw new UnauthorizedError();
} }

View File

@ -16,6 +16,7 @@ import {
ACCOUNT_DISABLED_LOGIN_URL, ACCOUNT_DISABLED_LOGIN_URL,
type User, type User,
buildLoginUrl, buildLoginUrl,
currentReturnPath,
parseAuthError, parseAuthError,
} from "./types"; } from "./types";
@ -106,7 +107,7 @@ export function AuthProvider({ children, initialUser }: AuthProviderProps) {
router.push(ACCOUNT_DISABLED_LOGIN_URL); router.push(ACCOUNT_DISABLED_LOGIN_URL);
} else if (pathname?.startsWith("/workspace")) { } else if (pathname?.startsWith("/workspace")) {
// Session expired or invalid — redirect on protected routes // Session expired or invalid — redirect on protected routes
router.push(buildLoginUrl(pathname)); router.push(buildLoginUrl(currentReturnPath()));
} }
} }
} catch (err) { } catch (err) {
@ -221,7 +222,7 @@ export function useRequireAuth(): AuthContextType {
// Only redirect if we're sure user is not authenticated (not just loading) // Only redirect if we're sure user is not authenticated (not just loading)
if (!auth.isLoading && !auth.isAuthenticated) { if (!auth.isLoading && !auth.isAuthenticated) {
router.push(buildLoginUrl(pathname || "/workspace")); router.push(buildLoginUrl(currentReturnPath()));
} }
}, [auth.isAuthenticated, auth.isLoading, router, pathname]); }, [auth.isAuthenticated, auth.isLoading, router, pathname]);

View File

@ -47,6 +47,21 @@ export function buildLoginUrl(returnPath: string): string {
return `/login?next=${encodeURIComponent(returnPath)}`; return `/login?next=${encodeURIComponent(returnPath)}`;
} }
/**
* The page the browser is on, query string and fragment included, as a
* `next` target.
*
* Every client-side bounce for an expired session uses this, so they agree:
* pages that carry their target in the query (a `?task_id=` deep link, the
* artifact viewer) are otherwise unrecoverable after login, which lands on the
* default workspace instead. Browser-only; the server cannot see the URL from
* a layout, and never sees the fragment at all.
*/
export function currentReturnPath(): string {
const { pathname, search, hash } = window.location;
return `${pathname}${search}${hash}`;
}
/** /**
* Login-page URL that surfaces the account-disabled reason (#4063 gap 3). * Login-page URL that surfaces the account-disabled reason (#4063 gap 3).
* A disabled account cannot sign back in, so unlike buildLoginUrl there is * A disabled account cannot sign back in, so unlike buildLoginUrl there is

View File

@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
/**
* A workspace page opened without a session — an expired cookie, or a
* notification link opened later — must come back to the same page, query
* string included, after the user signs in. The workspace layout cannot read
* the request URL on the server, so the bounce happens in the browser.
*/
test.describe("workspace login bounce", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/v1/auth/providers", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ providers: [] }),
}),
);
await page.route("**/api/v1/auth/setup-status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ needs_setup: false }),
}),
);
});
test("keeps the deep link's query string in next", async ({ page }) => {
await page.goto("/workspace/scheduled-tasks?task_id=task-1");
await expect(page).toHaveURL(
"/login?next=%2Fworkspace%2Fscheduled-tasks%3Ftask_id%3Dtask-1",
);
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
});
test("keeps a nested workspace path in next", async ({ page }) => {
await page.goto("/workspace/chats/thread-1");
await expect(page).toHaveURL("/login?next=%2Fworkspace%2Fchats%2Fthread-1");
});
test("keeps the fragment in next", async ({ page }) => {
// The server never sees the fragment; only the browser bounce can keep it.
await page.goto("/workspace/chats/thread-1#message-3");
await expect(page).toHaveURL(
"/login?next=%2Fworkspace%2Fchats%2Fthread-1%23message-3",
);
});
test("does not mount workspace pages before the bounce", async ({ page }) => {
// The layout renders only the redirect, so the chat page under it must
// never start loading data for a visitor without a session.
const workspaceRequests: string[] = [];
page.on("request", (request) => {
const { pathname } = new URL(request.url());
if (
pathname.startsWith("/api/") &&
!pathname.startsWith("/api/v1/auth/")
) {
workspaceRequests.push(pathname);
}
});
await page.goto("/workspace/chats/thread-1");
await expect(page).toHaveURL(/\/login\?next=/);
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
expect(workspaceRequests).toEqual([]);
});
test("does not leave the bounce page in history", async ({ page }) => {
await page.goto("/login");
await page.goto("/workspace/scheduled-tasks?task_id=task-1");
await expect(page).toHaveURL(/\/login\?next=/);
await page.goBack();
await expect(page).toHaveURL("/login");
});
});

View File

@ -0,0 +1,44 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import { cleanup, render } from "@testing-library/react";
import { WorkspaceLoginRedirect } from "@/components/workspace/workspace-login-redirect";
const push = rs.fn();
const replace = rs.fn();
rs.mock("next/navigation", () => ({
useRouter: () => ({ push, replace }),
}));
afterEach(() => {
cleanup();
push.mockReset();
replace.mockReset();
});
test("sends the visitor to login with the page they opened as next", () => {
window.history.replaceState({}, "", "/workspace/scheduled-tasks?task_id=t-1");
const { container } = render(<WorkspaceLoginRedirect />);
expect(replace).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledWith(
"/login?next=%2Fworkspace%2Fscheduled-tasks%3Ftask_id%3Dt-1",
);
// A replace, so Back does not return to this empty page.
expect(push).not.toHaveBeenCalled();
expect(container.innerHTML).toBe("");
});
test("keeps the fragment, so an anchored deep link survives the round trip", () => {
window.history.replaceState(
{},
"",
"/workspace/chats/thread-1?x=1#message-3",
);
render(<WorkspaceLoginRedirect />);
expect(replace).toHaveBeenCalledWith(
"/login?next=%2Fworkspace%2Fchats%2Fthread-1%3Fx%3D1%23message-3",
);
});

View File

@ -1,8 +1,12 @@
import { afterEach, beforeEach, expect, test, rs } from "@rstest/core"; import { afterEach, beforeEach, expect, test, rs } from "@rstest/core";
import { act, cleanup, render } from "@testing-library/react"; import { act, cleanup, render, renderHook } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { AuthProvider, useAuth } from "@/core/auth/AuthProvider"; import {
AuthProvider,
useAuth,
useRequireAuth,
} from "@/core/auth/AuthProvider";
import type { User } from "@/core/auth/types"; import type { User } from "@/core/auth/types";
// refreshUser's 401 handling runs through next/navigation; capture the push // refreshUser's 401 handling runs through next/navigation; capture the push
@ -10,7 +14,7 @@ import type { User } from "@/core/auth/types";
const push = rs.fn(); const push = rs.fn();
rs.mock("next/navigation", () => ({ rs.mock("next/navigation", () => ({
useRouter: () => ({ push }), useRouter: () => ({ push }),
usePathname: () => "/workspace", usePathname: () => window.location.pathname,
})); }));
const initialUser: User = { const initialUser: User = {
@ -87,3 +91,36 @@ test("every other 401 keeps the return-to-workspace redirect", async () => {
expect(ctx!.user).toBeNull(); expect(ctx!.user).toBeNull();
expect(push).toHaveBeenCalledWith("/login?next=%2Fworkspace"); expect(push).toHaveBeenCalledWith("/login?next=%2Fworkspace");
}); });
test("a 401 returns to the page it happened on, query string included", async () => {
window.history.replaceState({}, "", "/workspace/scheduled-tasks?task_id=t-1");
mount();
globalThis.fetch = rs.fn(async () =>
jsonResponse(
{ detail: { code: "token_expired", message: "Token expired" } },
401,
),
) as unknown as typeof globalThis.fetch;
await act(async () => {
await ctx!.refreshUser();
});
expect(push).toHaveBeenCalledWith(
"/login?next=%2Fworkspace%2Fscheduled-tasks%3Ftask_id%3Dt-1",
);
});
test("useRequireAuth returns to the page it guards, query string included", () => {
window.history.replaceState({}, "", "/workspace/scheduled-tasks?task_id=t-1");
renderHook(() => useRequireAuth(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<AuthProvider initialUser={null}>{children}</AuthProvider>
),
});
expect(push).toHaveBeenCalledWith(
"/login?next=%2Fworkspace%2Fscheduled-tasks%3Ftask_id%3Dt-1",
);
});

View File

@ -56,9 +56,14 @@ test("loadModels rejects unsuccessful gateway responses", async () => {
}); });
test("loadModels exposes the typed 401 redirect error", async () => { test("loadModels exposes the typed 401 redirect error", async () => {
// `search` is always a string on a real Location; the redirect target is // `search` and `hash` are always strings on a real Location; the redirect
// built from pathname + search so the stub has to carry both. // target is built from pathname + search + hash so the stub carries all three.
const location = { href: "", pathname: "/workspace/chats", search: "" }; const location = {
href: "",
pathname: "/workspace/chats",
search: "",
hash: "",
};
rs.stubGlobal("window", { location }); rs.stubGlobal("window", { location });
rs.stubGlobal( rs.stubGlobal(
"fetch", "fetch",