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 (
+
+ );
+}
+
+interface AuthenticatedAccountShellProps {
+ children: React.ReactNode;
+ loginHref: string;
+}
+
+/**
+ * The server layout rejects requests with no session credentials. This client
+ * boundary verifies the remaining session before exposing account chrome and
+ * preserves the existing refresh-token recovery flow.
+ */
+export function AuthenticatedAccountShell({
+ children,
+ loginHref,
+}: AuthenticatedAccountShellProps) {
+ const router = useRouter();
+ const { isAuthenticated, loading } = useAuth();
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) router.replace(loginHref);
+ }, [isAuthenticated, loading, loginHref, router]);
+
+ if (loading || !isAuthenticated) return ;
+
+ return {children};
+}
diff --git a/src/components/account/__tests__/AuthenticatedAccountShell.test.tsx b/src/components/account/__tests__/AuthenticatedAccountShell.test.tsx
new file mode 100644
index 00000000..fb1b3104
--- /dev/null
+++ b/src/components/account/__tests__/AuthenticatedAccountShell.test.tsx
@@ -0,0 +1,80 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ auth: {
+ isAuthenticated: false,
+ loading: true,
+ },
+ replace: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ replace: mocks.replace }),
+}));
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}));
+vi.mock("@/contexts/AuthContext", () => ({
+ useAuth: () => mocks.auth,
+}));
+vi.mock("@/components/account/AccountShell", () => ({
+ AccountShell: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+import { AuthenticatedAccountShell } from "../AuthenticatedAccountShell";
+
+describe("AuthenticatedAccountShell", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.auth.isAuthenticated = false;
+ mocks.auth.loading = true;
+ });
+
+ it("does not reveal account chrome while the session is loading", () => {
+ render(
+
+ Protected account content
+ ,
+ );
+
+ expect(screen.queryByTestId("account-shell")).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("Protected account content"),
+ ).not.toBeInTheDocument();
+ expect(mocks.replace).not.toHaveBeenCalled();
+ });
+
+ it("redirects a rejected session without revealing account chrome", async () => {
+ mocks.auth.loading = false;
+
+ render(
+
+ Protected account content
+ ,
+ );
+
+ expect(screen.queryByTestId("account-shell")).not.toBeInTheDocument();
+ await waitFor(() => {
+ expect(mocks.replace).toHaveBeenCalledWith(
+ "/us/en/account?redirect=%2Fus%2Fen%2Faccount%2Forders",
+ );
+ });
+ });
+
+ it("renders account chrome only after authentication succeeds", () => {
+ mocks.auth.loading = false;
+ mocks.auth.isAuthenticated = true;
+
+ render(
+
+ Protected account content
+ ,
+ );
+
+ expect(screen.getByTestId("account-shell")).toBeInTheDocument();
+ expect(screen.getByText("Protected account content")).toBeInTheDocument();
+ });
+});
diff --git a/src/lib/spree/__tests__/auth-helpers.test.ts b/src/lib/spree/__tests__/auth-helpers.test.ts
index 3555e716..0f786ebc 100644
--- a/src/lib/spree/__tests__/auth-helpers.test.ts
+++ b/src/lib/spree/__tests__/auth-helpers.test.ts
@@ -99,4 +99,18 @@ describe("ensureFreshSession refresh resilience", () => {
expect(second).toBe("refreshed");
expect(mockClient.auth.refresh).toHaveBeenCalledTimes(1);
});
+
+ it("recovers a refresh-only session after the access cookie expires", async () => {
+ mockCookieState.access = undefined;
+ mockClient.auth.refresh.mockResolvedValueOnce({
+ token: "new-jwt",
+ refresh_token: "rt-2",
+ });
+
+ const state = await ensureFreshSession();
+
+ expect(state).toBe("refreshed");
+ expect(mockCookieState.access).toBe("new-jwt");
+ expect(mockCookieState.refresh).toBe("rt-2");
+ });
});
diff --git a/src/lib/spree/auth-helpers.ts b/src/lib/spree/auth-helpers.ts
index d9156220..8ddde3ac 100644
--- a/src/lib/spree/auth-helpers.ts
+++ b/src/lib/spree/auth-helpers.ts
@@ -96,7 +96,16 @@ export async function withAuthRefresh(
*/
export async function ensureFreshSession(): Promise {
const token = await getAccessToken();
- if (!token) return "anonymous";
+ if (!token) {
+ const refreshToken = await getRefreshToken();
+ if (!refreshToken) return "anonymous";
+
+ const newToken = await tryRefresh();
+ if (newToken) return "refreshed";
+
+ const survivingRefreshToken = await getRefreshToken();
+ return survivingRefreshToken ? "stale" : "expired";
+ }
if (!isJwtExpired(token, 30)) return "valid";
diff --git a/src/lib/spree/middleware.test.ts b/src/lib/spree/middleware.test.ts
index 1d56f8c2..8183f6d5 100644
--- a/src/lib/spree/middleware.test.ts
+++ b/src/lib/spree/middleware.test.ts
@@ -83,4 +83,56 @@ describe("Spree locale middleware", () => {
response.headers.get("x-middleware-request-x-spree-request-search"),
).toBe("?sort=price");
});
+
+ it("redirects an anonymous protected account request to sign in", () => {
+ const response = middleware(
+ new NextRequest(
+ "https://store.example/us/en/account/orders?state=complete",
+ ),
+ );
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get("location")).toBe(
+ "https://store.example/us/en/account?redirect=%2Fus%2Fen%2Faccount%2Forders%3Fstate%3Dcomplete",
+ );
+ });
+
+ it.each([
+ "/us/en/account",
+ "/us/en/account/register",
+ "/us/en/account/forgot-password",
+ "/us/en/account/reset-password?token=reset-token",
+ ])("keeps the public account route accessible: %s", (pathname) => {
+ const response = middleware(
+ new NextRequest(`https://store.example${pathname}`),
+ );
+
+ expect(response.headers.get("location")).toBeNull();
+ });
+
+ it.each([
+ "_spree_jwt",
+ "_spree_refresh_token",
+ ])("allows a protected account request with a %s session cookie", (cookieName) => {
+ const request = new NextRequest(
+ "https://store.example/us/en/account/orders",
+ );
+ request.cookies.set(cookieName, "session-token");
+
+ const response = middleware(request);
+
+ expect(response.headers.get("location")).toBeNull();
+ });
+
+ it("does not treat an empty auth cookie as a session credential", () => {
+ const request = new NextRequest(
+ "https://store.example/us/en/account/orders",
+ );
+ request.cookies.set("_spree_jwt", "");
+
+ const response = middleware(request);
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get("location")).toContain("/us/en/account?");
+ });
});
diff --git a/src/lib/spree/middleware.ts b/src/lib/spree/middleware.ts
index 1740526b..45e970ef 100644
--- a/src/lib/spree/middleware.ts
+++ b/src/lib/spree/middleware.ts
@@ -6,9 +6,12 @@ import {
negotiateLocale,
} from "@/i18n/normalize";
import { REQUEST_PATHNAME_HEADER, REQUEST_SEARCH_HEADER } from "@/i18n/routing";
+import { buildAccountLoginHref } from "@/lib/utils/account-redirect";
const COUNTRY_COOKIE = "spree_country";
const LOCALE_COOKIE = "spree_locale";
+const ACCESS_TOKEN_COOKIE = "_spree_jwt";
+const REFRESH_TOKEN_COOKIE = "_spree_refresh_token";
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
const HAS_COUNTRY_LOCALE =
@@ -23,6 +26,27 @@ export interface SpreeMiddlewareConfig {
supportedLocales?: readonly string[];
/** Routes to skip — prefixes matched with startsWith (default: ['/_next', '/api', '/favicon.ico']) */
staticRoutes?: string[];
+ /** JWT cookie used to identify a potentially authenticated account request. */
+ accessTokenCookieName?: string;
+ /** Refresh-token cookie used to preserve recoverable account sessions. */
+ refreshTokenCookieName?: string;
+}
+
+const PUBLIC_ACCOUNT_PATHS = new Set([
+ "/account",
+ "/account/register",
+ "/account/forgot-password",
+ "/account/reset-password",
+]);
+
+function isProtectedAccountPath(pathname: string, localizedPrefix: string) {
+ const localizedPath = pathname.slice(localizedPrefix.length);
+ const normalizedPath = localizedPath.replace(/\/+$/, "") || "/";
+
+ return (
+ normalizedPath.startsWith("/account/") &&
+ !PUBLIC_ACCOUNT_PATHS.has(normalizedPath)
+ );
}
/**
@@ -86,6 +110,10 @@ export function createSpreeMiddleware(
"/dev",
"/favicon.ico",
];
+ const accessTokenCookieName =
+ config.accessTokenCookieName ?? ACCESS_TOKEN_COOKIE;
+ const refreshTokenCookieName =
+ config.refreshTokenCookieName ?? REFRESH_TOKEN_COOKIE;
return function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -131,6 +159,22 @@ export function createSpreeMiddleware(
return response;
}
+ if (
+ isProtectedAccountPath(pathname, canonicalPrefix) &&
+ !request.cookies.get(accessTokenCookieName)?.value &&
+ !request.cookies.get(refreshTokenCookieName)?.value
+ ) {
+ const loginHref = buildAccountLoginHref(
+ canonicalPrefix,
+ `${pathname}${request.nextUrl.search}`,
+ );
+ const response = NextResponse.redirect(
+ new URL(loginHref, request.nextUrl),
+ );
+ setLocaleCookies(response, country, locale);
+ return response;
+ }
+
return nextWithLocaleContext(request, country, locale);
}
diff --git a/src/lib/utils/__tests__/account-redirect.test.ts b/src/lib/utils/__tests__/account-redirect.test.ts
new file mode 100644
index 00000000..d5ff15dd
--- /dev/null
+++ b/src/lib/utils/__tests__/account-redirect.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildAccountLoginHref,
+ resolveAccountRedirect,
+} from "../account-redirect";
+
+describe("resolveAccountRedirect", () => {
+ const basePath = "/us/en";
+
+ it.each([
+ [
+ "/us/en/account/orders?state=complete#latest",
+ "/us/en/account/orders?state=complete#latest",
+ ],
+ ["/us/en/checkout/cart_123", "/us/en/checkout/cart_123"],
+ ])("allows a localized account or checkout path", (redirect, expected) => {
+ expect(resolveAccountRedirect(redirect, basePath)).toBe(expected);
+ });
+
+ it.each([
+ "https://example.com/us/en/account/orders",
+ "//example.com/us/en/account/orders",
+ "/\\example.com/us/en/account/orders",
+ "/us/en/account%2forders",
+ "/fr/fr/account/orders",
+ "/us/en/products",
+ ])("rejects an unsafe return target: %s", (redirect) => {
+ expect(resolveAccountRedirect(redirect, basePath)).toBeNull();
+ });
+});
+
+describe("buildAccountLoginHref", () => {
+ it("adds a validated return target", () => {
+ expect(
+ buildAccountLoginHref("/us/en", "/us/en/account/orders?state=complete"),
+ ).toBe(
+ "/us/en/account?redirect=%2Fus%2Fen%2Faccount%2Forders%3Fstate%3Dcomplete",
+ );
+ });
+
+ it("falls back to the account page for an invalid target", () => {
+ expect(buildAccountLoginHref("/us/en", "https://example.com")).toBe(
+ "/us/en/account",
+ );
+ });
+});
diff --git a/src/lib/utils/account-redirect.ts b/src/lib/utils/account-redirect.ts
new file mode 100644
index 00000000..1333c292
--- /dev/null
+++ b/src/lib/utils/account-redirect.ts
@@ -0,0 +1,62 @@
+const INTERNAL_ORIGIN = "https://storefront.invalid";
+const ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i;
+
+function isAllowedLocalizedDestination(
+ pathname: string,
+ basePath: string,
+): boolean {
+ const accountPath = `${basePath}/account`;
+ const checkoutPath = `${basePath}/checkout`;
+
+ return (
+ pathname === accountPath ||
+ pathname.startsWith(`${accountPath}/`) ||
+ pathname === checkoutPath ||
+ pathname.startsWith(`${checkoutPath}/`)
+ );
+}
+
+/**
+ * Resolve a login return target without allowing cross-origin or cross-market
+ * navigation. Account and checkout are the only flows that send users through
+ * the account sign-in page today.
+ */
+export function resolveAccountRedirect(
+ redirect: string | null | undefined,
+ basePath: string,
+): string | null {
+ if (
+ !redirect?.startsWith("/") ||
+ redirect.startsWith("//") ||
+ redirect.includes("\\") ||
+ ENCODED_PATH_SEPARATOR.test(redirect)
+ ) {
+ return null;
+ }
+
+ try {
+ const target = new URL(redirect, INTERNAL_ORIGIN);
+ if (
+ target.origin !== INTERNAL_ORIGIN ||
+ !isAllowedLocalizedDestination(target.pathname, basePath)
+ ) {
+ return null;
+ }
+
+ return `${target.pathname}${target.search}${target.hash}`;
+ } catch {
+ return null;
+ }
+}
+
+export function buildAccountLoginHref(
+ basePath: string,
+ returnTo?: string | null,
+): string {
+ const accountPath = `${basePath}/account`;
+ const safeReturnTo = resolveAccountRedirect(returnTo, basePath);
+
+ if (!safeReturnTo || safeReturnTo === accountPath) return accountPath;
+
+ return `${accountPath}?redirect=${encodeURIComponent(safeReturnTo)}`;
+}
From 2095c3a5cba60e5ee81db6ea63a7f6c2e4acb1a4 Mon Sep 17 00:00:00 2001
From: laaichiu <134155205+laaichiu@users.noreply.github.com>
Date: Mon, 27 Jul 2026 10:19:46 +0800
Subject: [PATCH 2/2] fix: rebase login redirects on market switch
---
src/hooks/__tests__/useCountrySwitch.test.ts | 41 +++++++++++++
src/hooks/useCountrySwitch.ts | 10 +++-
.../utils/__tests__/account-redirect.test.ts | 36 ++++++++++++
src/lib/utils/account-redirect.ts | 58 +++++++++++++++++++
4 files changed, 143 insertions(+), 2 deletions(-)
diff --git a/src/hooks/__tests__/useCountrySwitch.test.ts b/src/hooks/__tests__/useCountrySwitch.test.ts
index 8fab1ff0..4e575953 100644
--- a/src/hooks/__tests__/useCountrySwitch.test.ts
+++ b/src/hooks/__tests__/useCountrySwitch.test.ts
@@ -22,6 +22,10 @@ vi.mock("@/lib/utils/cookies", () => ({
setStoreCookies: vi.fn(),
}));
+vi.mock("@/lib/utils/account-redirect", () => ({
+ rebaseAccountRedirectSearch: vi.fn((search: string) => search),
+}));
+
const mockUseCart = vi.mocked(useCart);
const mockUpdateCartMarket = vi.mocked(updateCartMarket);
const mockSetStoreCookies = vi.mocked(setStoreCookies);
@@ -116,4 +120,41 @@ describe("useCountrySwitch", () => {
expect(mockSetStoreCookies).toHaveBeenCalledWith("de", "de");
expect(mockAssign).toHaveBeenCalledWith("/de/de/products");
});
+
+ it("rebases a login return target when switching market", async () => {
+ const { rebaseAccountRedirectSearch } = await import(
+ "@/lib/utils/account-redirect"
+ );
+ vi.mocked(rebaseAccountRedirectSearch).mockReturnValueOnce(
+ "?redirect=%2Fde%2Fde%2Faccount%2Forders",
+ );
+
+ const { result } = renderHook(() =>
+ useCountrySwitch({
+ currentCountry: "us",
+ currentLocale: "en",
+ }),
+ );
+ const mockAssign = vi.fn();
+ vi.stubGlobal("window", {
+ location: {
+ assign: mockAssign,
+ hash: "",
+ search: "?redirect=%2Fus%2Fen%2Faccount%2Forders",
+ },
+ });
+
+ await act(async () => {
+ await result.current.handleCountrySelect(targetCountry, "de");
+ });
+
+ expect(rebaseAccountRedirectSearch).toHaveBeenCalledWith(
+ "?redirect=%2Fus%2Fen%2Faccount%2Forders",
+ "/us/en",
+ "/de/de",
+ );
+ expect(mockAssign).toHaveBeenCalledWith(
+ "/de/de/products?redirect=%2Fde%2Fde%2Faccount%2Forders",
+ );
+ });
});
diff --git a/src/hooks/useCountrySwitch.ts b/src/hooks/useCountrySwitch.ts
index 1fa0023a..63baea4b 100644
--- a/src/hooks/useCountrySwitch.ts
+++ b/src/hooks/useCountrySwitch.ts
@@ -5,6 +5,7 @@ import { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import type { CountryWithMarket } from "@/contexts/StoreContext";
import { updateCartMarket } from "@/lib/data/checkout";
+import { rebaseAccountRedirectSearch } from "@/lib/utils/account-redirect";
import { setStoreCookies } from "@/lib/utils/cookies";
import { getPathWithoutPrefix } from "@/lib/utils/path";
@@ -57,6 +58,8 @@ export function useCountrySwitch({
const newCurrency = entry.currency;
const pathRest = getPathWithoutPrefix(pathname);
const newPath = `/${nextCountry}/${newLocale}${pathRest}`;
+ const currentBasePath = `/${activeCountry}/${currentLocale}`;
+ const nextBasePath = `/${nextCountry}/${newLocale}`;
try {
if (
@@ -77,9 +80,12 @@ export function useCountrySwitch({
setStoreCookies(nextCountry, newLocale);
onBeforeNavigate?.();
- window.location.assign(
- `${newPath}${window.location.search}${window.location.hash}`,
+ const nextSearch = rebaseAccountRedirectSearch(
+ window.location.search,
+ currentBasePath,
+ nextBasePath,
);
+ window.location.assign(`${newPath}${nextSearch}${window.location.hash}`);
return true;
} catch {
return false;
diff --git a/src/lib/utils/__tests__/account-redirect.test.ts b/src/lib/utils/__tests__/account-redirect.test.ts
index d5ff15dd..2b1b418f 100644
--- a/src/lib/utils/__tests__/account-redirect.test.ts
+++ b/src/lib/utils/__tests__/account-redirect.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
buildAccountLoginHref,
+ rebaseAccountRedirect,
+ rebaseAccountRedirectSearch,
resolveAccountRedirect,
} from "../account-redirect";
@@ -44,3 +46,37 @@ describe("buildAccountLoginHref", () => {
);
});
});
+
+describe("rebaseAccountRedirect", () => {
+ it("moves a localized target while preserving its suffix and query", () => {
+ expect(
+ rebaseAccountRedirect(
+ "/pl/de/account/orders?state=complete#latest",
+ "/pl/de",
+ "/us/en",
+ ),
+ ).toBe("/us/en/account/orders?state=complete#latest");
+ });
+
+ it("updates the redirect parameter and preserves other search params", () => {
+ expect(
+ rebaseAccountRedirectSearch(
+ "?redirect=%2Fpl%2Fde%2Faccount%2Forders%3Fstate%3Dcomplete&source=login",
+ "/pl/de",
+ "/fr/fr",
+ ),
+ ).toBe(
+ "?redirect=%2Ffr%2Ffr%2Faccount%2Forders%3Fstate%3Dcomplete&source=login",
+ );
+ });
+
+ it("drops an invalid redirect instead of carrying it across markets", () => {
+ expect(
+ rebaseAccountRedirectSearch(
+ "?redirect=https%3A%2F%2Fevil.example%2Faccount&source=login",
+ "/pl/de",
+ "/us/en",
+ ),
+ ).toBe("?source=login");
+ });
+});
diff --git a/src/lib/utils/account-redirect.ts b/src/lib/utils/account-redirect.ts
index 1333c292..5cb25c64 100644
--- a/src/lib/utils/account-redirect.ts
+++ b/src/lib/utils/account-redirect.ts
@@ -60,3 +60,61 @@ export function buildAccountLoginHref(
return `${accountPath}?redirect=${encodeURIComponent(safeReturnTo)}`;
}
+
+/**
+ * Move a validated localized account/checkout target to a new market prefix.
+ * The page suffix, query string, and hash belong to the target and must stay
+ * intact when the storefront country or locale changes.
+ */
+export function rebaseAccountRedirect(
+ redirect: string | null | undefined,
+ currentBasePath: string,
+ nextBasePath: string,
+): string | null {
+ const safeRedirect = resolveAccountRedirect(redirect, currentBasePath);
+ if (!safeRedirect) return null;
+
+ const target = new URL(safeRedirect, INTERNAL_ORIGIN);
+ const currentPrefix = `${currentBasePath}/`;
+ const nextPath =
+ target.pathname === currentBasePath
+ ? nextBasePath
+ : target.pathname.startsWith(currentPrefix)
+ ? `${nextBasePath}${target.pathname.slice(currentBasePath.length)}`
+ : null;
+
+ if (!nextPath) return null;
+
+ return resolveAccountRedirect(
+ `${nextPath}${target.search}${target.hash}`,
+ nextBasePath,
+ );
+}
+
+/**
+ * Rebase a login return target inside a URL search string. Invalid redirect
+ * values are discarded instead of being carried into another market.
+ */
+export function rebaseAccountRedirectSearch(
+ search: string,
+ currentBasePath: string,
+ nextBasePath: string,
+): string {
+ const params = new URLSearchParams(search);
+ const redirect = params.get("redirect");
+ if (redirect === null) return search;
+
+ const rebasedRedirect = rebaseAccountRedirect(
+ redirect,
+ currentBasePath,
+ nextBasePath,
+ );
+ if (rebasedRedirect) {
+ params.set("redirect", rebasedRedirect);
+ } else {
+ params.delete("redirect");
+ }
+
+ const serialized = params.toString();
+ return serialized ? `?${serialized}` : "";
+}