diff --git a/src/app/(customer)/(shop)/addresses/page.tsx b/src/app/(customer)/(shop)/addresses/page.tsx
index 9698b76..505294d 100644
--- a/src/app/(customer)/(shop)/addresses/page.tsx
+++ b/src/app/(customer)/(shop)/addresses/page.tsx
@@ -1,4 +1,5 @@
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
+import { requireSession } from "@/lib/auth/session";
import type { Address, City, Neighborhood } from "@/types/api";
import { AddressList } from "@/components/customer/address-list";
@@ -21,6 +22,7 @@ async function getNeighborhoods() {
}
export default async function AddressesPage() {
+ await requireSession("/addresses");
const [addresses, cities, neighborhoods] = await Promise.all([getAddresses(), getCities(), getNeighborhoods()]);
return (
diff --git a/src/app/(customer)/(shop)/cart/page.tsx b/src/app/(customer)/(shop)/cart/page.tsx
index 4bacd17..331d8ec 100644
--- a/src/app/(customer)/(shop)/cart/page.tsx
+++ b/src/app/(customer)/(shop)/cart/page.tsx
@@ -1,9 +1,11 @@
import Link from "next/link";
import { getCart } from "@/lib/actions/cart";
+import { requireSession } from "@/lib/auth/session";
import { CartItemRow } from "@/components/customer/cart-item-row";
import { Button } from "@/components/ui/button";
export default async function CartPage() {
+ await requireSession("/cart");
const cart = await getCart();
const hasBlockedGroup = cart.groups.some((g) => !g.meets_minimum_order);
diff --git a/src/app/(customer)/(shop)/checkout/page.tsx b/src/app/(customer)/(shop)/checkout/page.tsx
index 57dfcff..d60797f 100644
--- a/src/app/(customer)/(shop)/checkout/page.tsx
+++ b/src/app/(customer)/(shop)/checkout/page.tsx
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { getCart } from "@/lib/actions/cart";
+import { requireSession } from "@/lib/auth/session";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import type { Address } from "@/types/api";
import { CheckoutForm } from "@/components/customer/checkout-form";
@@ -10,6 +11,7 @@ async function getAddresses() {
}
export default async function CheckoutPage() {
+ await requireSession("/checkout");
const [cart, addresses] = await Promise.all([getCart(), getAddresses()]);
if (cart.groups.length === 0) redirect("/cart");
diff --git a/src/app/(customer)/(shop)/layout.tsx b/src/app/(customer)/(shop)/layout.tsx
index dc61bbe..5ed4d52 100644
--- a/src/app/(customer)/(shop)/layout.tsx
+++ b/src/app/(customer)/(shop)/layout.tsx
@@ -1,9 +1,9 @@
import Link from "next/link";
-import { getSession } from "@/lib/auth/session";
+import { getValidSession } from "@/lib/auth/session";
import { LogoutButton } from "@/components/customer/logout-button";
export default async function ShopLayout({ children }: LayoutProps<"/">) {
- const session = await getSession();
+ const session = await getValidSession();
return (
diff --git a/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx
index c435c80..5563862 100644
--- a/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx
+++ b/src/app/(customer)/(shop)/order-groups/[uuid]/page.tsx
@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { winofyFetch, ApiError } from "@/lib/api/winofy";
+import { requireSession } from "@/lib/auth/session";
import { OrderCard } from "@/components/customer/order-card";
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
import type { OrderGroup } from "@/types/api";
@@ -15,6 +16,7 @@ async function getOrderGroup(uuid: string) {
export default async function OrderGroupPage({ params }: PageProps<"/order-groups/[uuid]">) {
const { uuid } = await params;
+ await requireSession(`/order-groups/${uuid}`);
const orderGroup = await getOrderGroup(uuid);
if (!orderGroup) notFound();
diff --git a/src/app/(customer)/(shop)/orders/page.tsx b/src/app/(customer)/(shop)/orders/page.tsx
index 76af803..f20fc51 100644
--- a/src/app/(customer)/(shop)/orders/page.tsx
+++ b/src/app/(customer)/(shop)/orders/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
+import { requireSession } from "@/lib/auth/session";
import { PAYMENT_STATUS_LABELS_FA } from "@/lib/order-status";
import type { OrderGroup } from "@/types/api";
@@ -9,6 +10,7 @@ async function getOrderGroups() {
}
export default async function OrdersPage() {
+ await requireSession("/orders");
const orderGroups = await getOrderGroups();
return (
diff --git a/src/app/(customer)/(shop)/profile/page.tsx b/src/app/(customer)/(shop)/profile/page.tsx
index b96a46c..415b538 100644
--- a/src/app/(customer)/(shop)/profile/page.tsx
+++ b/src/app/(customer)/(shop)/profile/page.tsx
@@ -1,9 +1,11 @@
import Link from "next/link";
+import { requireSession } from "@/lib/auth/session";
import { LogoutButton } from "@/components/customer/logout-button";
// Minimal placeholder — winofyfrontenddoc.md's bottom-nav references a "پروفایل"
// tab, but no profile screen is documented yet in either doc.
-export default function ProfilePage() {
+export default async function ProfilePage() {
+ await requireSession("/profile");
return (
پروفایل
diff --git a/src/app/(customer)/page.tsx b/src/app/(customer)/page.tsx
index 0c57d04..37683c8 100644
--- a/src/app/(customer)/page.tsx
+++ b/src/app/(customer)/page.tsx
@@ -1,4 +1,4 @@
-import { getSession } from "@/lib/auth/session";
+import { getValidSession } from "@/lib/auth/session";
import { getLocationServer } from "@/lib/location/server";
import { winofyFetch, type PaginatedResponse } from "@/lib/api/winofy";
import { getCart } from "@/lib/actions/cart";
@@ -31,7 +31,7 @@ export default async function HomePage({ searchParams }: PageProps<"/">) {
const category = typeof params.category === "string" ? params.category : undefined;
const search = typeof params.search === "string" ? params.search : undefined;
- const [session, location] = await Promise.all([getSession(), getLocationServer()]);
+ const [session, location] = await Promise.all([getValidSession(), getLocationServer()]);
const isAuthenticated = !!session;
const [categories, stores, cart] = await Promise.all([
diff --git a/src/lib/api/winofy.ts b/src/lib/api/winofy.ts
index 8e94c70..942e336 100644
--- a/src/lib/api/winofy.ts
+++ b/src/lib/api/winofy.ts
@@ -1,6 +1,5 @@
import "server-only";
-import { getSession, createSession } from "@/lib/auth/session";
-import { refreshAccessToken } from "@/lib/auth/gooyal";
+import { getValidSession } from "@/lib/auth/session";
import { ApiError, type WinofyErrorDetails } from "./errors";
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
@@ -12,28 +11,6 @@ interface WinofyFetchOptions extends Omit {
body?: unknown;
}
-async function getValidAccessToken(): Promise {
- const session = await getSession();
- if (!session) return null;
-
- // 30s skew so we don't hand out a token that expires mid-request.
- if (Date.now() < session.expiresAt - 30_000) return session.accessToken;
- if (!session.refreshToken) return session.accessToken;
-
- try {
- const refreshed = await refreshAccessToken(session.refreshToken);
- await createSession({
- accessToken: refreshed.access_token,
- refreshToken: refreshed.refresh_token ?? session.refreshToken,
- tokenType: refreshed.token_type,
- expiresAt: Date.now() + refreshed.expires_in * 1000,
- });
- return refreshed.access_token;
- } catch {
- return null;
- }
-}
-
export interface PaginatedResponse {
count: number;
next: string | null;
@@ -58,8 +35,8 @@ export async function winofyFetch(path: string, options: WinofyFetchOptions =
}
if (auth) {
- const token = await getValidAccessToken();
- if (token) finalHeaders.set("Authorization", `Bearer ${token}`);
+ const session = await getValidSession();
+ if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
}
const res = await fetch(url.toString(), {
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
index 8e5d947..a27f9e8 100644
--- a/src/lib/auth/session.ts
+++ b/src/lib/auth/session.ts
@@ -1,6 +1,9 @@
import "server-only";
+import { cache } from "react";
+import { redirect } from "next/navigation";
import { EncryptJWT, jwtDecrypt } from "jose";
import { cookies } from "next/headers";
+import { refreshAccessToken } from "./gooyal";
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error("SESSION_SECRET is not set");
@@ -24,15 +27,30 @@ export async function createSession(tokens: SessionTokens) {
.encrypt(encodedKey);
const store = await cookies();
- store.set(COOKIE_NAME, jwe, {
- httpOnly: true,
- secure: process.env.NODE_ENV === "production",
- sameSite: "lax",
- path: "/",
- maxAge: 60 * 60 * 24 * 30,
- });
+ try {
+ store.set(COOKIE_NAME, jwe, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ path: "/",
+ maxAge: 60 * 60 * 24 * 30,
+ });
+ } catch {
+ // Next.js forbids writing cookies during a plain Server Component render
+ // (only Server Actions/Route Handlers may). getValidSession() calls this
+ // from both — when called from a render, the refreshed token still gets
+ // used for the rest of *this* request (the caller has the return value),
+ // it just won't persist; the next request re-runs the same refresh. Never
+ // let that turn into a crash.
+ }
}
+/**
+ * Raw read — decrypts the cookie but does NOT check whether the access token
+ * inside it has expired. The JWE wrapper lives for 30 days regardless of the
+ * real Gooyal token's much shorter `expires_in`, so a truthy result here does
+ * NOT mean the session is still usable against the API. Prefer getValidSession().
+ */
export async function getSession(): Promise {
const store = await cookies();
const value = store.get(COOKIE_NAME)?.value;
@@ -47,7 +65,63 @@ export async function getSession(): Promise {
export async function deleteSession() {
const store = await cookies();
- store.delete(COOKIE_NAME);
+ try {
+ store.delete(COOKIE_NAME);
+ } catch {
+ // Same rationale as createSession()'s catch — safe to no-op here.
+ }
+}
+
+/**
+ * The actual source of truth for "is this user logged in" — refreshes an
+ * expired access token when possible, and self-heals by clearing the cookie
+ * when it isn't (dead/expired refresh token), rather than leaving a session
+ * that decrypts fine but 401s on every real API call. Deduped per request via
+ * React's cache() since both a layout's isAuthenticated check and any
+ * winofyFetch call on the same page would otherwise each trigger their own
+ * refresh attempt.
+ */
+export const getValidSession = cache(async (): Promise => {
+ const session = await getSession();
+ if (!session) return null;
+
+ // 30s skew so we don't hand out a token that expires mid-request.
+ if (Date.now() < session.expiresAt - 30_000) return session;
+
+ if (!session.refreshToken) {
+ await deleteSession();
+ return null;
+ }
+
+ try {
+ const refreshed = await refreshAccessToken(session.refreshToken);
+ const nextSession: SessionTokens = {
+ accessToken: refreshed.access_token,
+ refreshToken: refreshed.refresh_token ?? session.refreshToken,
+ tokenType: refreshed.token_type,
+ expiresAt: Date.now() + refreshed.expires_in * 1000,
+ };
+ await createSession(nextSession);
+ return nextSession;
+ } catch {
+ await deleteSession();
+ return null;
+ }
+});
+
+/**
+ * For pages under proxy.ts's auth-required prefixes: the proxy only checks
+ * cookie *presence* (optimistic — see proxy.ts), so a present-but-dead session
+ * still reaches the page. Call this at the top of any such page instead of
+ * fetching data straight away, so a dead session redirects to /login instead
+ * of the page crashing on an uncaught 401 from winofyFetch.
+ */
+export async function requireSession(currentPath: string): Promise {
+ const session = await getValidSession();
+ if (!session) {
+ redirect(`/login?next=${encodeURIComponent(currentPath)}`);
+ }
+ return session;
}
export const SESSION_COOKIE_NAME = COOKIE_NAME;