another step
This commit is contained in:
+65
-13
@@ -20,8 +20,8 @@ i18n, cookies, and the rules every change must follow.
|
||||
- **MUI v9** (`@mui/material`) for components and theming; **Emotion** underneath (RTL via
|
||||
`stylis-plugin-rtl`).
|
||||
- **next-intl v4** for i18n — locales `fa` (default, RTL) and `en`.
|
||||
- **TanStack Query v5** for server state; a small **AppStore** (React context + reducer, `src/store/`)
|
||||
for client state.
|
||||
- **TanStack Query v5** for server state; a small **AuthContext** (React context + reducer,
|
||||
`src/context/auth/`, seeded with server-read auth state) for auth/session state.
|
||||
- **notistack** for toasts; **js-cookie** (wrapped) for client cookies.
|
||||
- **Jest** + **Testing Library** for unit tests.
|
||||
- Quality gates: **tsc**, **ESLint 9** (flat config), **Prettier**.
|
||||
@@ -58,6 +58,10 @@ Rules for this project:
|
||||
- **This project is flat-config only.** Do not add `.eslintrc*` files — put any rule changes in
|
||||
`eslint.config.mjs`.
|
||||
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
|
||||
- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from
|
||||
eslint-config-next's default `warn` to **`error`** (in `eslint.config.mjs`), so dead code fails
|
||||
`npm run check`. Delete unused code rather than disabling the rule; prefix a deliberately-unused
|
||||
binding with `_` (e.g. `_event`, `catch (_err)`) to opt out.
|
||||
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — e.g. a
|
||||
deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
|
||||
scoped `// eslint-disable-next-line <rule>` with a one-line reason, never a file-wide disable.
|
||||
@@ -86,9 +90,16 @@ A change is "done" only if it respects all of these — each has a full section
|
||||
8. **Shared components get a co-located `*.test.tsx`.** (A component imported from >1 place.)
|
||||
9. **Magic strings become named constants** (`src/constants/` or a co-located `constants.ts`).
|
||||
10. **`npm run check` is green** and translations stay in sync before you finish.
|
||||
11. **No dead code; comment the *why*, not the *what*.** Unused vars/imports are lint errors — remove
|
||||
them. Don't add comments that restate the code; comment only a non-obvious decision, constraint, or
|
||||
trade-off. See **Comments & dead code** below.
|
||||
|
||||
## Project Structure
|
||||
|
||||
**This section is the canonical description of the client's architecture.** When a change adds, removes,
|
||||
or renames a route group, provider, or top-level `src/` folder, update this tree in the same change
|
||||
(root `CLAUDE.md` working agreement #7).
|
||||
|
||||
```
|
||||
client/
|
||||
├── messages/ # Translation files (add keys to BOTH files)
|
||||
@@ -101,7 +112,7 @@ client/
|
||||
│ ├── globals.css
|
||||
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
|
||||
│ └── [locale]/
|
||||
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AppStoreProvider
|
||||
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState)
|
||||
│ ├── (private-routes)/
|
||||
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout
|
||||
│ │ └── page.tsx
|
||||
@@ -129,6 +140,9 @@ client/
|
||||
│ │ ├── client.ts # clientFetch<T> — throws ApiError on error; use in hooks/client components
|
||||
│ │ ├── server.ts # serverFetch<T> — throws ApiError on error; use in RSCs/Server Actions
|
||||
│ │ └── errors.ts # ApiError class (status, message, code)
|
||||
│ ├── auth/
|
||||
│ │ ├── token.ts # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers)
|
||||
│ │ └── server.ts # getServerAuthState — access-token cookie → AuthState for AuthProvider
|
||||
│ ├── query/
|
||||
│ │ ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton
|
||||
│ │ └── QueryProvider.tsx # 'use client' — QueryClientProvider + ReactQueryDevtools
|
||||
@@ -146,7 +160,8 @@ client/
|
||||
│ │ └── serverApi.ts # Namespace object wrapping serverFetch calls (only when needed)
|
||||
│ └── hooks/
|
||||
│ └── use{Action}.ts # One hook per file — useQuery or useMutation
|
||||
├── store/ # AppStore (Redux-like client state)
|
||||
├── context/ # React context providers
|
||||
│ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth
|
||||
├── theme/
|
||||
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper + ColorSchemeScript + ColorSchemeCookieSync
|
||||
│ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE
|
||||
@@ -174,7 +189,7 @@ client/
|
||||
- Loads the Mikhak font and attaches its CSS-variable class to `<html>` **only for `fa`** (see Fonts).
|
||||
- Calls `setRequestLocale(locale)` so server components deeper in the tree can call `getLocale()` / `getTranslations()` reliably.
|
||||
- Calls `getMessages({ locale })` with the locale passed **explicitly** so `getRequestConfig` receives it via `Promise.resolve(locale)` (not through the React.cache read), avoiding any cache-ordering race.
|
||||
- Wraps children with `NextIntlClientProvider`, `AppStoreProvider`, and `ThemeProvider`.
|
||||
- Wraps children with `NextIntlClientProvider`, `AuthProvider` (seeded with server-read auth state), and `ThemeProvider`.
|
||||
- Exports `generateStaticParams` so Next.js can enumerate locale routes at build time.
|
||||
|
||||
**WHY `<html>` MUST live in `[locale]/layout.tsx` and not a layout above it**: a layout above the `[locale]` segment is *shared* between `/fa` and `/en`. Next.js statically caches it at build time with `defaultLocale` ('fa') and never re-renders it on a client-side locale switch (the segment doesn't change). Its `lang`/`dir`/messages therefore freeze on 'fa'/'rtl' for every route, including `/en`. The `[locale]` layout is the lowest boundary keyed on the locale param, so it is the only place where `<html lang dir>` reliably tracks the active locale.
|
||||
@@ -370,6 +385,21 @@ Enforcement: before removing or renaming a shared component, check whether `src/
|
||||
|
||||
---
|
||||
|
||||
## Comments & dead code
|
||||
|
||||
- **No dead code.** Unused variables, imports, parameters, and private members are lint errors
|
||||
(`@typescript-eslint/no-unused-vars`, raised to `error` — see *Quality gates*). Delete them; don't
|
||||
comment them out and don't silence the rule. Prefix a deliberately-unused binding with `_` to opt out.
|
||||
- **Comment the *why*, never the *what*.** Code should read for itself — a comment that restates what
|
||||
the code already says is noise. Don't write `// set the access token` above `setClientCookie(...)`, or
|
||||
JSDoc that just echoes a function's name.
|
||||
- **Do** add a tight comment when a decision is genuinely non-obvious from the code: a workaround for a
|
||||
framework quirk, a business rule, an ordering or security constraint, a deliberate deviation. Explain
|
||||
*why it is this way*. The comments in `src/app/[locale]/layout.tsx` (why `<html>` lives in the
|
||||
`[locale]` layout) and `src/lib/auth/token.ts` (why the JWT `exp` check is UX-only, never a security
|
||||
boundary) are the model to follow.
|
||||
- Prefer a clearer name or a small helper over a comment whenever that removes the need for it.
|
||||
|
||||
## Anti-patterns (do not do these)
|
||||
|
||||
- **Do not** read `localStorage` or `document.cookie` in render functions — use `useEffect` or server-side `cookies()` from `next/headers`.
|
||||
@@ -423,24 +453,46 @@ Central fetch primitives live in `src/lib/api/`:
|
||||
|
||||
---
|
||||
|
||||
## Auth Cookies
|
||||
## Auth Cookies & session state
|
||||
|
||||
| Cookie | Constant | TTL | Set by |
|
||||
|--------|----------|-----|--------|
|
||||
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `useLogin()` in `src/services/auth/hooks/useLogin.ts` |
|
||||
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | `useLogin()` in `src/services/auth/hooks/useLogin.ts` |
|
||||
|
||||
Both are regular (non-httpOnly) cookies so they are readable by both server and client.
|
||||
**Session state lives in `AuthContext`** (`src/context/auth/`). The root layout resolves the session on
|
||||
the server with `getServerAuthState()` (`src/lib/auth/server.ts`) — which reads the `access_token` cookie
|
||||
and checks the JWT `exp` via the shared `isTokenAlive` (`src/lib/auth/token.ts`) — and passes it to
|
||||
`<AuthProvider initialState={…}>`. So the **first render already knows whether the user is
|
||||
authenticated**: no logged-out flash, no post-mount cookie read.
|
||||
|
||||
**Lifecycle:**
|
||||
- Written after a successful login via `setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS` / `AUTH_REFRESH_COOKIE_OPTIONS`
|
||||
- Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) / `useEventLogout()` (`src/hooks/auth.ts`), and automatically by `clientFetch` on 401
|
||||
- Read by `serverFetch` via `getServerCookie(COOKIE_NAMES.ACCESS_TOKEN)`
|
||||
- Read by `clientFetch` via `getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)`
|
||||
- Written after a successful login via `setClientCookie` (`useLogin`), which also dispatches `LOG_IN` to
|
||||
keep `AuthContext` in sync on the client without a reload.
|
||||
- Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) — the single logout path: it calls
|
||||
the API, clears both cookies, dispatches `LOG_OUT`, and redirects — and automatically by `clientFetch`
|
||||
on 401.
|
||||
- Read on the server by `serverFetch` / `getServerAuthState` via `getServerCookie`.
|
||||
- Read on the client by `clientFetch` via `getClientCookie` (to attach `Authorization: Bearer`).
|
||||
|
||||
**Middleware:** validates the `exp` claim of the access token locally (base64-decode the JWT payload, no signature check) before rendering any private page. An absent or expired token redirects to `/{locale}/login`.
|
||||
**`useIsAuthenticated()`** (`src/hooks/auth.ts`) reads the server-seeded `AuthContext`, so it is correct
|
||||
on the first paint (it no longer reads the cookie after mount).
|
||||
|
||||
**`useIsAuthenticated()` hook:** SSR-safe — initialises `false`, sets the real value in `useEffect` by reading the cookie. This prevents hydration mismatches.
|
||||
**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render.
|
||||
|
||||
**Security posture — current limits and best-practice follow-ups.** The flow above is the intended
|
||||
client design, but the auth model has known gaps that need *server* coordination to close. Don't
|
||||
silently "fix" them client-only:
|
||||
- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header — this
|
||||
trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a
|
||||
same-origin proxy) spans the server.
|
||||
- **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but
|
||||
does **not** verify the signature. The API is the only authority; never gate real authorization on the
|
||||
middleware or `isTokenAlive`.
|
||||
- **No refresh-token rotation** is implemented — the `refresh_token` cookie is set/cleared but never
|
||||
exchanged; access expiry just forces re-login on the next 401.
|
||||
- **No role/permission model** yet (`User` is `{ id, username }`); authorization is binary. Add roles to
|
||||
`User`/`AuthState` and the server before gating UI by permission.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user