diff --git a/apps/backoffice/src/app/features/user-management-host/UserManagementHostPage.tsx b/apps/backoffice/src/app/features/user-management-host/UserManagementHostPage.tsx index e6d8038d2..7f88afb6a 100644 --- a/apps/backoffice/src/app/features/user-management-host/UserManagementHostPage.tsx +++ b/apps/backoffice/src/app/features/user-management-host/UserManagementHostPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; +import { authStorage } from '@ema-platform/auth'; /** * Same-origin host for the user-management module. @@ -18,18 +19,13 @@ import { useNavigate, useLocation } from 'react-router-dom'; */ function readToken(): string | null { - return ( - localStorage.getItem('ema-backoffice-auth-token') ?? - (() => { - const escaped = 'auth-token'.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1'); - const match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)')); - return match ? decodeURIComponent(match[1]) : null; - })() - ); + const escaped = 'auth-token'.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1'); + const match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)')); + return authStorage.getToken() ?? (match ? decodeURIComponent(match[1]) : null); } function readRefreshToken(): string | null { - return localStorage.getItem('ema-backoffice-refresh-token') ?? null; + return authStorage.getRefreshToken() ?? null; } export default function UserManagementHostPage() { diff --git a/apps/backoffice/src/app/router/ProtectedRoute.tsx b/apps/backoffice/src/app/router/ProtectedRoute.tsx index ac718649c..c3492d2fe 100644 --- a/apps/backoffice/src/app/router/ProtectedRoute.tsx +++ b/apps/backoffice/src/app/router/ProtectedRoute.tsx @@ -1,13 +1 @@ -import { Navigate, Outlet } from 'react-router-dom'; - -function getTokenFromCookie(): string | undefined { - const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/); - return match ? decodeURIComponent(match[1]) : undefined; -} - -export function ProtectedRoute() { - const token = - localStorage.getItem('ema-backoffice-auth-token') ?? getTokenFromCookie(); - if (!token) return ; - return ; -} +export { ProtectedRoute } from '@ema-platform/auth'; diff --git a/apps/backoffice/src/app/store/index.ts b/apps/backoffice/src/app/store/index.ts index 22cc870f6..1b00fdc9f 100644 --- a/apps/backoffice/src/app/store/index.ts +++ b/apps/backoffice/src/app/store/index.ts @@ -1,6 +1,13 @@ import { configureStore } from '@reduxjs/toolkit'; -import { baseApi } from '@ema-platform/api'; -import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth'; +import { baseApi, configureTokenRefresh } from '@ema-platform/api'; +import { + authReducer, + signupReducer, + configureAuthStorage, + authStorage, + refreshAccessToken, + logout, +} from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth'; configureAuthStorage('ema-backoffice'); @@ -25,5 +32,13 @@ export const store = configureStore({ getDefaultMiddleware().concat(baseApi.middleware), }); +configureTokenRefresh({ + onTokenExpired: refreshAccessToken, + onAuthFailure: () => { + store.dispatch(logout()); + window.location.href = '/login'; + }, +}); + export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/apps/portal/src/app/components/ProtectedRoute.tsx b/apps/portal/src/app/components/ProtectedRoute.tsx index 1b5a4fd32..c3492d2fe 100644 --- a/apps/portal/src/app/components/ProtectedRoute.tsx +++ b/apps/portal/src/app/components/ProtectedRoute.tsx @@ -1,13 +1 @@ -import { Navigate, useLocation } from 'react-router-dom'; -import { useAppSelector } from '../store/hooks'; - -export function ProtectedRoute({ children }: { children: React.ReactNode }) { - const isAuthenticated = useAppSelector((s) => s.auth.isAuthenticated); - const location = useLocation(); - - if (!isAuthenticated) { - return ; - } - - return <>{children}; -} +export { ProtectedRoute } from '@ema-platform/auth'; diff --git a/apps/portal/src/app/store/index.ts b/apps/portal/src/app/store/index.ts index d1866d3d3..1dddd6952 100644 --- a/apps/portal/src/app/store/index.ts +++ b/apps/portal/src/app/store/index.ts @@ -1,6 +1,13 @@ import { configureStore } from '@reduxjs/toolkit'; -import { baseApi } from '@ema-platform/api'; -import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth'; +import { baseApi, configureTokenRefresh } from '@ema-platform/api'; +import { + authReducer, + signupReducer, + configureAuthStorage, + authStorage, + refreshAccessToken, + logout, +} from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth'; configureAuthStorage('ema-portal'); @@ -25,5 +32,13 @@ export const store = configureStore({ getDefaultMiddleware().concat(baseApi.middleware), }); +configureTokenRefresh({ + onTokenExpired: refreshAccessToken, + onAuthFailure: () => { + store.dispatch(logout()); + window.location.href = '/login'; + }, +}); + export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/libs/api/src/index.ts b/libs/api/src/index.ts index c64a910de..b24880dd7 100644 --- a/libs/api/src/index.ts +++ b/libs/api/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/base-api'; export * from './lib/query-and-mutation'; export * from './lib/session'; +export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth'; diff --git a/libs/api/src/lib/base-api/base-query-with-reauth.ts b/libs/api/src/lib/base-api/base-query-with-reauth.ts new file mode 100644 index 000000000..7d98a1cdb --- /dev/null +++ b/libs/api/src/lib/base-api/base-query-with-reauth.ts @@ -0,0 +1,53 @@ +import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react'; +import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import { resolveSessionContext } from '../session'; + +const BASE_API_URL = + (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? + 'http://localhost:3001/api'; + +let _onTokenExpired: (() => Promise) | null = null; +let _onAuthFailure: (() => void) | null = null; + +export function configureTokenRefresh(config: { + onTokenExpired: () => Promise; + onAuthFailure: () => void; +}) { + _onTokenExpired = config.onTokenExpired; + _onAuthFailure = config.onAuthFailure; +} + +export const baseQueryWithReauth: BaseQueryFn< + string | FetchArgs, + unknown, + FetchBaseQueryError +> = async (args, api, extraOptions) => { + const baseQuery = fetchBaseQuery({ + baseUrl: BASE_API_URL, + prepareHeaders: (headers) => { + const { token, sessionHeaders } = resolveSessionContext( + api.getState() as { auth?: { token?: string } }, + ); + if (token) headers.set('Authorization', `Bearer ${token}`); + Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v)); + return headers; + }, + }); + + let result = await baseQuery(args, api, extraOptions); + + if (result.error?.status === 401) { + if (_onTokenExpired) { + try { + await _onTokenExpired(); + result = await baseQuery(args, api, extraOptions); + } catch { + _onAuthFailure?.(); + } + } else { + _onAuthFailure?.(); + } + } + + return result; +}; diff --git a/libs/api/src/lib/base-api/index.ts b/libs/api/src/lib/base-api/index.ts index 6070cff60..0570a2d88 100644 --- a/libs/api/src/lib/base-api/index.ts +++ b/libs/api/src/lib/base-api/index.ts @@ -1,23 +1,9 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; -import { resolveSessionContext } from '../session'; - -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; +import { createApi } from '@reduxjs/toolkit/query/react'; +import { baseQueryWithReauth } from './base-query-with-reauth'; export const baseApi = createApi({ reducerPath: 'baseApi', - baseQuery: fetchBaseQuery({ - baseUrl: BASE_API_URL, - prepareHeaders: (headers, { getState }) => { - const { token, sessionHeaders } = resolveSessionContext( - getState() as { auth?: { token?: string } }, - ); - if (token) headers.set('Authorization', `Bearer ${token}`); - Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v)); - return headers; - }, - }), + baseQuery: baseQueryWithReauth, tagTypes: ['Api'], endpoints: () => ({}), }); diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index 972adb5ab..35e4d8c44 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -1,6 +1,7 @@ export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig'; export type { AuthConfigValue } from './lib/AuthConfig'; export { AuthShell, BrandMark } from './lib/components/AuthShell'; +export { ProtectedRoute } from './lib/components/ProtectedRoute'; export { LoginPage } from './lib/pages/LoginPage'; export { SignupPage } from './lib/pages/SignupPage'; export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage'; @@ -8,4 +9,5 @@ export { OTPVerificationPage } from './lib/pages/OTPVerificationPage'; export { authReducer, loginSuccess, setUser, logout, hydrateAuth } from './lib/store/auth.slice'; export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice'; export { configureAuthStorage, authStorage } from './lib/utils/auth-storage'; +export { refreshAccessToken } from './lib/utils/refresh-token'; export type { AuthUser, AuthState, LoginPayload } from './lib/types/auth.types'; diff --git a/libs/auth/src/lib/components/ProtectedRoute.tsx b/libs/auth/src/lib/components/ProtectedRoute.tsx new file mode 100644 index 000000000..9eb4d0e8f --- /dev/null +++ b/libs/auth/src/lib/components/ProtectedRoute.tsx @@ -0,0 +1,24 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { authStorage } from '../utils/auth-storage'; + +interface ProtectedRouteProps { + children?: ReactNode; + loginPath?: string; +} + +function getTokenFromCookie(): string | undefined { + const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/); + return match ? decodeURIComponent(match[1]) : undefined; +} + +export function ProtectedRoute({ children, loginPath = '/login' }: ProtectedRouteProps) { + const location = useLocation(); + const token = authStorage.getToken() ?? getTokenFromCookie(); + + if (!token) { + return ; + } + + return children ? <>{children} : ; +} diff --git a/libs/auth/src/lib/utils/auth-storage.ts b/libs/auth/src/lib/utils/auth-storage.ts index 19ff15fbf..d6076e70f 100644 --- a/libs/auth/src/lib/utils/auth-storage.ts +++ b/libs/auth/src/lib/utils/auth-storage.ts @@ -25,5 +25,7 @@ export const authStorage = { [key('auth-token'), key('refresh-token'), key('auth-user')].forEach((k) => localStorage.removeItem(k), ); + document.cookie = + 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax'; }, }; diff --git a/libs/auth/src/lib/utils/refresh-token.ts b/libs/auth/src/lib/utils/refresh-token.ts new file mode 100644 index 000000000..a809ad268 --- /dev/null +++ b/libs/auth/src/lib/utils/refresh-token.ts @@ -0,0 +1,31 @@ +import { authStorage } from './auth-storage'; + +const BASE_API_URL = + (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? + 'http://localhost:3001/api'; + +interface RefreshResponse { + token: string; + refreshToken: string; +} + +export async function refreshAccessToken(): Promise { + const refreshToken = authStorage.getRefreshToken(); + if (!refreshToken) throw new Error('No refresh token available'); + + const response = await fetch(`${BASE_API_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) { + authStorage.clear(); + throw new Error('Token refresh failed'); + } + + const data: RefreshResponse = await response.json(); + authStorage.setToken(data.token); + if (data.refreshToken) authStorage.setRefreshToken(data.refreshToken); + return data.token; +} diff --git a/user-management-config/project.theme.ts b/user-management-config/project.theme.ts index 14ac84530..0feda5e03 100644 --- a/user-management-config/project.theme.ts +++ b/user-management-config/project.theme.ts @@ -112,7 +112,7 @@ export const projectTheme: DesignConfig = { // Each value is also exposed as a --um-* CSS var, so tweaks apply instantly. // ───────────────────────────────────────────────────────────────────────── layout: { - userManagementView: "classic", // TODO: "legacy" for the top-tab UI + userManagementView: "legacy", // "legacy" → top-tab UI, "classic" → side menu // showTopBar: false, // TODO: overrides VITE_SHOW_TOP_BAR // ── Dimensions ──────────────────────────────────────────────────────────