diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts index 3307e5e..1a697e9 100644 --- a/apps/web/e2e/login.spec.ts +++ b/apps/web/e2e/login.spec.ts @@ -21,8 +21,8 @@ test.describe('login', () => { await page.getByLabel('Password').fill(E2E_LOGIN.password); await page.getByRole('button', { name: 'Login' }).click(); - await expect(page).toHaveURL(/\/app(\/|$)/); - await expect(page.getByRole('heading', { name: 'Full Page' })).toBeVisible(); + await expect(page).toHaveURL(/\/app\/timeline\/index/); + await expect(page.getByPlaceholder('Search activities')).toBeVisible(); }); test('shows an error and stays on login when credentials are rejected', async ({ page }) => { diff --git a/apps/web/src/apps/auth/index.tsx b/apps/web/src/apps/auth/index.tsx index 63edda4..e9162c1 100644 --- a/apps/web/src/apps/auth/index.tsx +++ b/apps/web/src/apps/auth/index.tsx @@ -1,5 +1,6 @@ import { lazy } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; +import { WEB_URL } from '../../core/constants/web-url'; import { AuthPageGuard } from '../../core/lib/auth-guard'; const LoginPage = lazy(() => import('./login')); @@ -9,7 +10,7 @@ export default function AuthModule() { } /> - } /> + } /> } /> diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx index 101d80d..a8470f6 100644 --- a/apps/web/src/apps/index.tsx +++ b/apps/web/src/apps/index.tsx @@ -5,8 +5,10 @@ import { StatusPage, AgGridProvider } from '@repo/ui/components'; import { useTranslation } from '@repo/core-i18n'; import { LoadingScreen } from '../core/components/loading-screen'; 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 { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker'; +import { initializeAndPurgeHistoryBackground, resetHistoryCache } from './main/layouts/hooks/useHistoryTracker'; const AuthModule = lazy(() => import('./auth')); const AppModule = lazy(() => import('./main')); @@ -20,7 +22,7 @@ function NotFoundPage() { description={t('common:systemPages.notFound.description')} showActionsBack showActionsHome - homeUrl="/app" + homeUrl={WEB_URL.APP} backButtonLabel={t('common:systemPages.actions.goBack')} homeButtonLabel={t('common:systemPages.actions.backToHome')} /> @@ -36,7 +38,7 @@ function ForbiddenPage() { description={t('common:systemPages.forbidden.description')} showActionsBack showActionsHome - homeUrl="/app" + homeUrl={WEB_URL.APP} backButtonLabel={t('common:systemPages.actions.goBack')} homeButtonLabel={t('common:systemPages.actions.backToHome')} /> @@ -61,10 +63,16 @@ const router = createBrowserRouter([ { path: '/403', element: }, { path: '/maintenance', element: }, { path: '/coming-soon', element: }, - { path: '/', element: }, + { path: '/', element: }, { path: '*', element: }, ]); +registerClientNavigate((to, options) => router.navigate(to, options)); +registerSessionLifecycle({ + onEnd: resetHistoryCache, + onStart: () => initializeAndPurgeHistoryBackground(), +}); + export default function App() { const colorScheme = useThemeStore((s) => s.colorScheme); diff --git a/apps/web/src/apps/main/app-home-redirect.tsx b/apps/web/src/apps/main/app-home-redirect.tsx new file mode 100644 index 0000000..5a2d8a7 --- /dev/null +++ b/apps/web/src/apps/main/app-home-redirect.tsx @@ -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(null); + + useEffect(() => { + let cancelled = false; + + async function load() { + const token = await appDatabase.getItem(AppDatabaseKey.ACCESS_TOKEN); + if (!token || cancelled) { + return; + } + + const profile = await appDatabase.getItem<{ isSuperadmin?: boolean }>(AppDatabaseKey.USER_PROFILE); + const privileges = await appDatabase.getItem>(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 ; +} diff --git a/apps/web/src/apps/main/index.tsx b/apps/web/src/apps/main/index.tsx index 26bd453..7b1e023 100644 --- a/apps/web/src/apps/main/index.tsx +++ b/apps/web/src/apps/main/index.tsx @@ -1,7 +1,9 @@ import { lazy } from 'react'; 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 { AppHomeRedirect } from './app-home-redirect'; +import ModuleLayout from './layouts/module.layout'; const ExampleModule = lazy(() => import('./modules/example')); const SystemSetting = lazy(() => import('./modules/system/setting')); @@ -29,7 +31,8 @@ export default function AppModule() { } /> } /> } /> - } /> + } /> + } /> } /> diff --git a/apps/web/src/apps/main/layouts/hooks/useHistoryTracker.ts b/apps/web/src/apps/main/layouts/hooks/useHistoryTracker.ts index 0717acf..baed516 100644 --- a/apps/web/src/apps/main/layouts/hooks/useHistoryTracker.ts +++ b/apps/web/src/apps/main/layouts/hooks/useHistoryTracker.ts @@ -29,6 +29,11 @@ if (historyChannel) { // --------------------------------------------------------------------------- // 2. Background Task: Initialization & Purge (Remain the same) // --------------------------------------------------------------------------- +export function resetHistoryCache(): void { + memoryHistoryCache = []; + isHistoryReady = false; +} + export async function initializeAndPurgeHistoryBackground() { if (isHistoryReady) return; diff --git a/apps/web/src/core/components/coming-soon-page.tsx b/apps/web/src/core/components/coming-soon-page.tsx index ca1fdbf..ece7c5d 100644 --- a/apps/web/src/core/components/coming-soon-page.tsx +++ b/apps/web/src/core/components/coming-soon-page.tsx @@ -1,5 +1,6 @@ import { StatusPage } from '@repo/ui/components'; import { useTranslation } from '@repo/core-i18n'; +import { WEB_URL } from '../constants/web-url'; interface ComingSoonPageProps { /** 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')} showActionsBack={!embedded} showActionsHome - homeUrl="/app" + homeUrl={WEB_URL.APP} backButtonLabel={t('common:systemPages.actions.goBack')} homeButtonLabel={t('common:systemPages.actions.backToHome')} height={embedded ? 500 : undefined} diff --git a/apps/web/src/core/constants/web-url.test.ts b/apps/web/src/core/constants/web-url.test.ts new file mode 100644 index 0000000..01da7bd --- /dev/null +++ b/apps/web/src/core/constants/web-url.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/core/constants/web-url.ts b/apps/web/src/core/constants/web-url.ts index 45d2d26..ab5e95e 100644 --- a/apps/web/src/core/constants/web-url.ts +++ b/apps/web/src/core/constants/web-url.ts @@ -1,4 +1,9 @@ export const WEB_URL = { + LOGIN: '/auth/login', + APP: '/app', + APP_HOME: '/app/dashboard', + DASHBOARD: '/app/dashboard', + TIMELINE: '/app/timeline', USERS: '/app/system/users', DIVISIONS: '/app/configuration/divisions', BRANCHES: '/app/configuration/branches', diff --git a/apps/web/src/core/lib/auth-guard.tsx b/apps/web/src/core/lib/auth-guard.tsx index fcb91db..de77e55 100644 --- a/apps/web/src/core/lib/auth-guard.tsx +++ b/apps/web/src/core/lib/auth-guard.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { appDatabase, AppDatabaseKey } from '../storage/local'; +import { WEB_URL } from '../constants/web-url'; import { refreshAuthSession, terminateAuthSession } from './auth.helper'; /** @@ -46,12 +47,12 @@ export function AuthPageGuard({ children }: { children: ReactNode }) { try { const token = await appDatabase.getItem(AppDatabaseKey.ACCESS_TOKEN); if (token && !isTokenExpired(token)) { - navigate('/app', { replace: true }); + navigate(WEB_URL.APP, { replace: true }); return; } if (await restoreSessionWithRefresh()) { - navigate('/app', { replace: true }); + navigate(WEB_URL.APP, { replace: true }); return; } @@ -80,8 +81,6 @@ export function AuthPageGuard({ children }: { children: ReactNode }) { */ export function GlobalCredentialChecker({ children }: { children: ReactNode }) { useEffect(() => { - let intervalId: ReturnType; - async function checkCredential() { try { const token = await appDatabase.getItem(AppDatabaseKey.ACCESS_TOKEN); @@ -102,7 +101,7 @@ export function GlobalCredentialChecker({ children }: { children: ReactNode }) { checkCredential(); - intervalId = setInterval(checkCredential, 60000); + const intervalId = setInterval(checkCredential, 60000); return () => clearInterval(intervalId); }, []); diff --git a/apps/web/src/core/lib/auth.helper.test.ts b/apps/web/src/core/lib/auth.helper.test.ts index 8d7124c..8d13de0 100644 --- a/apps/web/src/core/lib/auth.helper.test.ts +++ b/apps/web/src/core/lib/auth.helper.test.ts @@ -30,6 +30,7 @@ vi.mock('./api-client', () => ({ import { appDatabase, appStorage } from '../storage/local'; import { apiClient } from './api-client'; import { initiateAuthSession, logoutAuthSession, persistTokenPair, terminateAuthSession } from './auth.helper'; +import { resetClientNavigate } from './client-navigation'; const tokens = { accessToken: 'access-1', refreshToken: 'refresh-1' }; const me = { @@ -45,7 +46,7 @@ const me = { function stubLocation(pathname: string, search = '') { const replace = vi.fn(); vi.stubGlobal('window', { - location: { pathname, search, replace }, + location: { pathname, search, replace, origin: 'https://admin.trackgo.eigen.co.id' }, }); return replace; } @@ -53,6 +54,7 @@ function stubLocation(pathname: string, search = '') { describe('auth.helper', () => { beforeEach(() => { vi.clearAllMocks(); + resetClientNavigate(); }); it('persists the token pair', async () => { @@ -89,6 +91,18 @@ describe('auth.helper', () => { 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 () => { const replace = stubLocation('/auth/login', '?redirect=%2Fapp%2Fcustomers'); @@ -97,6 +111,14 @@ describe('auth.helper', () => { 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 () => { const replace = stubLocation('/app/customers'); diff --git a/apps/web/src/core/lib/auth.helper.ts b/apps/web/src/core/lib/auth.helper.ts index 8332cea..cd40f60 100644 --- a/apps/web/src/core/lib/auth.helper.ts +++ b/apps/web/src/core/lib/auth.helper.ts @@ -1,6 +1,9 @@ import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local'; 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 { safeInternalPath } from './safe-internal-path'; import type { AuthUser, TokenPair } from './auth.types'; interface TerminateOptions { @@ -39,16 +42,17 @@ export async function terminateAuthSession(options: TerminateOptions = {}): Prom await appDatabase.removeItem(AppDatabaseKey.REFRESH_TOKEN); await appStorage.removeItem(AppStorageKey.USER_ID); - let loginUrl = '/auth/login'; - const isNotLoginPage = !window.location.pathname.includes('/auth/login'); + let loginUrl = WEB_URL.LOGIN; + const isLoginPage = + window.location.pathname === WEB_URL.LOGIN || window.location.pathname.startsWith(`${WEB_URL.LOGIN}/`); - if (isNotLoginPage) { + if (!isLoginPage) { if (preserveRedirect) { - const currentPath = window.location.pathname + window.location.search; - loginUrl += `?redirect=${encodeURIComponent(currentPath)}`; + loginUrl += `?redirect=${encodeURIComponent(window.location.pathname)}`; } - window.location.replace(loginUrl); + notifySessionEnd(); + clientReplace(loginUrl); } } @@ -59,10 +63,10 @@ export async function initiateAuthSession(tokens: TokenPair, me: AuthUser): Prom await persistTokenPair(tokens); await persistUserSession(me); + notifySessionStart(); const params = new URLSearchParams(window.location.search); - const redirectTo = params.get('redirect'); - window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app'); + clientReplace(safeInternalPath(params.get('redirect'), WEB_URL.APP)); } let refreshInFlight: Promise | null = null; diff --git a/apps/web/src/core/lib/client-navigation.test.ts b/apps/web/src/core/lib/client-navigation.test.ts new file mode 100644 index 0000000..d8a1176 --- /dev/null +++ b/apps/web/src/core/lib/client-navigation.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/core/lib/client-navigation.ts b/apps/web/src/core/lib/client-navigation.ts new file mode 100644 index 0000000..e4f05fe --- /dev/null +++ b/apps/web/src/core/lib/client-navigation.ts @@ -0,0 +1,45 @@ +type ClientNavigate = (to: string, options: { replace: boolean }) => void | Promise; + +interface SessionLifecycle { + onStart?: () => void | Promise; + 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); + }); +} diff --git a/apps/web/src/core/lib/first-product-path.test.ts b/apps/web/src/core/lib/first-product-path.test.ts new file mode 100644 index 0000000..121ec8c --- /dev/null +++ b/apps/web/src/core/lib/first-product-path.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/core/lib/first-product-path.ts b/apps/web/src/core/lib/first-product-path.ts new file mode 100644 index 0000000..8fe195e --- /dev/null +++ b/apps/web/src/core/lib/first-product-path.ts @@ -0,0 +1,20 @@ +export function firstProductPath( + 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; +} diff --git a/apps/web/src/core/lib/safe-internal-path.test.ts b/apps/web/src/core/lib/safe-internal-path.test.ts new file mode 100644 index 0000000..6048d38 --- /dev/null +++ b/apps/web/src/core/lib/safe-internal-path.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/core/lib/safe-internal-path.ts b/apps/web/src/core/lib/safe-internal-path.ts new file mode 100644 index 0000000..c8fca0f --- /dev/null +++ b/apps/web/src/core/lib/safe-internal-path.ts @@ -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; +}