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
+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 };