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 ──────────────────────────────────────────────────────────