Add s s 8854yh440

This commit is contained in:
yznahmad 2025-06-22 23:36:14 +03:00
parent 5d59830e52
commit 62da722945
6 changed files with 382 additions and 278 deletions

View File

@ -1,110 +1,63 @@
'use client'; 'use client';
import { useAppSelector } from "@/redux/store" import { useEffect } from 'react';
import { isLoggedIn, mountCheckIfValid, logOut } from "@/redux/features/auth-slice"; import { useRouter } from 'next/navigation';
import { useDispatch } from "react-redux" import { useAppDispatch, useAppSelector } from '@/redux/store';
import { AppDispatch } from '@/redux/store'; import { checkAuth, selectAuth } from '@/redux/features/auth-slice';
import { useEffect, useCallback } from "react"; import FullScreenLoader from '@/components/common/fullScreenLoader';
import { useRouter } from 'next/navigation'
import { ReactNode } from 'react'
import FullScreenLoader from "@/components/common/fullScreenLoader";
import { load } from '@/redux/features/settings-slice'
import ErrorBoundary from '@/components/common/ErrorBoundary'; import ErrorBoundary from '@/components/common/ErrorBoundary';
import axios from 'axios'; // Added axios import import { AppDispatch } from '@/redux/store';
// HOC for auth pages (only accept auth user) // HOC for auth pages (only accept auth user)
export default function LocaleLayout({ children }: { children: ReactNode }) { export default function LocaleLayout({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const dispatch = useDispatch<AppDispatch>(); const dispatch = useAppDispatch();
const { isValid, checkIfValidMounted } = useAppSelector(selectAuth);
const notAuthRedirectPage = '/login';
// Load states // Check authentication status on mount and periodically
const isValid = useAppSelector((state) => state.authReducer.value.isValid); useEffect(() => {
const checkIfValidMounted = useAppSelector((state) => state.authReducer.value.checkIfValidMounted); const checkAuthentication = async () => {
const notAuthRedirectPage = useAppSelector((state) => state.settingsReducer.value.notAuthRedirectPage); try {
const isLoadingSettings = useAppSelector((state) => state.settingsReducer.value.isLoadingSettings); await (dispatch as AppDispatch)(checkAuth());
const loadedFirstTime = useAppSelector((state) => state.settingsReducer.value.loadedFirstTime); } catch (error) {
console.error('Authentication check failed:', error);
}
};
// Handle not logged in state // Initial check
const handleNotLoggedIn = useCallback(() => { checkAuthentication();
// Clear any invalid token from cookies
// Set up periodic check (every 5 minutes)
const intervalId = setInterval(checkAuthentication, 5 * 60 * 1000);
// Cleanup interval on component unmount
return () => clearInterval(intervalId);
}, [dispatch]);
// Handle redirection based on auth status
useEffect(() => {
if (checkIfValidMounted && !isValid) {
// Clear any existing token
document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
// Clear Redux state
dispatch(logOut());
// Redirect to login page
router.push(notAuthRedirectPage);
}, [notAuthRedirectPage, router, dispatch]);
// Initialize auth check // Only redirect if not already on the login page
useEffect(() => { if (window.location.pathname !== notAuthRedirectPage) {
if (checkIfValidMounted) return; window.location.href = notAuthRedirectPage;
const checkAuth = async () => {
try {
await dispatch(mountCheckIfValid());
await dispatch(isLoggedIn({ NotLoggedInCallback: handleNotLoggedIn }));
} catch (error) {
console.error('Auth check failed:', error);
handleNotLoggedIn();
} }
};
checkAuth();
}, [checkIfValidMounted, dispatch, handleNotLoggedIn]);
// Load settings
useEffect(() => {
if (loadedFirstTime || isLoadingSettings) return;
const loadSettings = async () => {
try {
await dispatch(load({ page: 1 }));
} catch (error) {
console.error('Failed to load settings:', error);
} }
}; }, [isValid, checkIfValidMounted]);
loadSettings();
}, [loadedFirstTime, isLoadingSettings, dispatch]);
// Add global error handler for API requests
useEffect(() => {
const responseInterceptor = (response: any) => response;
const errorInterceptor = (error: any) => {
if (error.response?.status === 401) {
// Handle unauthorized (token expired/invalid)
handleNotLoggedIn();
}
return Promise.reject(error);
};
// Add request interceptor
const requestInterceptor = axios.interceptors.request.use(
config => config,
error => Promise.reject(error)
);
// Add response interceptor
const responseIntercept = axios.interceptors.response.use(
responseInterceptor,
errorInterceptor
);
// Cleanup function
return () => {
axios.interceptors.request.eject(requestInterceptor);
axios.interceptors.response.eject(responseIntercept);
};
}, [handleNotLoggedIn]);
// Show loading state while checking auth // Show loading state while checking auth
if (isValid === null) { if (!checkIfValidMounted) {
return <FullScreenLoader />; return <FullScreenLoader />;
} }
return ( // If not valid, we'll be redirected by the effect above
<ErrorBoundary> if (!isValid) {
{isValid ? children : <FullScreenLoader />} return <FullScreenLoader />;
</ErrorBoundary> }
);
// If valid, render the protected content
return <ErrorBoundary>{children}</ErrorBoundary>;
} }

View File

@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import jwt from 'jsonwebtoken';
import { dbConnect } from '@/lib/dbConnect';
import User from '@/models/User';
export async function GET() {
try {
await dbConnect();
// Get the token from cookies
const cookieStore = cookies();
const token = cookieStore.get('authToken')?.value;
if (!token) {
return NextResponse.json(
{ valid: false, message: 'No token provided' },
{ status: 401 }
);
}
try {
// Verify the token
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
// Check if user still exists
const user = await User.findById(decoded.userId).select('-password');
if (!user) {
return NextResponse.json(
{ valid: false, message: 'User not found' },
{ status: 401 }
);
}
return NextResponse.json({ valid: true });
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return NextResponse.json(
{ valid: false, message: 'Token expired' },
{ status: 401 }
);
}
return NextResponse.json(
{ valid: false, message: 'Invalid token' },
{ status: 401 }
);
}
} catch (error) {
console.error('Token verification error:', error);
return NextResponse.json(
{ valid: false, message: 'Server error' },
{ status: 500 }
);
}
}

50
webapp/src/lib/api.ts Normal file
View File

@ -0,0 +1,50 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { store } from '@/redux/store';
import { logOut } from '@/redux/features/auth-slice';
const api: AxiosInstance = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = store.getState().authReducer.value.authToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle 401 errors
api.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const originalRequest = error.config as any;
// If the error is 401 and we haven't tried to refresh yet
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
// Clear any existing auth state
store.dispatch(logOut());
document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
// Redirect to login page
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;

47
webapp/src/lib/auth.ts Normal file
View File

@ -0,0 +1,47 @@
import jwt from 'jsonwebtoken';
import { cookies } from 'next/headers';
export async function verifyToken(token?: string): Promise<boolean> {
if (!token) return false;
try {
// Verify the token
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
return !!decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
console.log('Token expired');
} else if (error instanceof jwt.JsonWebTokenError) {
console.log('Invalid token');
}
return false;
}
}
export function getAuthToken(): string | undefined {
const cookieStore = cookies();
return cookieStore.get('authToken')?.value;
}
export function clearAuthToken() {
document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}
export function setAuthToken(token: string) {
const expires = new Date();
expires.setTime(expires.getTime() + 3 * 60 * 60 * 1000); // 3 hours
document.cookie = `authToken=${token}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; SameSite=Lax`;
}
// Helper to get user ID from token
export function getUserIdFromToken(token?: string): string | null {
if (!token) return null;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
return decoded.userId;
} catch (error) {
return null;
}
}

View File

@ -6,57 +6,92 @@
* * source : https://nextjs.org/docs/app/building-your-application/routing/middleware * * source : https://nextjs.org/docs/app/building-your-application/routing/middleware
*/ */
import createIntlMiddleware from 'next-intl/middleware'; import { NextResponse } from 'next/server';
import { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server' import { verifyToken } from '@/lib/auth';
import validateAuthToken from '@/middleware/validateAuthToken'
export default async function middleware(request: NextRequest) { // Define protected API routes
const protectedApiRoutes = [
'/api/user',
// Add other protected API routes here
];
// log the request general informations // Define public routes that don't require authentication
let ip = request.ip ?? request.headers.get('X-Forwarded-For')?.split(':')[3] const publicRoutes = [
console.log("request to : " ,request.nextUrl.pathname , 'from ip :' , ip) '/login',
'/register',
'/forgot-password',
'/api/auth',
'/api/auth/verify',
// Add other public routes here
];
// handle pages export async function middleware(request: NextRequest) {
if(!request.nextUrl.pathname.startsWith('/api')) const { pathname } = request.nextUrl;
{
// handle next-intl Internationalization
const defaultLocale = request.headers.get('x-default-locale') || 'ar';
const handleI18nRouting = createIntlMiddleware({
locales: ['ar', 'en'],
defaultLocale
});
const response = handleI18nRouting(request);
response.headers.set('x-default-locale', defaultLocale);
return response; // Skip middleware for public routes
if (publicRoutes.some(route => pathname.startsWith(route))) {
return NextResponse.next();
} }
// handle api routes // Check if the request is for a protected API route
// must be user routes const isProtectedApiRoute = protectedApiRoutes.some(route => pathname.startsWith(route));
// protect /api/user routes if (isProtectedApiRoute) {
if(request.nextUrl.pathname.startsWith('/api/user')) try {
{ // Verify the token
let authToken : {name:string , value : string} | undefined = request.cookies.get('authToken') const token = request.cookies.get('authToken')?.value;
let authValidation : boolean | undefined = await validateAuthToken(authToken?.value)
if(!authValidation) if (!token) {
{ // you are not auth you cant access this route return NextResponse.json(
return new NextResponse( { success: false, message: 'No token provided' },
JSON.stringify({ { status: 401 }
success: false, );
message: "notAllowed",
// @ts-ignore
} , {status : 405 , headers: { 'content-type': 'application/json'}})
)
} }
// you can access the api route
return NextResponse.next() const isValid = await verifyToken(token);
if (!isValid) {
return NextResponse.json(
{ success: false, message: 'Invalid or expired token' },
{ status: 401 }
);
} }
// Token is valid, continue with the request
return NextResponse.next();
} catch (error) {
console.error('Authentication error:', error);
return NextResponse.json(
{ success: false, message: 'Authentication failed' },
{ status: 500 }
);
}
}
// For non-API routes, check if user is authenticated
const isAuthenticated = await verifyToken(request.cookies.get('authToken')?.value);
// If not authenticated and trying to access a protected page, redirect to login
if (!isAuthenticated && !pathname.startsWith('/login')) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
} }
// represent the routes that this middleware supposed to handle them // Configure which routes should be processed by the middleware
export const config = { export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}; };

View File

@ -5,156 +5,119 @@
* * https://redux-toolkit.js.org/api/createAsyncThunk * * https://redux-toolkit.js.org/api/createAsyncThunk
*/ */
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import axios from 'axios' import { RootState } from '@/redux/store';
import Cookies from 'universal-cookie'; import api from '@/lib/api';
import { fireAlert } from './alert-slice'; import { setLoading } from './ui-slice';
const cookies = new Cookies(); interface AuthState {
type IinitialState = {
value: AuthState;
}
type AuthState = {
authToken: string | null; authToken: string | null;
isValid: boolean | null; isValid: boolean | null;
checkIfValidMounted: boolean; checkIfValidMounted: boolean;
lastChecked: number | null;
} }
const initialState = { const initialState: AuthState = {
value: { authToken: null,
// try to get the token from the browser cookies
authToken: cookies.get("authToken") || null,
isValid: null, isValid: null,
checkIfValidMounted: false, checkIfValidMounted: false,
} lastChecked: null,
} as IinitialState };
const logIn = createAsyncThunk( export const checkAuth = createAsyncThunk(
'auth/logInStatus', 'auth/checkAuth',
async (actionPayload: { username: string, password: string, successCallback: any }, thunkAPI) => { async (_, { dispatch, getState }) => {
try { try {
let { data } = await axios.post('/api/auth', actionPayload) const { authReducer } = getState() as { authReducer: AuthState };
if (data.success) {
actionPayload.successCallback()
// fire the success alert
thunkAPI.dispatch(fireAlert({
success: true,
message: "loggedIn",
}))
return data
}
else {
thunkAPI.dispatch(fireAlert({
success: false,
message: data.message
}))
return { success: false }
}
} catch (err) {
thunkAPI.dispatch(fireAlert({
success: false,
message: "unkownError",
}))
return { success: false }
}
}
)
const isLoggedIn = createAsyncThunk( // Skip if we've checked recently (within last 5 minutes)
'auth/isLoggedInStatus', if (authReducer.lastChecked && (Date.now() - authReducer.lastChecked < 5 * 60 * 1000)) {
async (actionPayload: { LoggedInCallback?: () => void; NotLoggedInCallback?: () => void }, { dispatch, getState }) => { return { isValid: authReducer.isValid };
try {
const state = getState() as RootState;
const { authToken } = state.authReducer.value;
// If no token, immediately trigger not logged in
if (!authToken) {
if (actionPayload.NotLoggedInCallback) {
actionPayload.NotLoggedInCallback();
} }
if (!authReducer.authToken) {
return { isValid: false }; return { isValid: false };
} }
try { const response = await api.get('/auth/verify');
const { data } = await axios.get('/api/auth?authToken=' + authToken); return { isValid: response.data.valid };
if (!data.success) {
// If token is invalid or expired, clear it
if (data.message === 'expiredToken' || data.message === 'invalidToken') {
dispatch(logOut());
document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}
if (actionPayload.NotLoggedInCallback) {
actionPayload.NotLoggedInCallback();
}
} else if (actionPayload.LoggedInCallback) {
actionPayload.LoggedInCallback();
}
return { isValid: data.success };
} catch (error: any) {
// Handle network errors or server issues
console.error('Token validation error:', error);
// If it's an auth-related error, clear the token
if (error.response?.status === 401 || error.response?.data?.message === 'expiredToken') {
dispatch(logOut());
document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
}
if (actionPayload.NotLoggedInCallback) {
actionPayload.NotLoggedInCallback();
}
return { isValid: false };
}
} catch (error) { } catch (error) {
console.error('Error in isLoggedIn:', error); console.error('Auth check failed:', error);
if (actionPayload.NotLoggedInCallback) {
actionPayload.NotLoggedInCallback();
}
return { isValid: false }; return { isValid: false };
} }
} }
) );
export const auth = createSlice({ export const login = createAsyncThunk(
name: "auth", 'auth/login',
async (credentials: { username: string; password: string }, { dispatch }) => {
try {
dispatch(setLoading(true));
const response = await api.post('/auth/login', credentials);
return {
authToken: response.data.token,
isValid: true
};
} finally {
dispatch(setLoading(false));
}
}
);
export const logout = createAsyncThunk(
'auth/logout',
async (_, { dispatch }) => {
try {
await api.post('/auth/logout');
} catch (error) {
console.error('Logout error:', error);
} finally {
// Clear token from cookies
document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
return { isValid: false, authToken: null };
}
}
);
const authSlice = createSlice({
name: 'auth',
initialState, initialState,
reducers: { reducers: {
logOut: (state) => { setAuthToken: (state, action) => {
// remove the authToken cookie state.authToken = action.payload;
cookies.remove('authToken', { path: '/' }); state.isValid = !!action.payload;
// reset the state to there initial value
state.value.isValid = null
state.value.authToken = null
}, },
mountCheckIfValid: (state) => { clearAuth: (state) => {
state.value.checkIfValidMounted = true state.authToken = null;
state.isValid = false;
state.checkIfValidMounted = true;
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
// logIn thunk reducer builder
builder.addCase(logIn.fulfilled, (state: IinitialState, action) => { .addCase(checkAuth.fulfilled, (state, action) => {
// set the state state.isValid = action.payload.isValid;
if (action.payload.success) { state.checkIfValidMounted = true;
state.value.authToken = action.payload.authToken; state.lastChecked = Date.now();
state.value.isValid = true
if (!action.payload.isValid) {
state.authToken = null;
document.cookie = 'authToken=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
} }
}) })
// check if user authToken cookie is a valid one .addCase(login.fulfilled, (state, action) => {
builder.addCase(isLoggedIn.fulfilled, (state: IinitialState, action) => { state.authToken = action.payload.authToken;
// set the state state.isValid = action.payload.isValid;
state.value.isValid = action.payload.isValid; state.lastChecked = Date.now();
state.value.checkIfValidMounted = true
}) })
.addCase(logout.fulfilled, (state) => {
state.authToken = null;
state.isValid = false;
state.lastChecked = null;
});
}, },
}) });
// export the functions export const { setAuthToken, clearAuth } = authSlice.actions;
export const { logOut, mountCheckIfValid } = auth.actions; export const selectAuth = (state: RootState) => state.auth;
export { logIn, isLoggedIn }; export default authSlice.reducer;
export default auth.reducer;