From 6349ca948274873fd24276052a5d4a84a9133a4e Mon Sep 17 00:00:00 2001 From: laaichiu <134155205+laaichiu@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:45:26 +0800 Subject: [PATCH 1/2] fix: guard authenticated account routes (#202) --- .../{ => (authenticated)}/addresses/page.tsx | 0 .../credit-cards/page.tsx | 0 .../{ => (authenticated)}/gift-cards/page.tsx | 0 .../account/(authenticated)/layout.test.tsx | 83 +++++++ .../account/(authenticated)/layout.tsx | 51 +++++ .../orders/[id]/page.tsx | 0 .../{ => (authenticated)}/orders/page.tsx | 0 .../{ => (authenticated)}/profile/page.tsx | 0 .../[locale]/(storefront)/account/layout.tsx | 214 ------------------ .../[locale]/(storefront)/account/page.tsx | 157 +++++++------ src/components/account/AccountShell.tsx | 111 +++++++++ .../account/AuthenticatedAccountShell.tsx | 54 +++++ .../AuthenticatedAccountShell.test.tsx | 80 +++++++ src/lib/spree/__tests__/auth-helpers.test.ts | 14 ++ src/lib/spree/auth-helpers.ts | 11 +- src/lib/spree/middleware.test.ts | 52 +++++ src/lib/spree/middleware.ts | 44 ++++ .../utils/__tests__/account-redirect.test.ts | 46 ++++ src/lib/utils/account-redirect.ts | 62 +++++ 19 files changed, 689 insertions(+), 290 deletions(-) rename src/app/[country]/[locale]/(storefront)/account/{ => (authenticated)}/addresses/page.tsx (100%) rename src/app/[country]/[locale]/(storefront)/account/{ => (authenticated)}/credit-cards/page.tsx (100%) rename src/app/[country]/[locale]/(storefront)/account/{ => (authenticated)}/gift-cards/page.tsx (100%) create mode 100644 src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.test.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.tsx rename src/app/[country]/[locale]/(storefront)/account/{ => (authenticated)}/orders/[id]/page.tsx (100%) rename src/app/[country]/[locale]/(storefront)/account/{ => (authenticated)}/orders/page.tsx (100%) rename src/app/[country]/[locale]/(storefront)/account/{ => (authenticated)}/profile/page.tsx (100%) delete mode 100644 src/app/[country]/[locale]/(storefront)/account/layout.tsx create mode 100644 src/components/account/AccountShell.tsx create mode 100644 src/components/account/AuthenticatedAccountShell.tsx create mode 100644 src/components/account/__tests__/AuthenticatedAccountShell.test.tsx create mode 100644 src/lib/utils/__tests__/account-redirect.test.ts create mode 100644 src/lib/utils/account-redirect.ts diff --git a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/addresses/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx rename to src/app/[country]/[locale]/(storefront)/account/(authenticated)/addresses/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/credit-cards/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx rename to src/app/[country]/[locale]/(storefront)/account/(authenticated)/credit-cards/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/gift-cards/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx rename to src/app/[country]/[locale]/(storefront)/account/(authenticated)/gift-cards/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.test.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.test.tsx new file mode 100644 index 00000000..d71a8563 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { REQUEST_PATHNAME_HEADER, REQUEST_SEARCH_HEADER } from "@/i18n/routing"; + +const mocks = vi.hoisted(() => ({ + accessToken: undefined as string | undefined, + refreshToken: undefined as string | undefined, + headers: vi.fn(), + redirect: vi.fn((location: string) => { + throw new Error(`redirect:${location}`); + }), +})); + +vi.mock("next/headers", () => ({ headers: mocks.headers })); +vi.mock("next/navigation", () => ({ redirect: mocks.redirect })); +vi.mock("@/lib/spree", () => ({ + getAccessToken: () => Promise.resolve(mocks.accessToken), + getRefreshToken: () => Promise.resolve(mocks.refreshToken), +})); +vi.mock("@/components/account/AuthenticatedAccountShell", () => ({ + AuthenticatedAccountShell: ({ + children, + loginHref, + }: { + children: React.ReactNode; + loginHref: string; + }) => ( +
+ {children} +
+ ), +})); + +import { AuthenticatedAccountLayoutContent } from "./layout"; + +function renderLayout() { + return AuthenticatedAccountLayoutContent({ + children:
Protected account content
, + params: Promise.resolve({ country: "us", locale: "en" }), + }); +} + +describe("AuthenticatedAccountLayoutContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.accessToken = undefined; + mocks.refreshToken = undefined; + mocks.headers.mockResolvedValue( + new Headers({ + [REQUEST_PATHNAME_HEADER]: "/us/en/account/orders", + [REQUEST_SEARCH_HEADER]: "?state=complete", + }), + ); + }); + + it("redirects an anonymous request before rendering protected chrome", async () => { + await expect(renderLayout()).rejects.toThrow( + "redirect:/us/en/account?redirect=%2Fus%2Fen%2Faccount%2Forders%3Fstate%3Dcomplete", + ); + expect(mocks.redirect).toHaveBeenCalledOnce(); + }); + + it("renders the protected route when an access token is present", async () => { + mocks.accessToken = "access-token"; + + render(await renderLayout()); + + expect(screen.getByText("Protected account content")).toBeInTheDocument(); + expect(screen.getByTestId("account-shell")).toHaveAttribute( + "data-login-href", + "/us/en/account?redirect=%2Fus%2Fen%2Faccount%2Forders%3Fstate%3Dcomplete", + ); + }); + + it("allows a refresh-only session to recover before client verification", async () => { + mocks.refreshToken = "refresh-token"; + + render(await renderLayout()); + + expect(screen.getByText("Protected account content")).toBeInTheDocument(); + expect(mocks.redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.tsx new file mode 100644 index 00000000..0168a8a7 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.tsx @@ -0,0 +1,51 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { AuthenticatedAccountShell } from "@/components/account/AuthenticatedAccountShell"; +import { REQUEST_PATHNAME_HEADER, REQUEST_SEARCH_HEADER } from "@/i18n/routing"; +import { getAccessToken, getRefreshToken } from "@/lib/spree"; +import { + buildAccountLoginHref, + resolveAccountRedirect, +} from "@/lib/utils/account-redirect"; + +interface AuthenticatedAccountLayoutProps { + children: React.ReactNode; + params: Promise<{ country: string; locale: string }>; +} + +export default function AuthenticatedAccountLayout( + props: AuthenticatedAccountLayoutProps, +) { + return ( + + + + ); +} + +export async function AuthenticatedAccountLayoutContent({ + children, + params, +}: AuthenticatedAccountLayoutProps) { + const [{ country, locale }, requestHeaders, accessToken, refreshToken] = + await Promise.all([params, headers(), getAccessToken(), getRefreshToken()]); + + const basePath = `/${country}/${locale}`; + const pathname = requestHeaders.get(REQUEST_PATHNAME_HEADER); + const search = requestHeaders.get(REQUEST_SEARCH_HEADER); + const requestedPath = `${pathname ?? ""}${search?.startsWith("?") ? search : ""}`; + const returnTo = resolveAccountRedirect(requestedPath, basePath); + const loginHref = buildAccountLoginHref(basePath, returnTo); + + // A refresh token is also a recoverable session credential. Let the client + // session action rotate it in a cookie-writable context before deciding that + // the customer is anonymous. + if (!accessToken && !refreshToken) redirect(loginHref); + + return ( + + {children} + + ); +} diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/orders/[id]/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx rename to src/app/[country]/[locale]/(storefront)/account/(authenticated)/orders/[id]/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/orders/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/orders/page.tsx rename to src/app/[country]/[locale]/(storefront)/account/(authenticated)/orders/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/profile/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/profile/page.tsx rename to src/app/[country]/[locale]/(storefront)/account/(authenticated)/profile/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/layout.tsx deleted file mode 100644 index 407c631b..00000000 --- a/src/app/[country]/[locale]/(storefront)/account/layout.tsx +++ /dev/null @@ -1,214 +0,0 @@ -"use client"; - -import type { LucideIcon } from "lucide-react"; -import { - CreditCard, - Gift, - Home, - LogOut, - MapPin, - ShoppingBag, - User, -} from "lucide-react"; -import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { useAuth } from "@/contexts/AuthContext"; -import { extractBasePath } from "@/lib/utils/path"; - -function getNavItems(t: ReturnType>): { - href: string; - label: string; - icon: LucideIcon; -}[] { - return [ - { href: "/account", label: t("overview"), icon: Home }, - { href: "/account/orders", label: t("orders"), icon: ShoppingBag }, - { href: "/account/addresses", label: t("addresses"), icon: MapPin }, - { - href: "/account/credit-cards", - label: t("paymentMethods"), - icon: CreditCard, - }, - { href: "/account/gift-cards", label: t("giftCards"), icon: Gift }, - { href: "/account/profile", label: t("profile"), icon: User }, - ]; -} - -function ContentSkeleton() { - return ( -
-
-
-
-
-
- ); -} - -interface AccountShellProps { - children: React.ReactNode; - basePath: string; - pathname: string; - user?: { - first_name?: string | null; - last_name?: string | null; - email?: string; - } | null; - onLogout?: () => void; - isLoading?: boolean; -} - -function AccountShell({ - children, - basePath, - pathname, - user, - onLogout, - isLoading, -}: AccountShellProps) { - const t = useTranslations("account"); - const navItems = getNavItems(t); - return ( -
-
- {/* Sidebar Navigation */} - - - {/* Main Content */} -
{children}
-
-
- ); -} - -export default function AccountLayout({ - children, -}: { - children: React.ReactNode; -}) { - const pathname = usePathname(); - const router = useRouter(); - const basePath = extractBasePath(pathname); - const { user, logout, isAuthenticated, loading } = useAuth(); - - // Pages that don't require authentication - const authPagePaths = new Set([ - `${basePath}/account/register`, - `${basePath}/account/forgot-password`, - `${basePath}/account/reset-password`, - ]); - const isAuthPage = authPagePaths.has(pathname); - const isMainAccountPage = pathname === `${basePath}/account`; - - // Redirect to login if not authenticated and trying to access protected sub-pages - useEffect(() => { - if (!loading && !isAuthenticated && !isAuthPage && !isMainAccountPage) { - router.replace(`${basePath}/account`); - } - }, [ - loading, - isAuthenticated, - isAuthPage, - isMainAccountPage, - basePath, - router, - ]); - - // Show loading or redirect-in-progress skeleton - if (loading || (!isAuthenticated && !isAuthPage && !isMainAccountPage)) { - if (isAuthPage || isMainAccountPage) { - return ( -
-
-
-
-
-
-
- ); - } - return ( - - - - ); - } - - // Don't show nav for login/register pages - if (isAuthPage || !isAuthenticated) { - return <>{children}; - } - - return ( - - {children} - - ); -} diff --git a/src/app/[country]/[locale]/(storefront)/account/page.tsx b/src/app/[country]/[locale]/(storefront)/account/page.tsx index 539605f6..878116b1 100644 --- a/src/app/[country]/[locale]/(storefront)/account/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/page.tsx @@ -13,6 +13,7 @@ import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import { AccountShell } from "@/components/account/AccountShell"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -26,6 +27,7 @@ import { import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/contexts/AuthContext"; +import { resolveAccountRedirect } from "@/lib/utils/account-redirect"; import { extractBasePath } from "@/lib/utils/path"; export default function AccountPage() { @@ -37,7 +39,10 @@ export default function AccountPage() { const { login, isAuthenticated, loading: authLoading } = useAuth(); // Get redirect URL from query params (e.g., from checkout) - const redirectUrl = searchParams.get("redirect"); + const redirectUrl = resolveAccountRedirect( + searchParams.get("redirect"), + basePath, + ); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -182,84 +187,86 @@ export default function AccountPage() { // Show account dashboard if authenticated return ( -
-

- {t("accountOverview")} -

+ +
+

+ {t("accountOverview")} +

-
- - - -
- -
-
-

- {t("orderHistory")} -

-

- {t("orderHistoryDescription")} -

-
-
-
- +
+ + + +
+ +
+
+

+ {t("orderHistory")} +

+

+ {t("orderHistoryDescription")} +

+
+
+
+ - - - -
- -
-
-

- {t("addresses")} -

-

- {t("addressesDescription")} -

-
-
-
- + + + +
+ +
+
+

+ {t("addresses")} +

+

+ {t("addressesDescription")} +

+
+
+
+ - - - -
- -
-
-

- {t("paymentMethods")} -

-

- {t("paymentMethodsDescription")} -

-
-
-
- + + + +
+ +
+
+

+ {t("paymentMethods")} +

+

+ {t("paymentMethodsDescription")} +

+
+
+
+ - - - -
- -
-
-

- {t("profile")} -

-

- {t("profileDescription")} -

-
-
-
- + + + +
+ +
+
+

+ {t("profile")} +

+

+ {t("profileDescription")} +

+
+
+
+ +
-
+
); } diff --git a/src/components/account/AccountShell.tsx b/src/components/account/AccountShell.tsx new file mode 100644 index 00000000..f63f6168 --- /dev/null +++ b/src/components/account/AccountShell.tsx @@ -0,0 +1,111 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { + CreditCard, + Gift, + Home, + LogOut, + MapPin, + ShoppingBag, + User, +} from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/AuthContext"; +import { extractBasePath } from "@/lib/utils/path"; + +function getNavItems(t: ReturnType>): { + href: string; + label: string; + icon: LucideIcon; +}[] { + return [ + { href: "/account", label: t("overview"), icon: Home }, + { href: "/account/orders", label: t("orders"), icon: ShoppingBag }, + { href: "/account/addresses", label: t("addresses"), icon: MapPin }, + { + href: "/account/credit-cards", + label: t("paymentMethods"), + icon: CreditCard, + }, + { href: "/account/gift-cards", label: t("giftCards"), icon: Gift }, + { href: "/account/profile", label: t("profile"), icon: User }, + ]; +} + +export function AccountShell({ children }: { children: React.ReactNode }) { + const t = useTranslations("account"); + const pathname = usePathname(); + const router = useRouter(); + const basePath = extractBasePath(pathname); + const { user, logout } = useAuth(); + const navItems = getNavItems(t); + + const handleLogout = async () => { + await logout(); + router.replace(`${basePath}/account`); + }; + + return ( +
+
+ {/* Sidebar Navigation */} + + + {/* Main Content */} +
{children}
+
+
+ ); +} diff --git a/src/components/account/AuthenticatedAccountShell.tsx b/src/components/account/AuthenticatedAccountShell.tsx new file mode 100644 index 00000000..67b596ff --- /dev/null +++ b/src/components/account/AuthenticatedAccountShell.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useEffect } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { AccountShell } from "./AccountShell"; + +function SessionFallback() { + const t = useTranslations("common"); + + return ( +
+
+ {t("loading")} +