mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
fix: preserve dashboard redirect after login (#1668)
* fix: preserve dashboard redirect after login Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * test: cover plan login redirect in e2e Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit bc7ce59169b5350da7286164afb83a7b037b528d)
This commit is contained in:
parent
e27fb33e56
commit
ddaa87b97d
22 changed files with 452 additions and 76 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'<option value="{u["login"]}">{u["name"]} (@{u["login"]})</option>' for u in TEST_USERS
|
||||
f'<option value="{escape(u["login"], quote=True)}">'
|
||||
f"{escape(u['name'])} (@{escape(u['login'])})</option>"
|
||||
for u in TEST_USERS
|
||||
)
|
||||
return HTMLResponse(
|
||||
f"""<!doctype html><meta charset=utf-8><title>Continue with GitHub (mock)</title>
|
||||
f"""<!doctype html><meta charset=utf-8><title>GitHub · Authorize open-swe</title>
|
||||
<body style="font-family:system-ui;max-width:420px;margin:3rem auto;padding:0 1rem">
|
||||
<h1 style="font-size:1.1rem">Continue with GitHub (mock)</h1>
|
||||
<p style="color:#888;font-size:0.9rem">Pick a fake GitHub account to sign in as.</p>
|
||||
<form method=get action=/dashboard/api/auth/login>
|
||||
<input type=hidden name=redirect_to value="{dest}">
|
||||
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
|
||||
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Continue</button>
|
||||
</form>
|
||||
<p style="color:#888;font-size:0.85rem">Tip: use a separate browser or profile per
|
||||
user so their sessions don't overwrite each other.</p>
|
||||
<main data-testid="fake-github-login">
|
||||
<h1 style="font-size:1.1rem">Authorize open-swe</h1>
|
||||
<p style="color:#888;font-size:0.9rem">Pick a fake GitHub account to continue.</p>
|
||||
<form method=get action=/fake-gh/login/oauth/authorize>
|
||||
<input type=hidden name=redirect_to value="{escape(dest, quote=True)}">
|
||||
<label>GitHub user
|
||||
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
|
||||
</label>
|
||||
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Authorize open-swe</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>"""
|
||||
)
|
||||
match = next((u for u in TEST_USERS if u["login"] == login), None)
|
||||
|
|
|
|||
|
|
@ -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<unknown>;
|
||||
const prs = (await (
|
||||
await request.get("/mock/github/data")
|
||||
).json()) as Array<unknown>;
|
||||
expect(prs.length).toBeGreaterThan(0);
|
||||
|
||||
await ownerCtx.close();
|
||||
|
|
|
|||
30
tests/test_dashboard_oauth_redirect.py
Normal file
30
tests/test_dashboard_oauth_redirect.py
Normal file
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
120
ui/src/lib/auth-redirect-core.ts
Normal file
120
ui/src/lib/auth-redirect-core.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
76
ui/src/lib/auth-redirect.test.ts
Normal file
76
ui/src/lib/auth-redirect.test.ts
Normal file
|
|
@ -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`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
13
ui/src/lib/auth-redirect.tsx
Normal file
13
ui/src/lib/auth-redirect.tsx
Normal file
|
|
@ -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 <Navigate to="/login" search={{ redirect }} />
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
return (
|
||||
<AgentsShell user={session.data} activeThreadId={activeThreadId}>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<a href={planSignInHref()} className={buttonVariants({ size: "sm" })}>
|
||||
Sign in to view this plan
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
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."}
|
||||
</p>
|
||||
{status === 401 ? <PlanSignInButton /> : null}
|
||||
<BackLink threadId={threadId} />
|
||||
</div>
|
||||
</Centered>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <RequireLogin />;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Navigate, createFileRoute } from "@tanstack/react-router"
|
|||
|
||||
import { AppShell } from "@/components/AppShell"
|
||||
import { RepoSnapshotsPanel } from "@/components/RepoSnapshotsPanel"
|
||||
import { RequireLogin } from "@/lib/auth-redirect"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useSession } from "@/lib/session"
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ function RepoSnapshotsPage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
const fallbackModel = defaultAgentModel
|
||||
const fallbackEffort = defaultAgentEffort
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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 <Navigate to="/my-settings" />;
|
||||
if (authenticatedRedirect) {
|
||||
return <ClientRedirect path={authenticatedRedirect} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -35,7 +63,10 @@ function Login() {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a href={loginUrl()} className={cn(buttonVariants({ size: "lg" }), "w-full")}>
|
||||
<a
|
||||
href={loginUrl(authRedirectUrl(intendedPath))}
|
||||
className={cn(buttonVariants({ size: "lg" }), "w-full")}
|
||||
>
|
||||
Continue with GitHub
|
||||
</a>
|
||||
</CardContent>
|
||||
|
|
@ -43,3 +74,15 @@ function Login() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientRedirect({ path }: { path: string }) {
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") window.location.replace(path);
|
||||
}, [path]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-svh items-center justify-center p-6">
|
||||
<Skeleton className="h-40 w-80" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
const handleLogout = async () => {
|
||||
await api.logout()
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <RequireLogin />;
|
||||
|
||||
const current: TeamSettings = local;
|
||||
const canEdit = session.data.is_admin;
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <RequireLogin />;
|
||||
|
||||
const canEdit = session.data.is_admin;
|
||||
const enabledCount = ownerRepos.filter((r) => enabledSet.has(r.full_name)).length;
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
if (!session.data) return <RequireLogin />;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
import type {
|
||||
|
|
@ -17,6 +17,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("/usage")({
|
||||
|
|
@ -57,7 +58,7 @@ function UsagePage() {
|
|||
</main>
|
||||
)
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />
|
||||
if (!session.data) return <RequireLogin />
|
||||
|
||||
return (
|
||||
<AppShell user={session.data} title="Usage" className="max-w-5xl">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue