From ddaa87b97d1cef5ef4214edff57e8a05a6836b4e Mon Sep 17 00:00:00 2001 From: Ramon Nogueira Date: Wed, 1 Jul 2026 23:51:06 -0400 Subject: [PATCH] fix: preserve dashboard redirect after login (#1668) * fix: preserve dashboard redirect after login Co-authored-by: open-swe[bot] * test: cover plan login redirect in e2e Co-authored-by: open-swe[bot] --------- Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] (cherry picked from commit bc7ce59169b5350da7286164afb83a7b037b528d) --- tests/e2e/README.md | 22 ++-- tests/e2e/harness.py | 48 ++++--- tests/e2e/tests/plan_review.spec.ts | 89 ++++++++++--- tests/test_dashboard_oauth_redirect.py | 30 +++++ ui/src/lib/api.ts | 2 +- ui/src/lib/auth-redirect-core.ts | 120 ++++++++++++++++++ ui/src/lib/auth-redirect.test.ts | 76 +++++++++++ ui/src/lib/auth-redirect.tsx | 13 ++ ui/src/routes/admin.tsx | 3 +- ui/src/routes/admin_.evals.tsx | 3 +- ui/src/routes/agents.tsx | 10 +- ui/src/routes/agents/$threadId_.plan.tsx | 16 +++ .../agents/reviews/$owner.$repo.$number.tsx | 5 +- ui/src/routes/agents_.instructions.tsx | 5 +- ui/src/routes/agents_.snapshots.tsx | 3 +- ui/src/routes/cloud-agents.tsx | 5 +- ui/src/routes/login.tsx | 53 +++++++- ui/src/routes/my-settings.tsx | 5 +- ui/src/routes/review.tsx | 5 +- ui/src/routes/review_.repositories.$owner.tsx | 5 +- ui/src/routes/review_.styles.tsx | 5 +- ui/src/routes/usage.tsx | 5 +- 22 files changed, 452 insertions(+), 76 deletions(-) create mode 100644 tests/test_dashboard_oauth_redirect.py create mode 100644 ui/src/lib/auth-redirect-core.ts create mode 100644 ui/src/lib/auth-redirect.test.ts create mode 100644 ui/src/lib/auth-redirect.tsx diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 990ef3cd..1469b129 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -12,17 +12,17 @@ This drives the **whole happy path** through two mock UIs: Only the **LLM** and the **external SaaS HTTP boundaries** are faked. All agent code runs for real. -| Piece | Real or fake | -|---|---| -| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.webapp`) | -| `get_agent`, deepagents loop, tools, middleware, prompt | **real** | -| `open_pull_request`, `slack_thread_reply` tools | **real** | -| Sandbox | **real** `local` provider, rooted in a throwaway temp dir | -| Git remote ("GitHub") | **real git**, a local bare repo the agent clones/pushes | -| The LLM | **fake** — a scripted model (`fake_llm.py`) emitting a fixed tool sequence | -| `api.github.com` REST (PR create) | **fake** (`/fake-gh/...`), state rendered at `/mock/github` | -| `slack.com/api` (post message, etc.) | **fake** (`/fake-slack/...`), thread rendered at `/mock/slack` | -| GitHub App token mint, `api.github.com/user` identity | stubbed (offline) | +| Piece | Real or fake | +| ---------------------------------------------------------------- | -------------------------------------------------------------------------- | +| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.webapp`) | +| `get_agent`, deepagents loop, tools, middleware, prompt | **real** | +| `open_pull_request`, `slack_thread_reply` tools | **real** | +| Sandbox | **real** `local` provider, rooted in a throwaway temp dir | +| Git remote ("GitHub") | **real git**, a local bare repo the agent clones/pushes | +| The LLM | **fake** — a scripted model (`fake_llm.py`) emitting a fixed tool sequence | +| `api.github.com` REST (PR create) + dashboard GitHub OAuth login | **fake** (`/fake-gh/...`), state rendered at `/mock/github` | +| `slack.com/api` (post message, etc.) | **fake** (`/fake-slack/...`), thread rendered at `/mock/slack` | +| GitHub App token mint, `api.github.com/user` identity | stubbed (offline) | The fake GitHub/Slack stores are the single source of truth the mock UIs render, so what Playwright asserts on is exactly what the real agent produced. diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index 1147300b..f774d546 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -18,8 +18,10 @@ import json import os import sys import time +from html import escape from pathlib import Path from typing import Any +from urllib.parse import quote sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -185,29 +187,43 @@ async def control_login_get(login: str = "", email: str = "", next_url: str = "" @app.get("/dashboard/api/auth/login") -async def mock_github_login(redirect_to: str = "", login: str = "") -> Response: - """Mock stand-in for GitHub OAuth: the dashboard's "Continue with GitHub" - button lands here. With no ``login``, render a picker of the fake GitHub - test users; once one is chosen, mint the real session cookie and redirect - back into the dashboard (``redirect_to``).""" +async def mock_github_login(redirect_to: str = "") -> Response: + """E2E stand-in for the dashboard OAuth start route. + + The real route would redirect to github.com. Keep the dashboard-facing URL + intact, then hand off to the fake GitHub simulator so Playwright exercises a + browser login flow instead of test code pre-minting a session cookie. + """ + ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/") + dest = redirect_to or (f"{ui}/agents" if ui else "/agents") + return RedirectResponse(f"/fake-gh/login/oauth/authorize?redirect_to={quote(dest)}", 302) + + +@app.get("/fake-gh/login/oauth/authorize") +async def fake_github_authorize(redirect_to: str = "", login: str = "") -> Response: + """Fake GitHub OAuth consent/login page for dashboard e2e tests.""" ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/") dest = redirect_to or (f"{ui}/agents" if ui else "/agents") if not login: options = "".join( - f'' for u in TEST_USERS + f'" + for u in TEST_USERS ) return HTMLResponse( - f"""Continue with GitHub (mock) + f"""GitHub · Authorize open-swe -

Continue with GitHub (mock)

-

Pick a fake GitHub account to sign in as.

-
- - - -
-

Tip: use a separate browser or profile per - user so their sessions don't overwrite each other.

+
+

Authorize open-swe

+

Pick a fake GitHub account to continue.

+
+ + + +
+
""" ) match = next((u for u in TEST_USERS if u["login"] == login), None) diff --git a/tests/e2e/tests/plan_review.spec.ts b/tests/e2e/tests/plan_review.spec.ts index 37d19ffa..e7f92199 100644 --- a/tests/e2e/tests/plan_review.spec.ts +++ b/tests/e2e/tests/plan_review.spec.ts @@ -38,9 +38,14 @@ test.describe("Plan review (HTTP comments)", () => { // 1. A user asks the bot to PLAN something in Slack. await request.post("/control/reset"); const send = await request.post("/mock/slack/send", { - data: { text: "<@U0BOT> plan how to add a greet() helper", mention_bot: true }, + data: { + text: "<@U0BOT> plan how to add a greet() helper", + mention_bot: true, + }, }); - const { thread_id: threadId } = (await send.json()) as { thread_id: string }; + const { thread_id: threadId } = (await send.json()) as { + thread_id: string; + }; expect(threadId).toBeTruthy(); const planPath = `/agents/${threadId}/plan`; @@ -57,7 +62,9 @@ test.describe("Plan review (HTTP comments)", () => { }; return (state.values?.messages ?? []) .map((m) => - typeof m.content === "string" ? m.content : JSON.stringify(m.content), + typeof m.content === "string" + ? m.content + : JSON.stringify(m.content), ) .some((c) => c.includes("Plan mode is active")); }, @@ -67,13 +74,45 @@ test.describe("Plan review (HTTP comments)", () => { // 2. The agent shares the plan-review link, then announces the plan is ready. await expect - .poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 }) + .poll(async () => (await botMessages(request)).join("\n"), { + timeout: 60_000, + }) .toMatch(/\/agents\/[^/]+\/plan\b/); await expect - .poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 }) + .poll(async () => (await botMessages(request)).join("\n"), { + timeout: 60_000, + }) .toMatch(/ready for review/i); - // 3. The OWNER opens the conversation, follows the "Review plan" banner, and + // 3. A logged-out user follows the plan deep link, signs in through the fake + // GitHub OAuth simulator, and lands back on the same plan page. + const loggedOutCtx = await browser.newContext(); + const loggedOut = await loggedOutCtx.newPage(); + await loggedOut.goto(planPath); + await expect(loggedOut).toHaveURL( + new RegExp(`/login\\?redirect=.*${threadId}.*plan`), + ); + await expect(loggedOut.getByText("Sign in to open-swe")).toBeVisible({ + timeout: 30_000, + }); + await loggedOut.getByRole("link", { name: "Continue with GitHub" }).click(); + await expect(loggedOut).toHaveURL(/\/fake-gh\/login\/oauth\/authorize/); + await expect(loggedOut.getByTestId("fake-github-login")).toBeVisible(); + await loggedOut.getByLabel("GitHub user").selectOption(OWNER.login); + await loggedOut.getByRole("button", { name: "Authorize open-swe" }).click(); + await expect(loggedOut).toHaveURL(new RegExp(`/agents/${threadId}/plan$`)); + await expect(loggedOut.getByTestId("plan-review")).toBeVisible({ + timeout: 30_000, + }); + await expect(loggedOut.getByTestId("plan-document")).toContainText( + "greet", + { + timeout: 30_000, + }, + ); + await loggedOutCtx.close(); + + // 4. The OWNER opens the conversation, follows the "Review plan" banner, and // sees the rendered plan. const ownerCtx = await browser.newContext({ permissions: ["clipboard-read", "clipboard-write"], @@ -85,7 +124,9 @@ test.describe("Plan review (HTTP comments)", () => { await expect(reviewLink).toBeVisible({ timeout: 30_000 }); await reviewLink.click(); await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}/plan$`)); - await expect(owner.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 }); + await expect(owner.getByTestId("plan-review")).toBeVisible({ + timeout: 30_000, + }); await expect(owner.getByText("Back to conversation")).toBeVisible(); await expect(owner.getByTestId("plan-document")).toContainText("greet", { timeout: 30_000, @@ -98,7 +139,9 @@ test.describe("Plan review (HTTP comments)", () => { // Copy the whole plan as markdown. await owner.getByTestId("copy-plan").click(); await expect(owner.getByTestId("copy-plan")).toContainText("Copied!"); - const clipboard = await owner.evaluate(() => navigator.clipboard.readText()); + const clipboard = await owner.evaluate(() => + navigator.clipboard.readText(), + ); expect(clipboard).toContain("## Plan: Add greet() helper"); expect(clipboard).toContain("### Verification"); @@ -107,18 +150,24 @@ test.describe("Plan review (HTTP comments)", () => { await expect(owner.getByTestId("plan-comment")).toHaveCount(1); await expect(owner.getByTestId("reject-plan")).toBeEnabled(); - // 4. A COLLABORATOR opens the same plan: sees it AND the owner's comment + // 5. A COLLABORATOR opens the same plan: sees it AND the owner's comment // (fetched over HTTP), but has NO approve button. const collabCtx = await browser.newContext(); await collabCtx.request.post("/control/login", { data: COLLABORATOR }); const collab = await collabCtx.newPage(); await collab.goto(planPath); - await expect(collab.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 }); + await expect(collab.getByTestId("plan-review")).toBeVisible({ + timeout: 30_000, + }); await expect(collab.getByTestId("plan-document")).toContainText("greet", { timeout: 30_000, }); - await expect(collab.getByTestId("plan-comment")).toHaveCount(1, { timeout: 30_000 }); - await expect(collab.getByTestId("plan-comment")).toContainText("looks solid"); + await expect(collab.getByTestId("plan-comment")).toHaveCount(1, { + timeout: 30_000, + }); + await expect(collab.getByTestId("plan-comment")).toContainText( + "looks solid", + ); await expect(collab.getByTestId("approve-plan")).toHaveCount(0); await expect(collab.getByTestId("reject-plan")).toBeVisible(); @@ -126,21 +175,27 @@ test.describe("Plan review (HTTP comments)", () => { await addComment(collab, "Reviewer: please also add a docstring."); await expect(collab.getByTestId("plan-comment")).toHaveCount(2); - // 5. The owner sees the collaborator's comment (polled), then approves and + // 6. The owner sees the collaborator's comment (polled), then approves and // returns to the main conversation while implementation starts. - await expect(owner.getByTestId("plan-comment")).toHaveCount(2, { timeout: 30_000 }); + await expect(owner.getByTestId("plan-comment")).toHaveCount(2, { + timeout: 30_000, + }); await owner.getByTestId("approve-plan").click(); await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}$`)); - // 6. The agent implements, opens a PR, and links it back in the Slack thread, + // 7. The agent implements, opens a PR, and links it back in the Slack thread, // echoing the reviewers' feedback — which proves the comments were stored // and harvested server-side on approve. await expect - .poll(async () => (await botMessages(request)).join("\n"), { timeout: 90_000 }) + .poll(async () => (await botMessages(request)).join("\n"), { + timeout: 90_000, + }) .toMatch(/\/pull\//); expect((await botMessages(request)).join("\n")).toMatch(/docstring/); - const prs = (await (await request.get("/mock/github/data")).json()) as Array; + const prs = (await ( + await request.get("/mock/github/data") + ).json()) as Array; expect(prs.length).toBeGreaterThan(0); await ownerCtx.close(); diff --git a/tests/test_dashboard_oauth_redirect.py b/tests/test_dashboard_oauth_redirect.py new file mode 100644 index 00000000..c19287ec --- /dev/null +++ b/tests/test_dashboard_oauth_redirect.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from agent.dashboard.oauth import sanitize_redirect_to + + +def test_sanitize_redirect_to_preserves_allowed_dashboard_target(monkeypatch) -> None: + monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example") + monkeypatch.setenv("DASHBOARD_ALLOWED_ORIGINS", "https://preview.example") + + target = "https://dashboard.example/agents/thread-1/plan?from=slack#review" + + assert sanitize_redirect_to(target) == target + + +def test_sanitize_redirect_to_preserves_allowed_preview_target(monkeypatch) -> None: + monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example") + monkeypatch.setenv("DASHBOARD_ALLOWED_ORIGINS", "https://preview.example") + + target = "https://preview.example/agents/thread-1/plan?from=slack#review" + + assert sanitize_redirect_to(target) == target + + +def test_sanitize_redirect_to_rejects_external_target(monkeypatch) -> None: + monkeypatch.setenv("DASHBOARD_BASE_URL", "https://dashboard.example") + monkeypatch.setenv("DASHBOARD_ALLOWED_ORIGINS", "https://preview.example") + + assert sanitize_redirect_to("https://evil.example/agents/thread-1/plan") == ( + "https://dashboard.example" + ) diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 6d828464..e9fd7705 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -823,7 +823,7 @@ export const api = { export function loginUrl(redirectTo?: string): string { const target = - redirectTo ?? (typeof window !== "undefined" ? window.location.origin : "") + redirectTo ?? (typeof window !== "undefined" ? window.location.href : "") const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : "" return `${API_BASE}/dashboard/api/auth/login${qs}` } diff --git a/ui/src/lib/auth-redirect-core.ts b/ui/src/lib/auth-redirect-core.ts new file mode 100644 index 00000000..91cc0e99 --- /dev/null +++ b/ui/src/lib/auth-redirect-core.ts @@ -0,0 +1,120 @@ +export const DEFAULT_AUTH_REDIRECT = "/agents" +export const AUTH_REDIRECT_STORAGE_KEY = "open-swe-auth-redirect" + +type LocationParts = { + pathname: string + search?: string + hash?: string +} + +function browserOrigin(): string | null { + return typeof window === "undefined" ? null : window.location.origin +} + +function storage(): Storage | null { + if (typeof window === "undefined") return null + try { + return window.sessionStorage + } catch { + return null + } +} + +function isBlockedRedirectPath(path: string): boolean { + return /^(?:\/login|\/dashboard\/api|\/_serverFn)(?:[/?#]|$)/.test(path) +} + +export function sanitizeAuthRedirect( + candidate: unknown, + fallback = DEFAULT_AUTH_REDIRECT +): string { + if (typeof candidate !== "string") return fallback + const trimmed = candidate.trim() + if (!trimmed) return fallback + + const origin = browserOrigin() + const isProtocolRelative = trimmed.startsWith("//") + const hasScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed) + if (isProtocolRelative) return fallback + if (hasScheme && !origin) return fallback + + let parsed: URL + try { + parsed = new URL(trimmed, origin ?? "https://open-swe.invalid") + } catch { + return fallback + } + + if ((hasScheme || origin) && origin && parsed.origin !== origin) { + return fallback + } + + const path = `${parsed.pathname}${parsed.search}${parsed.hash}` + if (!path.startsWith("/") || isBlockedRedirectPath(path)) return fallback + return path +} + +export function authRedirectPathFromLocation(location: LocationParts): string { + const hash = location.hash + ? location.hash.startsWith("#") + ? location.hash + : `#${location.hash}` + : "" + return sanitizeAuthRedirect( + `${location.pathname}${location.search ?? ""}${hash}` + ) +} + +export function currentAuthRedirectPath(): string { + if (typeof window === "undefined") return DEFAULT_AUTH_REDIRECT + return authRedirectPathFromLocation(window.location) +} + +export function rememberAuthRedirect(candidate: unknown): string { + const path = sanitizeAuthRedirect(candidate) + const s = storage() + if (s) { + try { + s.setItem(AUTH_REDIRECT_STORAGE_KEY, path) + } catch {} + } + return path +} + +export function getRememberedAuthRedirect(): string | null { + const s = storage() + if (!s) return null + let raw: string | null = null + try { + raw = s.getItem(AUTH_REDIRECT_STORAGE_KEY) + } catch { + return null + } + if (!raw) return null + const path = sanitizeAuthRedirect(raw, "") + if (path) return path + clearRememberedAuthRedirect() + return null +} + +export function clearRememberedAuthRedirect(): void { + const s = storage() + if (!s) return + try { + s.removeItem(AUTH_REDIRECT_STORAGE_KEY) + } catch {} +} + +export function consumeAuthRedirect(candidate?: unknown): string { + const explicit = sanitizeAuthRedirect(candidate, "") + const path = explicit || getRememberedAuthRedirect() || DEFAULT_AUTH_REDIRECT + clearRememberedAuthRedirect() + return path +} + +export function authRedirectUrl(candidate?: unknown): string { + const path = sanitizeAuthRedirect(candidate) + const origin = browserOrigin() + if (!origin) return path + return new URL(path, origin).toString() +} diff --git a/ui/src/lib/auth-redirect.test.ts b/ui/src/lib/auth-redirect.test.ts new file mode 100644 index 00000000..eacc5063 --- /dev/null +++ b/ui/src/lib/auth-redirect.test.ts @@ -0,0 +1,76 @@ +/** @vitest-environment jsdom */ + +import { beforeEach, describe, expect, it } from "vitest" + +import { loginUrl } from "./api" +import { + AUTH_REDIRECT_STORAGE_KEY, + DEFAULT_AUTH_REDIRECT, + authRedirectPathFromLocation, + authRedirectUrl, + consumeAuthRedirect, + currentAuthRedirectPath, + getRememberedAuthRedirect, + rememberAuthRedirect, + sanitizeAuthRedirect, +} from "./auth-redirect-core" + +beforeEach(() => { + window.sessionStorage.clear() + window.history.pushState({}, "", "/") +}) + +describe("auth redirect helpers", () => { + it("captures protected route targets as relative paths", () => { + const path = authRedirectPathFromLocation({ + pathname: "/agents/thread-1/plan", + search: "?from=slack", + hash: "#review", + }) + + expect(path).toBe("/agents/thread-1/plan?from=slack#review") + expect(rememberAuthRedirect(path)).toBe(path) + expect(window.sessionStorage.getItem(AUTH_REDIRECT_STORAGE_KEY)).toBe(path) + }) + + it("resolves login targets to absolute same-origin URLs", () => { + const path = rememberAuthRedirect("/agents/thread-1/plan?from=slack#review") + + const target = `${window.location.origin}/agents/thread-1/plan?from=slack#review` + + expect(authRedirectUrl(path)).toBe(target) + expect(loginUrl(authRedirectUrl(path))).toContain( + encodeURIComponent(target) + ) + }) + + it("consumes remembered targets and clears session storage", () => { + rememberAuthRedirect("/agents/thread-1/plan") + + expect(consumeAuthRedirect()).toBe("/agents/thread-1/plan") + expect(getRememberedAuthRedirect()).toBeNull() + }) + + it("falls back for unsafe targets", () => { + expect( + sanitizeAuthRedirect("https://evil.example/agents/thread-1/plan") + ).toBe(DEFAULT_AUTH_REDIRECT) + expect(sanitizeAuthRedirect("//evil.example/agents/thread-1/plan")).toBe( + DEFAULT_AUTH_REDIRECT + ) + expect(sanitizeAuthRedirect("/login?redirect=/agents/thread-1/plan")).toBe( + DEFAULT_AUTH_REDIRECT + ) + }) + + it("builds a plan sign-in target for the current plan URL", () => { + window.history.pushState({}, "", "/agents/thread-1/plan?from=slack") + + expect(currentAuthRedirectPath()).toBe("/agents/thread-1/plan?from=slack") + expect(loginUrl(authRedirectUrl(currentAuthRedirectPath()))).toContain( + encodeURIComponent( + `${window.location.origin}/agents/thread-1/plan?from=slack` + ) + ) + }) +}) diff --git a/ui/src/lib/auth-redirect.tsx b/ui/src/lib/auth-redirect.tsx new file mode 100644 index 00000000..061786e4 --- /dev/null +++ b/ui/src/lib/auth-redirect.tsx @@ -0,0 +1,13 @@ +import { Navigate } from "@tanstack/react-router" + +import { + currentAuthRedirectPath, + rememberAuthRedirect, +} from "./auth-redirect-core" + +export * from "./auth-redirect-core" + +export function RequireLogin() { + const redirect = rememberAuthRedirect(currentAuthRedirectPath()) + return +} diff --git a/ui/src/routes/admin.tsx b/ui/src/routes/admin.tsx index f7cd1e8b..69b99d13 100644 --- a/ui/src/routes/admin.tsx +++ b/ui/src/routes/admin.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" import { api } from "@/lib/api" +import { RequireLogin } from "@/lib/auth-redirect" import { useSession } from "@/lib/session" export const Route = createFileRoute("/admin")({ component: AdminPage }) @@ -43,7 +44,7 @@ function AdminPage() { ) } - if (!session.data) return + if (!session.data) return if (!session.data.is_admin) return return ( diff --git a/ui/src/routes/admin_.evals.tsx b/ui/src/routes/admin_.evals.tsx index 6c888f49..76309322 100644 --- a/ui/src/routes/admin_.evals.tsx +++ b/ui/src/routes/admin_.evals.tsx @@ -7,6 +7,7 @@ import { AppShell, SettingsSection } from "@/components/AppShell" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { api } from "@/lib/api" +import { RequireLogin } from "@/lib/auth-redirect" import { useSession } from "@/lib/session" export const Route = createFileRoute("/admin_/evals")({ component: ReviewerEvalPage }) @@ -21,7 +22,7 @@ function ReviewerEvalPage() { ) } - if (!session.data) return + if (!session.data) return if (!session.data.is_admin) return return ( diff --git a/ui/src/routes/agents.tsx b/ui/src/routes/agents.tsx index e93d3433..1a19a924 100644 --- a/ui/src/routes/agents.tsx +++ b/ui/src/routes/agents.tsx @@ -1,14 +1,10 @@ -import { - Navigate, - Outlet, - createFileRoute, - useRouterState, -} from "@tanstack/react-router" +import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router" import { AgentsShell } from "@/components/agents/AgentsSidebar" import { Skeleton } from "@/components/ui/skeleton" import agentsCss from "@/styles/agents.css?url" import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider" +import { RequireLogin } from "@/lib/auth-redirect" import { useSession } from "@/lib/session" export const Route = createFileRoute("/agents")({ @@ -41,7 +37,7 @@ function AgentsLayout() { ) } - if (!session.data) return + if (!session.data) return return ( diff --git a/ui/src/routes/agents/$threadId_.plan.tsx b/ui/src/routes/agents/$threadId_.plan.tsx index 23c94aba..5eeb0198 100644 --- a/ui/src/routes/agents/$threadId_.plan.tsx +++ b/ui/src/routes/agents/$threadId_.plan.tsx @@ -4,7 +4,10 @@ import { useEffect, useState } from "react" import { ArrowLeft } from "lucide-react" import { PlanReview } from "@/components/agents/PlanReview" +import { buttonVariants } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" +import { loginUrl } from "@/lib/api" +import { authRedirectUrl, currentAuthRedirectPath } from "@/lib/auth-redirect" import { PlanApiError, getPlan } from "@/lib/plan" export const Route = createFileRoute("/agents/$threadId_/plan")({ @@ -32,6 +35,18 @@ function BackLink({ threadId }: { threadId: string }) { ) } +export function planSignInHref(): string { + return loginUrl(authRedirectUrl(currentAuthRedirectPath())) +} + +export function PlanSignInButton() { + return ( + + Sign in to view this plan + + ) +} + function PlanPage() { const { threadId } = Route.useParams() @@ -70,6 +85,7 @@ function PlanPage() { ? "Please sign in to view this plan." : "This plan could not be found."}

+ {status === 401 ? : null} diff --git a/ui/src/routes/agents/reviews/$owner.$repo.$number.tsx b/ui/src/routes/agents/reviews/$owner.$repo.$number.tsx index 1fe49b26..f2b424fc 100644 --- a/ui/src/routes/agents/reviews/$owner.$repo.$number.tsx +++ b/ui/src/routes/agents/reviews/$owner.$repo.$number.tsx @@ -1,4 +1,4 @@ -import { Link, Navigate, createFileRoute } from "@tanstack/react-router" +import { Link, createFileRoute } from "@tanstack/react-router" import { useQuery, useQueryClient } from "@tanstack/react-query" import { useCallback, useEffect, useRef, useState } from "react" import { ArrowLeftIcon, GitPullRequestIcon } from "@phosphor-icons/react" @@ -9,6 +9,7 @@ import { ReviewMainBody } from "@/components/agents/ReviewMainBody" import { useSidebarControls } from "@/components/sidebar-layout" import { Skeleton } from "@/components/ui/skeleton" import { api } from "@/lib/api" +import { RequireLogin } from "@/lib/auth-redirect" import { useSession } from "@/lib/session" import { cn } from "@/lib/utils" @@ -70,7 +71,7 @@ function ReviewDetailPage() { ) } - if (!session.data) return + if (!session.data) return return (
diff --git a/ui/src/routes/agents_.instructions.tsx b/ui/src/routes/agents_.instructions.tsx index eb36d9d8..c12e5f25 100644 --- a/ui/src/routes/agents_.instructions.tsx +++ b/ui/src/routes/agents_.instructions.tsx @@ -1,8 +1,9 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { AgentInstructionsPanel } from "@/components/AgentInstructionsPanel"; import { AppShell } from "@/components/AppShell"; import { Skeleton } from "@/components/ui/skeleton"; +import { RequireLogin } from "@/lib/auth-redirect"; import { useSession } from "@/lib/session"; export const Route = createFileRoute("/agents_/instructions")({ @@ -19,7 +20,7 @@ function AgentInstructionsPage() { ); } - if (!session.data) return ; + if (!session.data) return ; return ( ) } - if (!session.data) return + if (!session.data) return if (!session.data.is_admin) return return ( diff --git a/ui/src/routes/cloud-agents.tsx b/ui/src/routes/cloud-agents.tsx index fc497049..0f570522 100644 --- a/ui/src/routes/cloud-agents.tsx +++ b/ui/src/routes/cloud-agents.tsx @@ -1,4 +1,4 @@ -import { Link, Navigate, createFileRoute } from "@tanstack/react-router" +import { Link, createFileRoute } from "@tanstack/react-router" import { CaretRightIcon } from "@phosphor-icons/react" import { useEffect, useRef, useState } from "react" @@ -23,6 +23,7 @@ import { useRepos, useSaveProfile, } from "@/lib/profile" +import { RequireLogin } from "@/lib/auth-redirect" import { useSession } from "@/lib/session" export const Route = createFileRoute("/cloud-agents")({ @@ -112,7 +113,7 @@ function CloudAgentsPage() { ) } - if (!session.data) return + if (!session.data) return const fallbackModel = defaultAgentModel const fallbackEffort = defaultAgentEffort diff --git a/ui/src/routes/login.tsx b/ui/src/routes/login.tsx index 0cefa50a..c3505bfc 100644 --- a/ui/src/routes/login.tsx +++ b/ui/src/routes/login.tsx @@ -1,16 +1,44 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useMemo } from "react"; import { buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { loginUrl } from "@/lib/api"; +import { + DEFAULT_AUTH_REDIRECT, + authRedirectUrl, + consumeAuthRedirect, + getRememberedAuthRedirect, + rememberAuthRedirect, +} from "@/lib/auth-redirect"; import { useSession } from "@/lib/session"; import { cn } from "@/lib/utils"; -export const Route = createFileRoute("/login")({ component: Login }); +type LoginSearch = { redirect?: string }; + +export const Route = createFileRoute("/login")({ + validateSearch: (search: Record): LoginSearch => ({ + redirect: typeof search.redirect === "string" ? search.redirect : undefined, + }), + component: Login, +}); function Login() { const session = useSession(); + const search = Route.useSearch(); + const redirectParam = search.redirect; + const intendedPath = useMemo( + () => + redirectParam + ? rememberAuthRedirect(redirectParam) + : getRememberedAuthRedirect() ?? DEFAULT_AUTH_REDIRECT, + [redirectParam] + ); + const authenticatedRedirect = useMemo( + () => (session.data ? consumeAuthRedirect(redirectParam) : null), + [redirectParam, session.data] + ); if (session.isLoading) { return ( @@ -20,8 +48,8 @@ function Login() { ); } - if (session.data) { - return ; + if (authenticatedRedirect) { + return ; } return ( @@ -35,7 +63,10 @@ function Login() { - + Continue with GitHub @@ -43,3 +74,15 @@ function Login() { ); } + +function ClientRedirect({ path }: { path: string }) { + useEffect(() => { + if (typeof window !== "undefined") window.location.replace(path); + }, [path]); + + return ( +
+ +
+ ); +} diff --git a/ui/src/routes/my-settings.tsx b/ui/src/routes/my-settings.tsx index 2298421f..0ff3336f 100644 --- a/ui/src/routes/my-settings.tsx +++ b/ui/src/routes/my-settings.tsx @@ -1,4 +1,4 @@ -import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router" +import { createFileRoute, useNavigate } from "@tanstack/react-router" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useState } from "react" import { IoLogoSlack } from "react-icons/io5" @@ -24,6 +24,7 @@ import { useProfile, useSaveProfile, } from "@/lib/profile" +import { RequireLogin } from "@/lib/auth-redirect" import { useSession } from "@/lib/session" import { notificationsEnabled, @@ -366,7 +367,7 @@ function MySettingsPage() { ) } - if (!session.data) return + if (!session.data) return const handleLogout = async () => { await api.logout() diff --git a/ui/src/routes/review.tsx b/ui/src/routes/review.tsx index d01574ea..91ee968e 100644 --- a/ui/src/routes/review.tsx +++ b/ui/src/routes/review.tsx @@ -1,4 +1,4 @@ -import { Link, Navigate, createFileRoute } from "@tanstack/react-router"; +import { Link, createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CaretRightIcon } from "@phosphor-icons/react"; import { useEffect, useMemo, useState } from "react"; @@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ApiError, api } from "@/lib/api"; +import { RequireLogin } from "@/lib/auth-redirect"; import { useSession } from "@/lib/session"; export const Route = createFileRoute("/review")({ component: ReviewPage }); @@ -66,7 +67,7 @@ function ReviewPage() { ); } - if (!session.data) return ; + if (!session.data) return ; const current: TeamSettings = local; const canEdit = session.data.is_admin; diff --git a/ui/src/routes/review_.repositories.$owner.tsx b/ui/src/routes/review_.repositories.$owner.tsx index 1cf611f8..64c8e797 100644 --- a/ui/src/routes/review_.repositories.$owner.tsx +++ b/ui/src/routes/review_.repositories.$owner.tsx @@ -1,4 +1,4 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { ApiError, api } from "@/lib/api"; +import { RequireLogin } from "@/lib/auth-redirect"; import { useSession } from "@/lib/session"; const PAGE_SIZE = 20; @@ -78,7 +79,7 @@ function RepositoriesOwnerPage() { ); } - if (!session.data) return ; + if (!session.data) return ; const canEdit = session.data.is_admin; const enabledCount = ownerRepos.filter((r) => enabledSet.has(r.full_name)).length; diff --git a/ui/src/routes/review_.styles.tsx b/ui/src/routes/review_.styles.tsx index 2d72f49a..c17d9f82 100644 --- a/ui/src/routes/review_.styles.tsx +++ b/ui/src/routes/review_.styles.tsx @@ -1,8 +1,9 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { AppShell } from "@/components/AppShell"; import { ReviewStylesPanel } from "@/components/ReviewStylesPanel"; import { Skeleton } from "@/components/ui/skeleton"; +import { RequireLogin } from "@/lib/auth-redirect"; import { useSession } from "@/lib/session"; export const Route = createFileRoute("/review_/styles")({ component: ReviewStylesPage }); @@ -17,7 +18,7 @@ function ReviewStylesPage() { ); } - if (!session.data) return ; + if (!session.data) return ; return ( ) } - if (!session.data) return + if (!session.data) return return (