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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-5
@@ -72,9 +72,9 @@ src/
|
||||
├── hooks/ Custom hooks (auth, layout/mobile, events, window size)
|
||||
├── i18n/ next-intl routing + request config
|
||||
├── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar
|
||||
├── lib/ api (client/server fetch), cookies, query (TanStack), toast
|
||||
├── lib/ api (client/server fetch), auth (JWT/session helpers), cookies, query, toast
|
||||
├── services/ Domain services: {domain}/{types,keys,apis,hooks}
|
||||
├── store/ AppStore (React context + reducer) for client state
|
||||
├── context/ React context providers — auth/ is AuthContext (server-seeded session state)
|
||||
├── theme/ ThemeProvider, palettes, tokens.css, typography, direction
|
||||
└── utils/ Helpers (storage, navigation, env, text, types)
|
||||
```
|
||||
@@ -89,6 +89,7 @@ Translation files live in [`messages/`](messages) (`en.json` / `fa.json`) and mu
|
||||
- This is a server-rendered Next.js app (App Router + middleware + server-side cookies) — **not** a
|
||||
static export.
|
||||
- Internationalization is locale-prefixed (`/fa`, `/en`); `fa` is the default and is RTL.
|
||||
- Authentication is cookie-based (`access_token` / `refresh_token`), managed through
|
||||
`src/lib/cookies/` and the `src/services/auth/` hooks. The API base URL comes from
|
||||
`NEXT_PUBLIC_API_URL`.
|
||||
- Authentication is cookie-based (`access_token` / `refresh_token`). Session state lives in
|
||||
`AuthContext` (`src/context/auth/`), seeded on the server from the request cookie so the first
|
||||
render reflects the real session; cookies are managed through `src/lib/cookies/` and the
|
||||
`src/services/auth/` hooks. The API base URL comes from `NEXT_PUBLIC_API_URL`.
|
||||
|
||||
@@ -9,6 +9,31 @@
|
||||
import next from 'eslint-config-next';
|
||||
import prettier from 'eslint-config-prettier/flat';
|
||||
|
||||
// Unused variables/imports are dead code. eslint-config-next already enables
|
||||
// `@typescript-eslint/no-unused-vars` at 'warn'; we raise it to 'error' so
|
||||
// `npm run check` fails on dead code instead of merely warning. We patch the
|
||||
// severity *in place* on next's own config objects rather than adding a separate
|
||||
// override object, because in flat config a rule can only be referenced from a
|
||||
// config object that registers its plugin — and the `@typescript-eslint` plugin
|
||||
// is registered inside next's objects, not ours. Prefix a name with `_` to opt
|
||||
// out (intentionally-unused args, catch bindings, rest-spread siblings).
|
||||
const NO_UNUSED_VARS = [
|
||||
'error',
|
||||
{
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
];
|
||||
|
||||
const nextWithStrictUnusedVars = [...next].map((entry) =>
|
||||
entry?.rules?.['@typescript-eslint/no-unused-vars']
|
||||
? { ...entry, rules: { ...entry.rules, '@typescript-eslint/no-unused-vars': NO_UNUSED_VARS } }
|
||||
: entry
|
||||
);
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
const config = [
|
||||
// Things ESLint should never look at.
|
||||
@@ -16,17 +41,15 @@ const config = [
|
||||
ignores: ['.next/**', 'out/**', 'coverage/**', 'node_modules/**', 'next-env.d.ts'],
|
||||
},
|
||||
|
||||
// Next.js + TypeScript + React + import/a11y rule sets.
|
||||
...next,
|
||||
// Next.js + TypeScript + React + import/a11y rule sets, with no-unused-vars
|
||||
// raised to error (see NO_UNUSED_VARS above).
|
||||
...nextWithStrictUnusedVars,
|
||||
|
||||
// Turn off every stylistic rule that would fight Prettier. Keep this last
|
||||
// among the rule-providing entries so it wins. Formatting is owned by
|
||||
// Prettier (`npm run format`), never by ESLint.
|
||||
prettier,
|
||||
|
||||
// Project-specific rule overrides go here, e.g.:
|
||||
// { rules: { 'react/jsx-key': 'error' } }
|
||||
//
|
||||
// NOTE: `import/no-cycle` is intentionally NOT enabled. On this toolchain the
|
||||
// eslint-plugin-import TypeScript resolver bundled by eslint-config-next 16
|
||||
// throws "invalid interface loaded as resolver" for that rule, and it cannot
|
||||
|
||||
+1
-10
@@ -2,20 +2,11 @@ import createMiddleware from 'next-intl/middleware';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { routing } from './src/i18n/routing';
|
||||
import { COOKIE_NAMES } from './src/lib/cookies';
|
||||
import { isTokenAlive } from './src/lib/auth/token';
|
||||
import { HEADER_NAMES, PUBLIC_PATHS, ROUTES } from './src/constants';
|
||||
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
function isTokenAlive(token?: string): boolean {
|
||||
if (!token) return false;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return typeof payload.exp === 'number' && payload.exp * 1000 > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function middleware(request: NextRequest) {
|
||||
const i18nResponse = intlMiddleware(request);
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import localFont from 'next/font/local';
|
||||
import { setRequestLocale, getMessages } from 'next-intl/server';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getThemeMode } from '@/lib/cookies/server';
|
||||
import { AppStoreProvider } from '@/store';
|
||||
import { getServerAuthState } from '@/lib/auth/server';
|
||||
import { AuthProvider } from '@/context/auth';
|
||||
import { ThemeProvider, getDirection } from '@/theme';
|
||||
import { BRAND } from '@/theme/colors';
|
||||
import { NotistackProvider } from '@/lib/toast';
|
||||
@@ -73,6 +74,7 @@ export default async function LocaleLayout({
|
||||
|
||||
const messages = await getMessages({ locale: safeLocale });
|
||||
const { colorScheme, defaultMode } = await getThemeMode();
|
||||
const authState = await getServerAuthState();
|
||||
const dir = getDirection(safeLocale);
|
||||
|
||||
// Only attach the Mikhak font variable on RTL (Persian) routes so the
|
||||
@@ -88,7 +90,7 @@ export default async function LocaleLayout({
|
||||
>
|
||||
<body>
|
||||
<NextIntlClientProvider locale={safeLocale} messages={messages}>
|
||||
<AppStoreProvider>
|
||||
<AuthProvider initialState={authState}>
|
||||
<ThemeProvider dir={dir} defaultMode={defaultMode}>
|
||||
<QueryProvider>
|
||||
<NotistackProvider>
|
||||
@@ -96,7 +98,7 @@ export default async function LocaleLayout({
|
||||
</NotistackProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</AppStoreProvider>
|
||||
</AuthProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
import { createContext, useContext, useReducer } from 'react';
|
||||
import type { Dispatch, FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { authReducer } from './authReducer';
|
||||
import { INITIAL_AUTH_STATE } from './types';
|
||||
import type { AuthAction, AuthState } from './types';
|
||||
|
||||
export type AuthContextValue = [AuthState, Dispatch<AuthAction>];
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>([INITIAL_AUTH_STATE, () => null]);
|
||||
|
||||
interface AuthProviderProps extends PropsWithChildren {
|
||||
// Auth state resolved on the server from the request's access-token cookie.
|
||||
// Seeding the reducer with it means the first client render already reflects
|
||||
// the real session — no logged-out flash, no post-mount cookie read.
|
||||
initialState?: AuthState;
|
||||
}
|
||||
|
||||
export const AuthProvider: FunctionComponent<AuthProviderProps> = ({ initialState, children }) => {
|
||||
const value = useReducer(authReducer, initialState ?? INITIAL_AUTH_STATE);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextValue => useContext(AuthContext);
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Reducer } from 'react';
|
||||
import type { AuthAction, AuthState } from './types';
|
||||
|
||||
export const authReducer: Reducer<AuthState, AuthAction> = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'LOG_IN':
|
||||
return { isAuthenticated: true, currentUser: action.user ?? state.currentUser };
|
||||
case 'LOG_OUT':
|
||||
return { isAuthenticated: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AuthProvider, useAuth } from './AuthContext';
|
||||
export type { AuthContextValue } from './AuthContext';
|
||||
export { INITIAL_AUTH_STATE } from './types';
|
||||
export type { AuthAction, AuthState } from './types';
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { User } from '@/services/auth/types';
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
currentUser?: User;
|
||||
}
|
||||
|
||||
export type AuthAction = { type: 'LOG_IN'; user?: User } | { type: 'LOG_OUT' };
|
||||
|
||||
export const INITIAL_AUTH_STATE: AuthState = {
|
||||
isAuthenticated: false,
|
||||
};
|
||||
@@ -1,42 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { deleteClientCookie, getClientCookie } from '@/lib/cookies/client';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAppStore } from '../store';
|
||||
import { useAuth } from '@/context/auth';
|
||||
|
||||
/**
|
||||
* Returns true when an access_token cookie is present on the client.
|
||||
* Initialises as false (SSR-safe) and updates after mount to avoid hydration mismatches.
|
||||
* True when the current session is authenticated.
|
||||
*
|
||||
* Reads AuthContext, which the root layout seeds from the request's access-token
|
||||
* cookie on the server. The value is therefore correct on the first render — no
|
||||
* post-mount cookie read, no hydration flash.
|
||||
*/
|
||||
export function useIsAuthenticated() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// SSR-safe: read the browser-only cookie once after mount so the server-rendered
|
||||
// `false` reconciles on the client without a hydration mismatch. Deliberate setState.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsAuthenticated(Boolean(getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)));
|
||||
}, []);
|
||||
|
||||
return isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a handler that logs the current user out:
|
||||
* clears auth cookies, resets AppStore, and redirects to the login page.
|
||||
*/
|
||||
export function useEventLogout() {
|
||||
const [, dispatch] = useAppStore();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
return useCallback(() => {
|
||||
deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
dispatch({ type: 'LOG_OUT' });
|
||||
router.replace(`/${locale}${ROUTES.LOGIN}`);
|
||||
}, [dispatch, router, locale]);
|
||||
export function useIsAuthenticated(): boolean {
|
||||
const [state] = useAuth();
|
||||
return state.isAuthenticated;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren, useEffect } from 'react';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { getClientCookie } from '@/lib/cookies/client';
|
||||
import { useAppStore } from '@/store';
|
||||
import type { FunctionComponent, PropsWithChildren } from 'react';
|
||||
|
||||
const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const [,dispatch] = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (getClientCookie(COOKIE_NAMES.ACCESS_TOKEN)) {
|
||||
dispatch({ type: 'LOG_IN' });
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
children
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Authenticated layout shell. Auth state is seeded on the server by AuthProvider
|
||||
* (see getServerAuthState) and kept current by the login/logout hooks, so this
|
||||
* component no longer reads the cookie after mount — it is the place to build the
|
||||
* authenticated layout chrome.
|
||||
*/
|
||||
const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => <>{children}</>;
|
||||
|
||||
export default PrivateLayout;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FunctionComponent, useCallback, MouseEvent } from 'react';
|
||||
import { Stack, Divider, Drawer, DrawerProps } from '@mui/material';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useEventLogout, useIsAuthenticated, useIsMobile } from '@/hooks';
|
||||
import { useIsAuthenticated, useIsMobile } from '@/hooks';
|
||||
import { useLogout } from '@/services/auth';
|
||||
import { AppIconButton, UserInfo } from '@/components';
|
||||
import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config';
|
||||
import SideBarNavList from './SideBarNavList';
|
||||
@@ -18,7 +19,7 @@ export interface SideBarProps extends Pick<DrawerProps, 'anchor' | 'className' |
|
||||
const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items, onClose, ...restOfProps }) => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const onMobile = useIsMobile();
|
||||
const onLogout = useEventLogout();
|
||||
const { mutate: logout } = useLogout();
|
||||
|
||||
const handleAfterLinkClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
@@ -72,7 +73,7 @@ const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items
|
||||
{/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */}
|
||||
<DarkModeFormSwitch />
|
||||
|
||||
{isAuthenticated && <AppIconButton icon="logout" title="Logout Current User" onClick={onLogout} />}
|
||||
{isAuthenticated && <AppIconButton icon="logout" title="Logout Current User" onClick={() => logout()} />}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Server-only auth utilities.
|
||||
* Import as: import { getServerAuthState } from '@/lib/auth/server'
|
||||
*
|
||||
* Uses `next/headers` (via the server cookie manager) and must only be called
|
||||
* from Server Components, Server Actions, or Route Handlers — never from a
|
||||
* client component.
|
||||
*/
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { getServerCookie } from '@/lib/cookies/server';
|
||||
import type { AuthState } from '@/context/auth/types';
|
||||
import { isTokenAlive } from './token';
|
||||
|
||||
/**
|
||||
* Resolve the current request's auth state from the access-token cookie so the
|
||||
* root layout can seed AuthProvider with a server-correct value before the first
|
||||
* paint.
|
||||
*/
|
||||
export async function getServerAuthState(): Promise<AuthState> {
|
||||
const token = await getServerCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
return { isAuthenticated: isTokenAlive(token) };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* JWT helpers shared by the edge middleware and server components.
|
||||
*
|
||||
* Pure functions with no `next/headers` dependency, so they are safe to import
|
||||
* from `middleware.ts` (edge runtime) and from RSCs alike. They only inspect the
|
||||
* token's `exp` claim — a cheap liveness check for routing/UX, NOT a security
|
||||
* boundary. The signature is never verified here; the API server is the only
|
||||
* authority that actually trusts the token.
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
exp?: number;
|
||||
sub?: string;
|
||||
[claim: string]: unknown;
|
||||
}
|
||||
|
||||
// JWTs use base64url (no padding, `-`/`_` instead of `+`/`/`); atob expects
|
||||
// standard base64, so normalise before decoding.
|
||||
function base64UrlDecode(segment: string): string {
|
||||
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
||||
return atob(padded);
|
||||
}
|
||||
|
||||
export function decodeJwtPayload(token: string | undefined): JwtPayload | null {
|
||||
if (!token) return null;
|
||||
const segment = token.split('.')[1];
|
||||
if (!segment) return null;
|
||||
try {
|
||||
return JSON.parse(base64UrlDecode(segment)) as JwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenAlive(token: string | undefined): boolean {
|
||||
const payload = decodeJwtPayload(token);
|
||||
return typeof payload?.exp === 'number' && payload.exp * 1000 > Date.now();
|
||||
}
|
||||
@@ -4,16 +4,22 @@ import { AUTH_ACCESS_COOKIE_OPTIONS, AUTH_REFRESH_COOKIE_OPTIONS, COOKIE_NAMES }
|
||||
import { setClientCookie } from '@/lib/cookies/client';
|
||||
import { dispatchToast } from '@/lib/toast/dispatchToast';
|
||||
import type { ApiError } from '@/lib/api/errors';
|
||||
import { useAuth } from '@/context/auth';
|
||||
|
||||
import { AuthClientApi } from '../apis/clientApi';
|
||||
import type { LoginDto } from '../types';
|
||||
|
||||
export function useLogin() {
|
||||
const [, dispatch] = useAuth();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: LoginDto) => AuthClientApi.login(dto),
|
||||
onSuccess: (data) => {
|
||||
setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, data.accessToken, AUTH_ACCESS_COOKIE_OPTIONS);
|
||||
setClientCookie(COOKIE_NAMES.REFRESH_TOKEN, data.refreshToken, AUTH_REFRESH_COOKIE_OPTIONS);
|
||||
// Sync AuthContext after a client-side login so the UI reflects the new
|
||||
// session immediately, without waiting for the next full server render.
|
||||
dispatch({ type: 'LOG_IN' });
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
dispatchToast(error.message, 'error');
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useLocale } from 'next-intl';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { deleteClientCookie } from '@/lib/cookies/client';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAppStore } from '@/store';
|
||||
import { useAuth } from '@/context/auth';
|
||||
|
||||
import { AuthClientApi } from '../apis/clientApi';
|
||||
|
||||
export function useLogout() {
|
||||
const [, dispatch] = useAppStore();
|
||||
const [, dispatch] = useAuth();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Reducer } from 'react';
|
||||
import { AppStoreState } from './config';
|
||||
|
||||
const AppReducer: Reducer<AppStoreState, any> = (state, action) => {
|
||||
switch (action.type || action.action) {
|
||||
case 'CURRENT_USER':
|
||||
return { ...state, currentUser: action?.currentUser || action?.payload };
|
||||
case 'SIGN_UP':
|
||||
case 'LOG_IN':
|
||||
return { ...state, isAuthenticated: true };
|
||||
case 'LOG_OUT':
|
||||
return { ...state, isAuthenticated: false, currentUser: undefined };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default AppReducer;
|
||||
@@ -1,32 +0,0 @@
|
||||
'use client';
|
||||
import {
|
||||
createContext,
|
||||
useReducer,
|
||||
useContext,
|
||||
FunctionComponent,
|
||||
PropsWithChildren,
|
||||
Dispatch,
|
||||
ComponentType,
|
||||
} from 'react';
|
||||
import AppReducer from './AppReducer';
|
||||
import { APP_STORE_INITIAL_STATE, AppStoreState } from './config';
|
||||
|
||||
export type AppContextReturningType = [AppStoreState, Dispatch<any>];
|
||||
const AppContext = createContext<AppContextReturningType>([APP_STORE_INITIAL_STATE, () => null]);
|
||||
|
||||
const AppStoreProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const value: AppContextReturningType = useReducer(AppReducer, APP_STORE_INITIAL_STATE);
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
};
|
||||
|
||||
const useAppStore = (): AppContextReturningType => useContext(AppContext);
|
||||
|
||||
interface WithAppStoreProps {
|
||||
appStore: AppContextReturningType;
|
||||
}
|
||||
const withAppStore = (Component: ComponentType<WithAppStoreProps>): FunctionComponent =>
|
||||
function ComponentWithAppStore(props) {
|
||||
return <Component {...props} appStore={useAppStore()} />;
|
||||
};
|
||||
|
||||
export { AppStoreProvider, useAppStore, withAppStore };
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface AppStoreState {
|
||||
isAuthenticated: boolean;
|
||||
currentUser?: object | undefined;
|
||||
}
|
||||
|
||||
export const APP_STORE_INITIAL_STATE: AppStoreState = {
|
||||
isAuthenticated: false,
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import { AppStoreProvider, useAppStore, withAppStore } from './AppStore';
|
||||
|
||||
export { AppStoreProvider, useAppStore, withAppStore };
|
||||
Reference in New Issue
Block a user