From ad3abfaadb08804db5a170422d21bed66b586f54 Mon Sep 17 00:00:00 2001 From: deefour Date: Sun, 20 Sep 2026 10:19:38 -0400 Subject: [PATCH] feat: start hosted login from review --- apps/web/src/review/review-app.tsx | 19 +++++++-- apps/web/src/review/review-login-redirect.ts | 25 +++++++++++ tests/client/review-login-redirect.test.ts | 44 ++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/review/review-login-redirect.ts create mode 100644 tests/client/review-login-redirect.test.ts diff --git a/apps/web/src/review/review-app.tsx b/apps/web/src/review/review-app.tsx index 18e2f10..bae8745 100644 --- a/apps/web/src/review/review-app.tsx +++ b/apps/web/src/review/review-app.tsx @@ -4,6 +4,7 @@ import { useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -73,6 +74,10 @@ import {ReviewProjectPicker} from "./review-project-picker.tsx"; import {isSettingsPath} from "./review-routes.ts"; import {ReviewSettings} from "./review-settings.tsx"; import {ReviewPanelEdge} from "./review-panel-edge.tsx"; +import { + reviewLoginRedirect, + type ReviewSessionState, +} from "./review-login-redirect.ts"; import {AgentLogos, ReviewShareControl} from "./review-share.tsx"; import {useReviewPanelMotion} from "./use-review-panel-motion.ts"; import {useReviewResizablePanel} from "./use-review-resizable-panel.ts"; @@ -364,9 +369,7 @@ export function ReviewApp() { const accessContextRef = useRef(null); const bootstrapInFlightRef = useRef(false); const [projects, setProjects] = useState([]); - const [sessionState, setSessionState] = useState< - "loading" | "ready" | "unauthenticated" - >("loading"); + const [sessionState, setSessionState] = useState("loading"); const [error, setError] = useState(null); const [theme, setTheme] = useState(readInitialTheme); @@ -438,6 +441,15 @@ export function ReviewApp() { window.localStorage.setItem("artifact-review-theme", theme); }, [theme]); + const loginRedirect = reviewLoginRedirect( + accessContextRef.current, + sessionState, + window.location, + ); + useLayoutEffect(() => { + if (loginRedirect !== null) window.location.replace(loginRedirect); + }, [loginRedirect]); + const createProject = useCallback(async (name: string): Promise => { const created = await api.createProject(name); setProjects((current) => [ @@ -455,6 +467,7 @@ export function ReviewApp() { if (sessionState === "loading") { return ; } + if (loginRedirect !== null) return null; if (sessionState === "unauthenticated") { const returnTo = `${window.location.pathname}${window.location.search}`; return ( diff --git a/apps/web/src/review/review-login-redirect.ts b/apps/web/src/review/review-login-redirect.ts new file mode 100644 index 0000000..a11083f --- /dev/null +++ b/apps/web/src/review/review-login-redirect.ts @@ -0,0 +1,25 @@ +import type {AccessContext} from "../api/client.js"; + +export type ReviewSessionState = "loading" | "ready" | "unauthenticated"; + +interface ReviewLocation { + readonly pathname: string; + readonly search: string; +} + +/** Resolve the hosted browser login handoff without affecting local-owner mode. */ +export function reviewLoginRedirect( + accessContext: AccessContext | null, + sessionState: ReviewSessionState, + location: ReviewLocation, +): string | null { + if ( + sessionState !== "unauthenticated" + || accessContext?.accessMode !== "private_team" + ) { + return null; + } + + const returnTo = `${location.pathname}${location.search}`; + return `/auth/login?returnTo=${encodeURIComponent(returnTo)}`; +} diff --git a/tests/client/review-login-redirect.test.ts b/tests/client/review-login-redirect.test.ts new file mode 100644 index 0000000..0042ef4 --- /dev/null +++ b/tests/client/review-login-redirect.test.ts @@ -0,0 +1,44 @@ +import {describe, expect, it} from "vitest"; + +import { + reviewLoginRedirect, + type ReviewSessionState, +} from "../../apps/web/src/review/review-login-redirect.js"; +import type {AccessContext} from "../../apps/web/src/api/client.js"; + +const privateTeamAccess = { + accessMode: "private_team", + login: {kind: "oidc"}, +} satisfies AccessContext; + +const localOwnerAccess = { + accessMode: "local_owner", + login: {kind: "local_owner"}, +} satisfies AccessContext; + +describe("review login redirect", () => { + it("starts hosted login and preserves the complete review destination", () => { + expect(reviewLoginRedirect( + privateTeamAccess, + "unauthenticated", + { + pathname: "/review", + search: "?artifact=art_123&project=prj_default&view=focus", + }, + )).toBe( + "/auth/login?returnTo=%2Freview%3Fartifact%3Dart_123%26project%3Dprj_default%26view%3Dfocus", + ); + }); + + it.each([ + [privateTeamAccess, "loading"], + [privateTeamAccess, "ready"], + [localOwnerAccess, "unauthenticated"], + [null, "unauthenticated"], + ])("does not redirect access context %# in session state %s", (context, state) => { + expect(reviewLoginRedirect(context, state, { + pathname: "/review", + search: "", + })).toBeNull(); + }); +});