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..21bd642d --- /dev/null +++ b/src/components/core/LoginButton.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Button } from "@radix-ui/themes"; +import { ReactNode, useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { loginUrl } from "@/lib/urls"; + +/** + * 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 ( + + ); +} 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 = {