Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/app/(app)/edit/account/[account_id]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,7 +19,6 @@ import {
ExternalLinkIcon,
} from "@radix-ui/react-icons";
import {
loginUrl,
editAccountProfileUrl,
editAccountProfilePictureUrl,
editAccountPermissionsUrl,
Expand All @@ -45,7 +44,7 @@ export default async function AccountLayout({
const userSession = await getPageSession();

if (!userSession?.account) {
redirect(loginUrl(await getReturnToUrl()));
return <LoginRequired />;
}

const accountToEdit = await accountsTable.fetchById(account_id);
Expand Down
12 changes: 4 additions & 8 deletions src/app/(app)/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoginRequired />;
}

if (!session.account?.account_id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,7 +36,7 @@ export default async function ProductLayout({
const session = await getPageSession();

if (!session?.account) {
redirect(loginUrl(await getReturnToUrl()));
return <LoginRequired />;
}

const product = await productsTable.fetchById(account_id, product_id);
Expand Down
36 changes: 36 additions & 0 deletions src/components/core/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -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 <a> (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 (
<Button asChild>
<a href={href}>{children}</a>
</Button>
);
}
16 changes: 16 additions & 0 deletions src/components/core/LoginRequired.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<StatusPage
type="unauthenticated"
action={<LoginButton>Sign in</LoginButton>}
/>
);
}
36 changes: 29 additions & 7 deletions src/components/core/StatusPage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import {
Box,
Container,
Heading,
Text,
Flex,
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;
title?: string;
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;
Expand Down Expand Up @@ -46,6 +53,17 @@ const statusConfig = {
</>
),
},
unauthenticated: {
icon: PersonIcon,
defaultTitle: "Sign in required",
defaultDescription: (
<>
You need to sign in to access this page.
<br />
You&apos;ll be returned here after logging in.
</>
),
},
};

export function StatusPage({
Expand All @@ -54,6 +72,7 @@ export function StatusPage({
description,
actionText,
actionHref,
action,
iconSize = 48,
containerSize,
minHeight = "60vh",
Expand Down Expand Up @@ -90,11 +109,14 @@ export function StatusPage({
{finalDescription}
</Text>

{showAction && (
<RadixLink size="3" mt="5" asChild>
<Link href={finalActionHref}>{finalActionText}</Link>
</RadixLink>
)}
{showAction &&
(action ? (
<Box mt="5">{action}</Box>
) : (
<RadixLink size="3" mt="5" asChild>
<Link href={finalActionHref}>{finalActionText}</Link>
</RadixLink>
))}
</Flex>
</Container>
);
Expand Down
2 changes: 2 additions & 0 deletions src/components/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
14 changes: 4 additions & 10 deletions src/components/layout/AuthButtons.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -26,11 +26,5 @@ export async function AuthButtons() {
);
}

const returnTo = await getReturnToUrl();

return (
<Link href={loginUrl(returnTo)}>
<Button>Log In / Register</Button>
</Link>
);
return <LoginButton />;
}
13 changes: 0 additions & 13 deletions src/lib/baseUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,3 @@ export async function getBaseUrl(): Promise<string> {
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<string> {
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}`;
}
10 changes: 2 additions & 8 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = {
Expand Down
Loading