another step
This commit is contained in:
@@ -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