another step

This commit is contained in:
hamid
2026-06-23 23:36:19 +03:30
parent 3fd147cf80
commit be07c703ec
27 changed files with 3682 additions and 194 deletions
+65 -13
View File
@@ -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
View File
@@ -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`.
+28 -5
View File
@@ -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
View File
@@ -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);
+5 -3
View File
@@ -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>
+24
View File
@@ -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);
+13
View File
@@ -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;
}
};
+4
View File
@@ -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';
+12
View File
@@ -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,
};
+9 -38
View File
@@ -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;
}
+8 -18
View File
@@ -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;
+4 -3
View File
@@ -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>
+22
View File
@@ -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) };
}
+38
View File
@@ -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');
+2 -2
View File
@@ -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();
-18
View File
@@ -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;
-32
View File
@@ -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 };
-8
View File
@@ -1,8 +0,0 @@
export interface AppStoreState {
isAuthenticated: boolean;
currentUser?: object | undefined;
}
export const APP_STORE_INITIAL_STATE: AppStoreState = {
isAuthenticated: false,
};
-3
View File
@@ -1,3 +0,0 @@
import { AppStoreProvider, useAppStore, withAppStore } from './AppStore';
export { AppStoreProvider, useAppStore, withAppStore };