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;