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 = {