feat: implement web URL constants and enhance navigation handling
- Introduced a centralized `WEB_URL` constant for managing application routes, improving maintainability and readability across components. - Updated various components, including login, auth, and main app modules, to utilize the new `WEB_URL` constants for navigation, ensuring consistency in route management. - Added a new `AppHomeRedirect` component to streamline user redirection based on privileges, enhancing user experience. - Implemented client-side navigation functions to prevent full page reloads, improving performance and user interaction. - Added tests for new functionalities, ensuring reliability in navigation and URL handling. These changes significantly enhance the application's routing structure and navigation efficiency, providing a more cohesive user experience.
This commit is contained in:
@@ -21,8 +21,8 @@ test.describe('login', () => {
|
|||||||
await page.getByLabel('Password').fill(E2E_LOGIN.password);
|
await page.getByLabel('Password').fill(E2E_LOGIN.password);
|
||||||
await page.getByRole('button', { name: 'Login' }).click();
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/app(\/|$)/);
|
await expect(page).toHaveURL(/\/app\/timeline\/index/);
|
||||||
await expect(page.getByRole('heading', { name: 'Full Page' })).toBeVisible();
|
await expect(page.getByPlaceholder('Search activities')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows an error and stays on login when credentials are rejected', async ({ page }) => {
|
test('shows an error and stays on login when credentials are rejected', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { WEB_URL } from '../../core/constants/web-url';
|
||||||
import { AuthPageGuard } from '../../core/lib/auth-guard';
|
import { AuthPageGuard } from '../../core/lib/auth-guard';
|
||||||
|
|
||||||
const LoginPage = lazy(() => import('./login'));
|
const LoginPage = lazy(() => import('./login'));
|
||||||
@@ -9,7 +10,7 @@ export default function AuthModule() {
|
|||||||
<AuthPageGuard>
|
<AuthPageGuard>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/" element={<Navigate to="/auth/login" replace={true} />} />
|
<Route path="/" element={<Navigate to={WEB_URL.LOGIN} replace={true} />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthPageGuard>
|
</AuthPageGuard>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { StatusPage, AgGridProvider } from '@repo/ui/components';
|
|||||||
import { useTranslation } from '@repo/core-i18n';
|
import { useTranslation } from '@repo/core-i18n';
|
||||||
import { LoadingScreen } from '../core/components/loading-screen';
|
import { LoadingScreen } from '../core/components/loading-screen';
|
||||||
import { ComingSoonPage } from '../core/components/coming-soon-page';
|
import { ComingSoonPage } from '../core/components/coming-soon-page';
|
||||||
|
import { WEB_URL } from '../core/constants/web-url';
|
||||||
|
import { registerClientNavigate, registerSessionLifecycle } from '../core/lib/client-navigation';
|
||||||
import { useThemeStore } from '../core/stores/theme.store';
|
import { useThemeStore } from '../core/stores/theme.store';
|
||||||
import { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker';
|
import { initializeAndPurgeHistoryBackground, resetHistoryCache } from './main/layouts/hooks/useHistoryTracker';
|
||||||
|
|
||||||
const AuthModule = lazy(() => import('./auth'));
|
const AuthModule = lazy(() => import('./auth'));
|
||||||
const AppModule = lazy(() => import('./main'));
|
const AppModule = lazy(() => import('./main'));
|
||||||
@@ -20,7 +22,7 @@ function NotFoundPage() {
|
|||||||
description={t('common:systemPages.notFound.description')}
|
description={t('common:systemPages.notFound.description')}
|
||||||
showActionsBack
|
showActionsBack
|
||||||
showActionsHome
|
showActionsHome
|
||||||
homeUrl="/app"
|
homeUrl={WEB_URL.APP}
|
||||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||||
/>
|
/>
|
||||||
@@ -36,7 +38,7 @@ function ForbiddenPage() {
|
|||||||
description={t('common:systemPages.forbidden.description')}
|
description={t('common:systemPages.forbidden.description')}
|
||||||
showActionsBack
|
showActionsBack
|
||||||
showActionsHome
|
showActionsHome
|
||||||
homeUrl="/app"
|
homeUrl={WEB_URL.APP}
|
||||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||||
/>
|
/>
|
||||||
@@ -61,10 +63,16 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/403', element: <ForbiddenPage /> },
|
{ path: '/403', element: <ForbiddenPage /> },
|
||||||
{ path: '/maintenance', element: <MaintenancePage /> },
|
{ path: '/maintenance', element: <MaintenancePage /> },
|
||||||
{ path: '/coming-soon', element: <ComingSoonPage /> },
|
{ path: '/coming-soon', element: <ComingSoonPage /> },
|
||||||
{ path: '/', element: <Navigate to="/app" /> },
|
{ path: '/', element: <Navigate to={WEB_URL.APP} /> },
|
||||||
{ path: '*', element: <Navigate to="/404" /> },
|
{ path: '*', element: <Navigate to="/404" /> },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
registerClientNavigate((to, options) => router.navigate(to, options));
|
||||||
|
registerSessionLifecycle({
|
||||||
|
onEnd: resetHistoryCache,
|
||||||
|
onStart: () => initializeAndPurgeHistoryBackground(),
|
||||||
|
});
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||||
|
import { WEB_URL } from '../../core/constants/web-url';
|
||||||
|
import { filterMenuByViewPrivilege } from '../../core/lib/filter-menu-by-view-privilege';
|
||||||
|
import { firstProductPath } from '../../core/lib/first-product-path';
|
||||||
|
import { appDatabase, AppDatabaseKey } from '../../core/storage/local';
|
||||||
|
import { MENU_ITEMS } from './layouts/data/menu.data';
|
||||||
|
|
||||||
|
export function AppHomeRedirect() {
|
||||||
|
const [to, setTo] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
||||||
|
if (!token || cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await appDatabase.getItem<{ isSuperadmin?: boolean }>(AppDatabaseKey.USER_PROFILE);
|
||||||
|
const privileges = await appDatabase.getItem<Record<string, PrivilegeEntity>>(AppDatabaseKey.USER_PRIVILEGE);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = filterMenuByViewPrivilege(MENU_ITEMS, privileges ?? {}, Boolean(profile?.isSuperadmin));
|
||||||
|
setTo(firstProductPath(filtered, WEB_URL.APP_HOME));
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!to) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to={to} replace />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import ModuleLayout from './layouts/module.layout';
|
import { EmbeddedComingSoonPage } from '../../core/components/coming-soon-page';
|
||||||
import { GlobalCredentialChecker } from '../../core/lib/auth-guard';
|
import { GlobalCredentialChecker } from '../../core/lib/auth-guard';
|
||||||
|
import { AppHomeRedirect } from './app-home-redirect';
|
||||||
|
import ModuleLayout from './layouts/module.layout';
|
||||||
|
|
||||||
const ExampleModule = lazy(() => import('./modules/example'));
|
const ExampleModule = lazy(() => import('./modules/example'));
|
||||||
const SystemSetting = lazy(() => import('./modules/system/setting'));
|
const SystemSetting = lazy(() => import('./modules/system/setting'));
|
||||||
@@ -29,7 +31,8 @@ export default function AppModule() {
|
|||||||
<Route path="/sales/*" element={<SalesModule />} />
|
<Route path="/sales/*" element={<SalesModule />} />
|
||||||
<Route path="/timeline/*" element={<TimelineModule />} />
|
<Route path="/timeline/*" element={<TimelineModule />} />
|
||||||
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
||||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
<Route path="/dashboard" element={<EmbeddedComingSoonPage />} />
|
||||||
|
<Route path="/" element={<AppHomeRedirect />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ModuleLayout>
|
</ModuleLayout>
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ if (historyChannel) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 2. Background Task: Initialization & Purge (Remain the same)
|
// 2. Background Task: Initialization & Purge (Remain the same)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
export function resetHistoryCache(): void {
|
||||||
|
memoryHistoryCache = [];
|
||||||
|
isHistoryReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initializeAndPurgeHistoryBackground() {
|
export async function initializeAndPurgeHistoryBackground() {
|
||||||
if (isHistoryReady) return;
|
if (isHistoryReady) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StatusPage } from '@repo/ui/components';
|
import { StatusPage } from '@repo/ui/components';
|
||||||
import { useTranslation } from '@repo/core-i18n';
|
import { useTranslation } from '@repo/core-i18n';
|
||||||
|
import { WEB_URL } from '../constants/web-url';
|
||||||
|
|
||||||
interface ComingSoonPageProps {
|
interface ComingSoonPageProps {
|
||||||
/** Use inside ModuleLayout so the page fits under the app chrome */
|
/** Use inside ModuleLayout so the page fits under the app chrome */
|
||||||
@@ -16,7 +17,7 @@ export function ComingSoonPage({ embedded = false }: ComingSoonPageProps) {
|
|||||||
description={t('common:systemPages.comingSoon.description')}
|
description={t('common:systemPages.comingSoon.description')}
|
||||||
showActionsBack={!embedded}
|
showActionsBack={!embedded}
|
||||||
showActionsHome
|
showActionsHome
|
||||||
homeUrl="/app"
|
homeUrl={WEB_URL.APP}
|
||||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||||
height={embedded ? 500 : undefined}
|
height={embedded ? 500 : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { WEB_URL } from './web-url';
|
||||||
|
|
||||||
|
describe('WEB_URL', () => {
|
||||||
|
it('uses an ungated fallback home instead of the example module', () => {
|
||||||
|
expect(WEB_URL.APP_HOME).toBe('/app/dashboard');
|
||||||
|
expect(WEB_URL.APP_HOME).not.toContain('/example/');
|
||||||
|
expect(WEB_URL.LOGIN).toBe('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
export const WEB_URL = {
|
export const WEB_URL = {
|
||||||
|
LOGIN: '/auth/login',
|
||||||
|
APP: '/app',
|
||||||
|
APP_HOME: '/app/dashboard',
|
||||||
|
DASHBOARD: '/app/dashboard',
|
||||||
|
TIMELINE: '/app/timeline',
|
||||||
USERS: '/app/system/users',
|
USERS: '/app/system/users',
|
||||||
DIVISIONS: '/app/configuration/divisions',
|
DIVISIONS: '/app/configuration/divisions',
|
||||||
BRANCHES: '/app/configuration/branches',
|
BRANCHES: '/app/configuration/branches',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, ReactNode } from 'react';
|
import { useEffect, useState, ReactNode } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { appDatabase, AppDatabaseKey } from '../storage/local';
|
import { appDatabase, AppDatabaseKey } from '../storage/local';
|
||||||
|
import { WEB_URL } from '../constants/web-url';
|
||||||
import { refreshAuthSession, terminateAuthSession } from './auth.helper';
|
import { refreshAuthSession, terminateAuthSession } from './auth.helper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,12 +47,12 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
||||||
if (token && !isTokenExpired(token)) {
|
if (token && !isTokenExpired(token)) {
|
||||||
navigate('/app', { replace: true });
|
navigate(WEB_URL.APP, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await restoreSessionWithRefresh()) {
|
if (await restoreSessionWithRefresh()) {
|
||||||
navigate('/app', { replace: true });
|
navigate(WEB_URL.APP, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +81,6 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
|
|||||||
*/
|
*/
|
||||||
export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
|
export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let intervalId: ReturnType<typeof setInterval>;
|
|
||||||
|
|
||||||
async function checkCredential() {
|
async function checkCredential() {
|
||||||
try {
|
try {
|
||||||
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
||||||
@@ -102,7 +101,7 @@ export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
checkCredential();
|
checkCredential();
|
||||||
|
|
||||||
intervalId = setInterval(checkCredential, 60000);
|
const intervalId = setInterval(checkCredential, 60000);
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ vi.mock('./api-client', () => ({
|
|||||||
import { appDatabase, appStorage } from '../storage/local';
|
import { appDatabase, appStorage } from '../storage/local';
|
||||||
import { apiClient } from './api-client';
|
import { apiClient } from './api-client';
|
||||||
import { initiateAuthSession, logoutAuthSession, persistTokenPair, terminateAuthSession } from './auth.helper';
|
import { initiateAuthSession, logoutAuthSession, persistTokenPair, terminateAuthSession } from './auth.helper';
|
||||||
|
import { resetClientNavigate } from './client-navigation';
|
||||||
|
|
||||||
const tokens = { accessToken: 'access-1', refreshToken: 'refresh-1' };
|
const tokens = { accessToken: 'access-1', refreshToken: 'refresh-1' };
|
||||||
const me = {
|
const me = {
|
||||||
@@ -45,7 +46,7 @@ const me = {
|
|||||||
function stubLocation(pathname: string, search = '') {
|
function stubLocation(pathname: string, search = '') {
|
||||||
const replace = vi.fn();
|
const replace = vi.fn();
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
location: { pathname, search, replace },
|
location: { pathname, search, replace, origin: 'https://admin.trackgo.eigen.co.id' },
|
||||||
});
|
});
|
||||||
return replace;
|
return replace;
|
||||||
}
|
}
|
||||||
@@ -53,6 +54,7 @@ function stubLocation(pathname: string, search = '') {
|
|||||||
describe('auth.helper', () => {
|
describe('auth.helper', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
resetClientNavigate();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists the token pair', async () => {
|
it('persists the token pair', async () => {
|
||||||
@@ -89,6 +91,18 @@ describe('auth.helper', () => {
|
|||||||
expect(replace).toHaveBeenCalledWith('/app');
|
expect(replace).toHaveBeenCalledWith('/app');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the SPA navigator after login so nginx is not asked for a missing file', async () => {
|
||||||
|
const { registerClientNavigate } = await import('./client-navigation');
|
||||||
|
const navigate = vi.fn();
|
||||||
|
const replace = stubLocation('/auth/login', '');
|
||||||
|
registerClientNavigate(navigate);
|
||||||
|
|
||||||
|
await initiateAuthSession(tokens, me);
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith('/app', { replace: true });
|
||||||
|
expect(replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('honours the redirect query param after login', async () => {
|
it('honours the redirect query param after login', async () => {
|
||||||
const replace = stubLocation('/auth/login', '?redirect=%2Fapp%2Fcustomers');
|
const replace = stubLocation('/auth/login', '?redirect=%2Fapp%2Fcustomers');
|
||||||
|
|
||||||
@@ -97,6 +111,14 @@ describe('auth.helper', () => {
|
|||||||
expect(replace).toHaveBeenCalledWith('/app/customers');
|
expect(replace).toHaveBeenCalledWith('/app/customers');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores an external redirect query param', async () => {
|
||||||
|
const replace = stubLocation('/auth/login', '?redirect=https%3A%2F%2Fevil.example');
|
||||||
|
|
||||||
|
await initiateAuthSession(tokens, me);
|
||||||
|
|
||||||
|
expect(replace).toHaveBeenCalledWith('/app');
|
||||||
|
});
|
||||||
|
|
||||||
it('clears the refresh token when terminating a session', async () => {
|
it('clears the refresh token when terminating a session', async () => {
|
||||||
const replace = stubLocation('/app/customers');
|
const replace = stubLocation('/app/customers');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local';
|
import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local';
|
||||||
import { API_URL } from '../constants/api-url';
|
import { API_URL } from '../constants/api-url';
|
||||||
|
import { WEB_URL } from '../constants/web-url';
|
||||||
|
import { clientReplace, notifySessionEnd, notifySessionStart } from './client-navigation';
|
||||||
import { mapUserPrivileges } from './map-user-privileges';
|
import { mapUserPrivileges } from './map-user-privileges';
|
||||||
|
import { safeInternalPath } from './safe-internal-path';
|
||||||
import type { AuthUser, TokenPair } from './auth.types';
|
import type { AuthUser, TokenPair } from './auth.types';
|
||||||
|
|
||||||
interface TerminateOptions {
|
interface TerminateOptions {
|
||||||
@@ -39,16 +42,17 @@ export async function terminateAuthSession(options: TerminateOptions = {}): Prom
|
|||||||
await appDatabase.removeItem(AppDatabaseKey.REFRESH_TOKEN);
|
await appDatabase.removeItem(AppDatabaseKey.REFRESH_TOKEN);
|
||||||
await appStorage.removeItem(AppStorageKey.USER_ID);
|
await appStorage.removeItem(AppStorageKey.USER_ID);
|
||||||
|
|
||||||
let loginUrl = '/auth/login';
|
let loginUrl = WEB_URL.LOGIN;
|
||||||
const isNotLoginPage = !window.location.pathname.includes('/auth/login');
|
const isLoginPage =
|
||||||
|
window.location.pathname === WEB_URL.LOGIN || window.location.pathname.startsWith(`${WEB_URL.LOGIN}/`);
|
||||||
|
|
||||||
if (isNotLoginPage) {
|
if (!isLoginPage) {
|
||||||
if (preserveRedirect) {
|
if (preserveRedirect) {
|
||||||
const currentPath = window.location.pathname + window.location.search;
|
loginUrl += `?redirect=${encodeURIComponent(window.location.pathname)}`;
|
||||||
loginUrl += `?redirect=${encodeURIComponent(currentPath)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.replace(loginUrl);
|
notifySessionEnd();
|
||||||
|
clientReplace(loginUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +63,10 @@ export async function initiateAuthSession(tokens: TokenPair, me: AuthUser): Prom
|
|||||||
|
|
||||||
await persistTokenPair(tokens);
|
await persistTokenPair(tokens);
|
||||||
await persistUserSession(me);
|
await persistUserSession(me);
|
||||||
|
notifySessionStart();
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const redirectTo = params.get('redirect');
|
clientReplace(safeInternalPath(params.get('redirect'), WEB_URL.APP));
|
||||||
window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let refreshInFlight: Promise<TokenPair> | null = null;
|
let refreshInFlight: Promise<TokenPair> | null = null;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
clientReplace,
|
||||||
|
notifySessionEnd,
|
||||||
|
registerClientNavigate,
|
||||||
|
registerSessionLifecycle,
|
||||||
|
resetClientNavigate,
|
||||||
|
} from './client-navigation';
|
||||||
|
|
||||||
|
describe('clientReplace', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
resetClientNavigate();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the registered SPA navigator instead of a full page load', () => {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
const replace = vi.fn();
|
||||||
|
vi.stubGlobal('window', { location: { replace } });
|
||||||
|
registerClientNavigate(navigate);
|
||||||
|
|
||||||
|
clientReplace('/auth/login');
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith('/auth/login', { replace: true });
|
||||||
|
expect(replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to location.replace when no navigator is registered', () => {
|
||||||
|
const replace = vi.fn();
|
||||||
|
vi.stubGlobal('window', { location: { replace } });
|
||||||
|
|
||||||
|
clientReplace('/auth/login');
|
||||||
|
|
||||||
|
expect(replace).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs onEnd when the session is torn down', () => {
|
||||||
|
const onEnd = vi.fn();
|
||||||
|
registerSessionLifecycle({ onEnd });
|
||||||
|
|
||||||
|
notifySessionEnd();
|
||||||
|
|
||||||
|
expect(onEnd).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
type ClientNavigate = (to: string, options: { replace: boolean }) => void | Promise<void>;
|
||||||
|
|
||||||
|
interface SessionLifecycle {
|
||||||
|
onStart?: () => void | Promise<void>;
|
||||||
|
onEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let navigateFn: ClientNavigate | null = null;
|
||||||
|
let sessionLifecycle: SessionLifecycle = {};
|
||||||
|
|
||||||
|
export function registerClientNavigate(fn: ClientNavigate): void {
|
||||||
|
navigateFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSessionLifecycle(lifecycle: SessionLifecycle): void {
|
||||||
|
sessionLifecycle = lifecycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetClientNavigate(): void {
|
||||||
|
navigateFn = null;
|
||||||
|
sessionLifecycle = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifySessionStart(): void {
|
||||||
|
void sessionLifecycle.onStart?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifySessionEnd(): void {
|
||||||
|
sessionLifecycle.onEnd?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stay inside the already-loaded SPA when possible.
|
||||||
|
* A full `location.replace` makes nginx look up a real file (e.g. `/auth/login`) and 404.
|
||||||
|
*/
|
||||||
|
export function clientReplace(to: string): void {
|
||||||
|
if (!navigateFn) {
|
||||||
|
window.location.replace(to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Promise.resolve(navigateFn(to, { replace: true })).catch(() => {
|
||||||
|
window.location.replace(to);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { firstProductPath } from './first-product-path';
|
||||||
|
|
||||||
|
describe('firstProductPath', () => {
|
||||||
|
it('skips ungated placeholders and returns the first privileged leaf', () => {
|
||||||
|
const path = firstProductPath(
|
||||||
|
[
|
||||||
|
{ path: '/app/dashboard' },
|
||||||
|
{ path: '/app/timeline/index', moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE' },
|
||||||
|
{
|
||||||
|
path: '/app/sales',
|
||||||
|
children: [{ path: '/app/sales/orders/index', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/app/dashboard',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(path).toBe('/app/timeline/index');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks nested children when the first privileged item is nested', () => {
|
||||||
|
const path = firstProductPath(
|
||||||
|
[
|
||||||
|
{ path: '/app/dashboard' },
|
||||||
|
{
|
||||||
|
path: '/app/logistics',
|
||||||
|
children: [{ path: '/app/logistics/plans/index', moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/app/dashboard',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(path).toBe('/app/logistics/plans/index');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the fallback when no privileged leaf remains', () => {
|
||||||
|
expect(firstProductPath([{ path: '/app/dashboard' }], '/app/dashboard')).toBe('/app/dashboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function firstProductPath<T extends { path?: string; moduleKey?: string; children?: T[] }>(
|
||||||
|
items: T[],
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.children?.length) {
|
||||||
|
const nested = firstProductPath(item.children, '');
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.moduleKey && item.path) {
|
||||||
|
return item.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { safeInternalPath } from './safe-internal-path';
|
||||||
|
|
||||||
|
const ORIGIN = 'https://admin.trackgo.eigen.co.id';
|
||||||
|
|
||||||
|
describe('safeInternalPath', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('window', { location: { origin: ORIGIN } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the fallback when the value is missing', () => {
|
||||||
|
expect(safeInternalPath(null, '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath(undefined, '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath('', '/app')).toBe('/app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an in-app absolute path', () => {
|
||||||
|
expect(safeInternalPath('/app/timeline/index', '/app')).toBe('/app/timeline/index');
|
||||||
|
expect(safeInternalPath('/app/timeline/index?tab=map', '/app')).toBe('/app/timeline/index?tab=map');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects open redirects', () => {
|
||||||
|
expect(safeInternalPath('https://evil.example', '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath('//evil.example/phish', '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath('auth/login', '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath('/auth/login', '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath('/\\evil.example', '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath('/\tevil.example', '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath('/\n/evil.example', '/app')).toBe('/app');
|
||||||
|
expect(safeInternalPath('/\r/evil.example', '/app')).toBe('/app');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { WEB_URL } from '../constants/web-url';
|
||||||
|
|
||||||
|
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
|
||||||
|
|
||||||
|
function resolveOrigin(): string {
|
||||||
|
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
return 'http://localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allows in-app paths only (`/app` or `/app/...`). Rejects open redirects. */
|
||||||
|
export function safeInternalPath(raw: string | null | undefined, fallback: string): string {
|
||||||
|
if (!raw || raw.includes('\\') || CONTROL_CHARS.test(raw)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate: URL;
|
||||||
|
try {
|
||||||
|
candidate = new URL(raw, resolveOrigin());
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.origin !== resolveOrigin()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `${candidate.pathname}${candidate.search}${candidate.hash}`;
|
||||||
|
if (path !== WEB_URL.APP && !path.startsWith(`${WEB_URL.APP}/`)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user