From 9c675fb276aab45800da44b9db81433716a14d83 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 1 Jul 2026 19:46:56 -0700 Subject: [PATCH 1/2] refactor: compute login return_to client-side, drop middleware header injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login button and the edit-page auth guards built their `return_to` server-side from `x-pathname`/`x-search` request headers that middleware injected on every matched request. This replaces that with client-side computation, removing the middleware header injection entirely. - Add `LoginButton` (client): reads `window.location.href` at click time to build the login URL, mirroring the logout button. - Add `LoginRequired` interstitial (reuses `StatusPage`) shown when an unauthenticated user hits a protected edit page, instead of a server-side redirect-to-login. The authorization checks (isAuthorized → 403/404) are unchanged. - `StatusPage`: add an `unauthenticated` type and a custom `action` slot. - Header login button now renders `` (no server-side return_to). - Delete `getReturnToUrl()` (its only callers are gone) and stop the middleware from stamping x-pathname/x-search on every request. Cost: one extra click when a logged-out user lands directly on an edit page. Co-Authored-By: Claude Opus 4.8 --- .../edit/account/[account_id]/layout.tsx | 7 ++-- src/app/(app)/edit/page.tsx | 12 +++---- .../[account_id]/[product_id]/layout.tsx | 7 ++-- src/components/core/LoginButton.tsx | 26 ++++++++++++++ src/components/core/LoginRequired.tsx | 16 +++++++++ src/components/core/StatusPage.tsx | 36 +++++++++++++++---- src/components/core/index.ts | 2 ++ src/components/layout/AuthButtons.tsx | 14 +++----- src/lib/baseUrl.ts | 13 ------- src/middleware.ts | 10 ++---- 10 files changed, 89 insertions(+), 54 deletions(-) create mode 100644 src/components/core/LoginButton.tsx create mode 100644 src/components/core/LoginRequired.tsx diff --git a/src/app/(app)/edit/account/[account_id]/layout.tsx b/src/app/(app)/edit/account/[account_id]/layout.tsx index fbddbc9e..1fe3c00e 100644 --- a/src/app/(app)/edit/account/[account_id]/layout.tsx +++ b/src/app/(app)/edit/account/[account_id]/layout.tsx @@ -8,8 +8,8 @@ import { getPageSession } from "@/lib/api/utils"; import { isAuthorized, canManageAccountDataConnections } from "@/lib/api/authz"; import { Actions } from "@/types"; import { accountsTable } from "@/lib/clients/database"; -import { notFound, redirect } from "next/navigation"; -import { getReturnToUrl } from "@/lib/baseUrl"; +import { notFound } from "next/navigation"; +import { LoginRequired } from "@/components/core"; import { PersonIcon, LockClosedIcon, @@ -19,7 +19,6 @@ import { ExternalLinkIcon, } from "@radix-ui/react-icons"; import { - loginUrl, editAccountProfileUrl, editAccountProfilePictureUrl, editAccountPermissionsUrl, @@ -45,7 +44,7 @@ export default async function AccountLayout({ const userSession = await getPageSession(); if (!userSession?.account) { - redirect(loginUrl(await getReturnToUrl())); + return ; } const accountToEdit = await accountsTable.fetchById(account_id); diff --git a/src/app/(app)/edit/page.tsx b/src/app/(app)/edit/page.tsx index eafaaed2..ae3d3995 100644 --- a/src/app/(app)/edit/page.tsx +++ b/src/app/(app)/edit/page.tsx @@ -1,16 +1,12 @@ import { redirect } from "next/navigation"; import { getPageSession } from "@/lib/api/utils"; -import { editProfileUrl, homeUrl, loginUrl } from "@/lib/urls"; -import { getReturnToUrl } from "@/lib/baseUrl"; +import { editProfileUrl, homeUrl } from "@/lib/urls"; +import { LoginRequired } from "@/components/core"; -interface SettingsPageProps { - params: Promise<{ account_id: string }>; -} - -export default async function Redirect({ params }: SettingsPageProps) { +export default async function Redirect() { const session = await getPageSession(); if (!session) { - redirect(loginUrl(await getReturnToUrl())); + return ; } if (!session.account?.account_id) { diff --git a/src/app/(app)/edit/product/[account_id]/[product_id]/layout.tsx b/src/app/(app)/edit/product/[account_id]/[product_id]/layout.tsx index 7fbb21a1..9a29966b 100644 --- a/src/app/(app)/edit/product/[account_id]/[product_id]/layout.tsx +++ b/src/app/(app)/edit/product/[account_id]/[product_id]/layout.tsx @@ -11,11 +11,10 @@ import { getPageSession } from "@/lib/api/utils"; import { isAuthorized, isAdmin } from "@/lib/api/authz"; import { Actions } from "@/types"; import { productsTable } from "@/lib/clients/database"; -import { notFound, redirect } from "next/navigation"; -import { getReturnToUrl } from "@/lib/baseUrl"; +import { notFound } from "next/navigation"; import { LinkAway } from "@/components/core/LinkAway"; +import { LoginRequired } from "@/components/core"; import { - loginUrl, productUrl, editProductDetailsUrl, editProductMembershipsUrl, @@ -37,7 +36,7 @@ export default async function ProductLayout({ const session = await getPageSession(); if (!session?.account) { - redirect(loginUrl(await getReturnToUrl())); + return ; } const product = await productsTable.fetchById(account_id, product_id); diff --git a/src/components/core/LoginButton.tsx b/src/components/core/LoginButton.tsx new file mode 100644 index 00000000..7a45c181 --- /dev/null +++ b/src/components/core/LoginButton.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Button } from "@radix-ui/themes"; +import { ReactNode } from "react"; +import { loginUrl } from "@/lib/urls"; + +/** + * Sign-in button that returns the user to the current page after login. + * Reads window.location at click time so no server-side path plumbing is + * needed (login lives on the Ory domain, so this is a full-page navigation). + */ +export function LoginButton({ + children = "Log In / Register", +}: { + children?: ReactNode; +}) { + return ( + + ); +} diff --git a/src/components/core/LoginRequired.tsx b/src/components/core/LoginRequired.tsx new file mode 100644 index 00000000..e81b85cc --- /dev/null +++ b/src/components/core/LoginRequired.tsx @@ -0,0 +1,16 @@ +import { StatusPage } from "./StatusPage"; +import { LoginButton } from "./LoginButton"; + +/** + * Interstitial shown when an unauthenticated user hits a page that requires + * login. Replaces a server-side redirect-to-login so the return_to can be + * computed client-side (see LoginButton) instead of via middleware headers. + */ +export function LoginRequired() { + return ( + Sign in} + /> + ); +} diff --git a/src/components/core/StatusPage.tsx b/src/components/core/StatusPage.tsx index 80734d5e..2cf7a504 100644 --- a/src/components/core/StatusPage.tsx +++ b/src/components/core/StatusPage.tsx @@ -1,4 +1,5 @@ import { + Box, Container, Heading, Text, @@ -6,10 +7,14 @@ import { Link as RadixLink, } from "@radix-ui/themes"; import Link from "next/link"; -import { LinkBreak2Icon, LockClosedIcon } from "@radix-ui/react-icons"; +import { + LinkBreak2Icon, + LockClosedIcon, + PersonIcon, +} from "@radix-ui/react-icons"; import { ReactNode } from "react"; -type StatusType = "not-found" | "not-authorized"; +type StatusType = "not-found" | "not-authorized" | "unauthenticated"; interface StatusPageProps { type: StatusType; @@ -17,6 +22,8 @@ interface StatusPageProps { description?: ReactNode; actionText?: ReactNode; actionHref?: string; + /** Custom action node (e.g. a client button); overrides actionText/actionHref. */ + action?: ReactNode; iconSize?: number; containerSize?: "1" | "2" | "3" | "4"; minHeight?: string; @@ -46,6 +53,17 @@ const statusConfig = { ), }, + unauthenticated: { + icon: PersonIcon, + defaultTitle: "Sign in required", + defaultDescription: ( + <> + You need to sign in to access this page. +
+ You'll be returned here after logging in. + + ), + }, }; export function StatusPage({ @@ -54,6 +72,7 @@ export function StatusPage({ description, actionText, actionHref, + action, iconSize = 48, containerSize, minHeight = "60vh", @@ -90,11 +109,14 @@ export function StatusPage({ {finalDescription} - {showAction && ( - - {finalActionText} - - )} + {showAction && + (action ? ( + {action} + ) : ( + + {finalActionText} + + ))} ); diff --git a/src/components/core/index.ts b/src/components/core/index.ts index dfd1dcab..dc9c4a47 100644 --- a/src/components/core/index.ts +++ b/src/components/core/index.ts @@ -7,6 +7,8 @@ export { DynamicForm } from "./DynamicForm"; export type { FormField } from "./DynamicForm"; export { SmallColumnContainer } from "./SmallColumnContainer"; export { StatusPage, NotFoundPage, NotAuthorizedPage } from "./StatusPage"; +export { LoginButton } from "./LoginButton"; +export { LoginRequired } from "./LoginRequired"; export { EditButton } from "./EditButton"; export { LinkAway } from "./LinkAway"; export * from "./AccountLinks"; diff --git a/src/components/layout/AuthButtons.tsx b/src/components/layout/AuthButtons.tsx index 4aa87f0b..4df97230 100644 --- a/src/components/layout/AuthButtons.tsx +++ b/src/components/layout/AuthButtons.tsx @@ -1,9 +1,9 @@ import { AccountDropdown } from "./AccountDropdown"; import { getPageSession } from "@/lib/api/utils"; -import { Button, Callout, Link } from "@radix-ui/themes"; +import { Callout, Link } from "@radix-ui/themes"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import { loginUrl, onboardingUrl } from "@/lib/urls"; -import { getReturnToUrl } from "@/lib/baseUrl"; +import { onboardingUrl } from "@/lib/urls"; +import { LoginButton } from "@/components/core"; export async function AuthButtons() { const session = await getPageSession(); @@ -26,11 +26,5 @@ export async function AuthButtons() { ); } - const returnTo = await getReturnToUrl(); - - return ( - - - - ); + return ; } diff --git a/src/lib/baseUrl.ts b/src/lib/baseUrl.ts index 0fa2a856..f2936ebc 100644 --- a/src/lib/baseUrl.ts +++ b/src/lib/baseUrl.ts @@ -12,16 +12,3 @@ export async function getBaseUrl(): Promise { const protocol = headersList.get("x-forwarded-proto") || "http"; return `${protocol}://${host}`; } - -/** - * Get the full URL of the current request, including path and query string. - * Requires x-pathname and x-search headers injected by middleware. - */ -export async function getReturnToUrl(): Promise { - const headersList = await headers(); - const host = headersList.get("host") || "source.coop"; - const protocol = headersList.get("x-forwarded-proto") || "http"; - const pathname = headersList.get("x-pathname") || "/"; - const search = headersList.get("x-search") || ""; - return `${protocol}://${host}${pathname}${search}`; -} diff --git a/src/middleware.ts b/src/middleware.ts index 28abf434..3e25ea82 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -66,8 +66,7 @@ const handleLegacyRedirects = (request: NextRequest): NextResponse | null => { const ory = createOryMiddleware({}); // Paths the Ory middleware proxies (self-service flows, session checks). For -// everything else its middleware just returns a no-op NextResponse.next(), so -// we own the response and inject the current-path headers below. +// everything else it returns a no-op NextResponse.next(). // See node_modules/@ory/nextjs/dist/middleware (proxyRequest match list). const ORY_PROXIED_PREFIXES = [ "/self-service", @@ -93,12 +92,7 @@ export const middleware = async (request: NextRequest) => { return ory(request); } - // Inject current path into request headers so server components can read it - // via headers() from next/headers (used to build return_to login URLs). - const requestHeaders = new Headers(request.headers); - requestHeaders.set("x-pathname", request.nextUrl.pathname); - requestHeaders.set("x-search", request.nextUrl.search); - return NextResponse.next({ request: { headers: requestHeaders } }); + return NextResponse.next(); }; export const config = { From 93676338dd9751dc3ea2e94baf399719b4fec239 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 1 Jul 2026 23:00:01 -0700 Subject: [PATCH 2/2] refactor: render LoginButton as an anchor instead of window.location.href MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a real (via Button asChild) so the sign-in control behaves like a link — open-in-new-tab, middle-click, keyboard. A plain anchor (not next/link) forces the full-page navigation the Ory login flow needs (external domain in prod, middleware-proxied relative path in dev). return_to is set to the current absolute URL after mount and refreshed on navigation via usePathname, since the button lives in the persistent header. Co-Authored-By: Claude Opus 4.8 --- src/components/core/LoginButton.tsx | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/components/core/LoginButton.tsx b/src/components/core/LoginButton.tsx index 7a45c181..21bd642d 100644 --- a/src/components/core/LoginButton.tsx +++ b/src/components/core/LoginButton.tsx @@ -1,26 +1,36 @@ "use client"; import { Button } from "@radix-ui/themes"; -import { ReactNode } from "react"; +import { ReactNode, useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; import { loginUrl } from "@/lib/urls"; /** - * Sign-in button that returns the user to the current page after login. - * Reads window.location at click time so no server-side path plumbing is - * needed (login lives on the Ory domain, so this is a full-page navigation). + * Sign-in link that returns the user to the current page after login. + * + * A plain (not next/link): login lives on the Ory flow — an external + * domain in prod, a middleware-proxied relative path in dev — so it must be a + * full-page navigation, not a client-side route change. + * + * The return_to (current absolute URL) is filled in after mount and refreshed + * on navigation, since this button sits in the persistent header. + * ponytail: before mount the href is the bare login URL, so a click in that + * first frame returns to Ory's default rather than the current page. */ export function LoginButton({ children = "Log In / Register", }: { children?: ReactNode; }) { + const pathname = usePathname(); + const [href, setHref] = useState(loginUrl()); + useEffect(() => { + setHref(loginUrl(window.location.href)); + }, [pathname]); + return ( - ); }