mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-02 03:53:18 +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
|
Only the **LLM** and the **external SaaS HTTP boundaries** are faked. All agent
|
||||||
code runs for real.
|
code runs for real.
|
||||||
|
|
||||||
| Piece | Real or fake |
|
| Piece | Real or fake |
|
||||||
|---|---|
|
| ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.webapp`) |
|
| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.webapp`) |
|
||||||
| `get_agent`, deepagents loop, tools, middleware, prompt | **real** |
|
| `get_agent`, deepagents loop, tools, middleware, prompt | **real** |
|
||||||
| `open_pull_request`, `slack_thread_reply` tools | **real** |
|
| `open_pull_request`, `slack_thread_reply` tools | **real** |
|
||||||
| Sandbox | **real** `local` provider, rooted in a throwaway temp dir |
|
| Sandbox | **real** `local` provider, rooted in a throwaway temp dir |
|
||||||
| Git remote ("GitHub") | **real git**, a local bare repo the agent clones/pushes |
|
| 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 |
|
| 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` |
|
| `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` |
|
| `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) |
|
| 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,
|
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.
|
so what Playwright asserts on is exactly what the real agent produced.
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from html import escape
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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")
|
@app.get("/dashboard/api/auth/login")
|
||||||
async def mock_github_login(redirect_to: str = "", login: str = "") -> Response:
|
async def mock_github_login(redirect_to: str = "") -> Response:
|
||||||
"""Mock stand-in for GitHub OAuth: the dashboard's "Continue with GitHub"
|
"""E2E stand-in for the dashboard OAuth start route.
|
||||||
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
|
The real route would redirect to github.com. Keep the dashboard-facing URL
|
||||||
back into the dashboard (``redirect_to``)."""
|
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("/")
|
ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
||||||
dest = redirect_to or (f"{ui}/agents" if ui else "/agents")
|
dest = redirect_to or (f"{ui}/agents" if ui else "/agents")
|
||||||
if not login:
|
if not login:
|
||||||
options = "".join(
|
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(
|
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">
|
<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>
|
<main data-testid="fake-github-login">
|
||||||
<p style="color:#888;font-size:0.9rem">Pick a fake GitHub account to sign in as.</p>
|
<h1 style="font-size:1.1rem">Authorize open-swe</h1>
|
||||||
<form method=get action=/dashboard/api/auth/login>
|
<p style="color:#888;font-size:0.9rem">Pick a fake GitHub account to continue.</p>
|
||||||
<input type=hidden name=redirect_to value="{dest}">
|
<form method=get action=/fake-gh/login/oauth/authorize>
|
||||||
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
|
<input type=hidden name=redirect_to value="{escape(dest, quote=True)}">
|
||||||
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Continue</button>
|
<label>GitHub user
|
||||||
</form>
|
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
|
||||||
<p style="color:#888;font-size:0.85rem">Tip: use a separate browser or profile per
|
</label>
|
||||||
user so their sessions don't overwrite each other.</p>
|
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Authorize open-swe</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
</body>"""
|
</body>"""
|
||||||
)
|
)
|
||||||
match = next((u for u in TEST_USERS if u["login"] == login), None)
|
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.
|
// 1. A user asks the bot to PLAN something in Slack.
|
||||||
await request.post("/control/reset");
|
await request.post("/control/reset");
|
||||||
const send = await request.post("/mock/slack/send", {
|
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();
|
expect(threadId).toBeTruthy();
|
||||||
const planPath = `/agents/${threadId}/plan`;
|
const planPath = `/agents/${threadId}/plan`;
|
||||||
|
|
||||||
|
|
@ -57,7 +62,9 @@ test.describe("Plan review (HTTP comments)", () => {
|
||||||
};
|
};
|
||||||
return (state.values?.messages ?? [])
|
return (state.values?.messages ?? [])
|
||||||
.map((m) =>
|
.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"));
|
.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.
|
// 2. The agent shares the plan-review link, then announces the plan is ready.
|
||||||
await expect
|
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/);
|
.toMatch(/\/agents\/[^/]+\/plan\b/);
|
||||||
await expect
|
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);
|
.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.
|
// sees the rendered plan.
|
||||||
const ownerCtx = await browser.newContext({
|
const ownerCtx = await browser.newContext({
|
||||||
permissions: ["clipboard-read", "clipboard-write"],
|
permissions: ["clipboard-read", "clipboard-write"],
|
||||||
|
|
@ -85,7 +124,9 @@ test.describe("Plan review (HTTP comments)", () => {
|
||||||
await expect(reviewLink).toBeVisible({ timeout: 30_000 });
|
await expect(reviewLink).toBeVisible({ timeout: 30_000 });
|
||||||
await reviewLink.click();
|
await reviewLink.click();
|
||||||
await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}/plan$`));
|
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.getByText("Back to conversation")).toBeVisible();
|
||||||
await expect(owner.getByTestId("plan-document")).toContainText("greet", {
|
await expect(owner.getByTestId("plan-document")).toContainText("greet", {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|
@ -98,7 +139,9 @@ test.describe("Plan review (HTTP comments)", () => {
|
||||||
// Copy the whole plan as markdown.
|
// Copy the whole plan as markdown.
|
||||||
await owner.getByTestId("copy-plan").click();
|
await owner.getByTestId("copy-plan").click();
|
||||||
await expect(owner.getByTestId("copy-plan")).toContainText("Copied!");
|
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("## Plan: Add greet() helper");
|
||||||
expect(clipboard).toContain("### Verification");
|
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("plan-comment")).toHaveCount(1);
|
||||||
await expect(owner.getByTestId("reject-plan")).toBeEnabled();
|
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.
|
// (fetched over HTTP), but has NO approve button.
|
||||||
const collabCtx = await browser.newContext();
|
const collabCtx = await browser.newContext();
|
||||||
await collabCtx.request.post("/control/login", { data: COLLABORATOR });
|
await collabCtx.request.post("/control/login", { data: COLLABORATOR });
|
||||||
const collab = await collabCtx.newPage();
|
const collab = await collabCtx.newPage();
|
||||||
await collab.goto(planPath);
|
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", {
|
await expect(collab.getByTestId("plan-document")).toContainText("greet", {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(collab.getByTestId("plan-comment")).toHaveCount(1, { timeout: 30_000 });
|
await expect(collab.getByTestId("plan-comment")).toHaveCount(1, {
|
||||||
await expect(collab.getByTestId("plan-comment")).toContainText("looks solid");
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(collab.getByTestId("plan-comment")).toContainText(
|
||||||
|
"looks solid",
|
||||||
|
);
|
||||||
await expect(collab.getByTestId("approve-plan")).toHaveCount(0);
|
await expect(collab.getByTestId("approve-plan")).toHaveCount(0);
|
||||||
await expect(collab.getByTestId("reject-plan")).toBeVisible();
|
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 addComment(collab, "Reviewer: please also add a docstring.");
|
||||||
await expect(collab.getByTestId("plan-comment")).toHaveCount(2);
|
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.
|
// 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 owner.getByTestId("approve-plan").click();
|
||||||
await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}$`));
|
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
|
// echoing the reviewers' feedback — which proves the comments were stored
|
||||||
// and harvested server-side on approve.
|
// and harvested server-side on approve.
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 90_000 })
|
.poll(async () => (await botMessages(request)).join("\n"), {
|
||||||
|
timeout: 90_000,
|
||||||
|
})
|
||||||
.toMatch(/\/pull\//);
|
.toMatch(/\/pull\//);
|
||||||
expect((await botMessages(request)).join("\n")).toMatch(/docstring/);
|
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);
|
expect(prs.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
await ownerCtx.close();
|
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 {
|
export function loginUrl(redirectTo?: string): string {
|
||||||
const target =
|
const target =
|
||||||
redirectTo ?? (typeof window !== "undefined" ? window.location.origin : "")
|
redirectTo ?? (typeof window !== "undefined" ? window.location.href : "")
|
||||||
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : ""
|
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : ""
|
||||||
return `${API_BASE}/dashboard/api/auth/login${qs}`
|
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"
|
} from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin")({ component: AdminPage })
|
export const Route = createFileRoute("/admin")({ component: AdminPage })
|
||||||
|
|
@ -43,7 +44,7 @@ function AdminPage() {
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <RequireLogin />
|
||||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { AppShell, SettingsSection } from "@/components/AppShell"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin_/evals")({ component: ReviewerEvalPage })
|
export const Route = createFileRoute("/admin_/evals")({ component: ReviewerEvalPage })
|
||||||
|
|
@ -21,7 +22,7 @@ function ReviewerEvalPage() {
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <RequireLogin />
|
||||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import {
|
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router"
|
||||||
Navigate,
|
|
||||||
Outlet,
|
|
||||||
createFileRoute,
|
|
||||||
useRouterState,
|
|
||||||
} from "@tanstack/react-router"
|
|
||||||
|
|
||||||
import { AgentsShell } from "@/components/agents/AgentsSidebar"
|
import { AgentsShell } from "@/components/agents/AgentsSidebar"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import agentsCss from "@/styles/agents.css?url"
|
import agentsCss from "@/styles/agents.css?url"
|
||||||
import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider"
|
import { AgentThreadStreamProvider } from "@/lib/agents/AgentThreadStreamProvider"
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
|
|
||||||
export const Route = createFileRoute("/agents")({
|
export const Route = createFileRoute("/agents")({
|
||||||
|
|
@ -41,7 +37,7 @@ function AgentsLayout() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <RequireLogin />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentsShell user={session.data} activeThreadId={activeThreadId}>
|
<AgentsShell user={session.data} activeThreadId={activeThreadId}>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ import { useEffect, useState } from "react"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
import { PlanReview } from "@/components/agents/PlanReview"
|
import { PlanReview } from "@/components/agents/PlanReview"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { loginUrl } from "@/lib/api"
|
||||||
|
import { authRedirectUrl, currentAuthRedirectPath } from "@/lib/auth-redirect"
|
||||||
import { PlanApiError, getPlan } from "@/lib/plan"
|
import { PlanApiError, getPlan } from "@/lib/plan"
|
||||||
|
|
||||||
export const Route = createFileRoute("/agents/$threadId_/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() {
|
function PlanPage() {
|
||||||
const { threadId } = Route.useParams()
|
const { threadId } = Route.useParams()
|
||||||
|
|
||||||
|
|
@ -70,6 +85,7 @@ function PlanPage() {
|
||||||
? "Please sign in to view this plan."
|
? "Please sign in to view this plan."
|
||||||
: "This plan could not be found."}
|
: "This plan could not be found."}
|
||||||
</p>
|
</p>
|
||||||
|
{status === 401 ? <PlanSignInButton /> : null}
|
||||||
<BackLink threadId={threadId} />
|
<BackLink threadId={threadId} />
|
||||||
</div>
|
</div>
|
||||||
</Centered>
|
</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 { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { ArrowLeftIcon, GitPullRequestIcon } from "@phosphor-icons/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 { useSidebarControls } from "@/components/sidebar-layout"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ function ReviewDetailPage() {
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <RequireLogin />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
|
<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 { AgentInstructionsPanel } from "@/components/AgentInstructionsPanel";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
export const Route = createFileRoute("/agents_/instructions")({
|
export const Route = createFileRoute("/agents_/instructions")({
|
||||||
|
|
@ -19,7 +20,7 @@ function AgentInstructionsPage() {
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />;
|
if (!session.data) return <RequireLogin />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Navigate, createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { AppShell } from "@/components/AppShell"
|
import { AppShell } from "@/components/AppShell"
|
||||||
import { RepoSnapshotsPanel } from "@/components/RepoSnapshotsPanel"
|
import { RepoSnapshotsPanel } from "@/components/RepoSnapshotsPanel"
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
|
|
||||||
|
|
@ -19,7 +20,7 @@ function RepoSnapshotsPage() {
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <RequireLogin />
|
||||||
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
if (!session.data.is_admin) return <Navigate to="/my-settings" />
|
||||||
|
|
||||||
return (
|
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 { CaretRightIcon } from "@phosphor-icons/react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
useRepos,
|
useRepos,
|
||||||
useSaveProfile,
|
useSaveProfile,
|
||||||
} from "@/lib/profile"
|
} from "@/lib/profile"
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
|
|
||||||
export const Route = createFileRoute("/cloud-agents")({
|
export const Route = createFileRoute("/cloud-agents")({
|
||||||
|
|
@ -112,7 +113,7 @@ function CloudAgentsPage() {
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <RequireLogin />
|
||||||
|
|
||||||
const fallbackModel = defaultAgentModel
|
const fallbackModel = defaultAgentModel
|
||||||
const fallbackEffort = defaultAgentEffort
|
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 { buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { loginUrl } from "@/lib/api";
|
import { loginUrl } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
DEFAULT_AUTH_REDIRECT,
|
||||||
|
authRedirectUrl,
|
||||||
|
consumeAuthRedirect,
|
||||||
|
getRememberedAuthRedirect,
|
||||||
|
rememberAuthRedirect,
|
||||||
|
} from "@/lib/auth-redirect";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
import { cn } from "@/lib/utils";
|
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() {
|
function Login() {
|
||||||
const session = useSession();
|
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) {
|
if (session.isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -20,8 +48,8 @@ function Login() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.data) {
|
if (authenticatedRedirect) {
|
||||||
return <Navigate to="/my-settings" />;
|
return <ClientRedirect path={authenticatedRedirect} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -35,7 +63,10 @@ function Login() {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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
|
Continue with GitHub
|
||||||
</a>
|
</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -43,3 +74,15 @@ function Login() {
|
||||||
</main>
|
</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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { IoLogoSlack } from "react-icons/io5"
|
import { IoLogoSlack } from "react-icons/io5"
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
useProfile,
|
useProfile,
|
||||||
useSaveProfile,
|
useSaveProfile,
|
||||||
} from "@/lib/profile"
|
} from "@/lib/profile"
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
import {
|
import {
|
||||||
notificationsEnabled,
|
notificationsEnabled,
|
||||||
|
|
@ -366,7 +367,7 @@ function MySettingsPage() {
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <RequireLogin />
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await api.logout()
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ApiError, api } from "@/lib/api";
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
export const Route = createFileRoute("/review")({ component: ReviewPage });
|
export const Route = createFileRoute("/review")({ component: ReviewPage });
|
||||||
|
|
@ -66,7 +67,7 @@ function ReviewPage() {
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />;
|
if (!session.data) return <RequireLogin />;
|
||||||
|
|
||||||
const current: TeamSettings = local;
|
const current: TeamSettings = local;
|
||||||
const canEdit = session.data.is_admin;
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ApiError, api } from "@/lib/api";
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
@ -78,7 +79,7 @@ function RepositoriesOwnerPage() {
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />;
|
if (!session.data) return <RequireLogin />;
|
||||||
|
|
||||||
const canEdit = session.data.is_admin;
|
const canEdit = session.data.is_admin;
|
||||||
const enabledCount = ownerRepos.filter((r) => enabledSet.has(r.full_name)).length;
|
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 { AppShell } from "@/components/AppShell";
|
||||||
import { ReviewStylesPanel } from "@/components/ReviewStylesPanel";
|
import { ReviewStylesPanel } from "@/components/ReviewStylesPanel";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
export const Route = createFileRoute("/review_/styles")({ component: ReviewStylesPage });
|
export const Route = createFileRoute("/review_/styles")({ component: ReviewStylesPage });
|
||||||
|
|
@ -17,7 +18,7 @@ function ReviewStylesPage() {
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />;
|
if (!session.data) return <RequireLogin />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<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 { useQuery } from "@tanstack/react-query"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { api } from "@/lib/api"
|
import { api } from "@/lib/api"
|
||||||
|
import { RequireLogin } from "@/lib/auth-redirect"
|
||||||
import { useSession } from "@/lib/session"
|
import { useSession } from "@/lib/session"
|
||||||
|
|
||||||
export const Route = createFileRoute("/usage")({
|
export const Route = createFileRoute("/usage")({
|
||||||
|
|
@ -57,7 +58,7 @@ function UsagePage() {
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />
|
if (!session.data) return <RequireLogin />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell user={session.data} title="Usage" className="max-w-5xl">
|
<AppShell user={session.data} title="Usage" className="max-w-5xl">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue