Routing and navigation

Configuration

All routing is configured once, in src/app/app-routes.module.ts:

RouterModule.forRoot(generalAppRoutes, {
  enableTracing: false,
  scrollPositionRestoration: 'enabled',
  useHash: true,
  relativeLinkResolution: 'legacy',
})
Option Consequence

useHash: true

URLs are https://host//suppliers/list. Anything that manipulates URLs by hand must account for the — see deep links and redirects.

scrollPositionRestoration: 'enabled'

The router restores scroll on back/forward. AppComponent also resets document.body.scrollTop on NavigationEnd.

relativeLinkResolution: 'legacy'

Angular 12 compatibility flag; relative routerLink resolution follows the pre-v11 rules. Changing it would silently alter many relative links.

enableTracing: false

Router tracing off; use @ngrx/router-store actions in DevTools instead.

Route aggregation

Each feature module exports a plain Routes array from a *-routing.routes.ts file. The root module imports them all and nests them under the layout shell:

export const generalAppRoutes: Routes = [
  {
    path: '',
    component: MainLayoutComponent,
    children: [
      ...welcomeRoutes, ...adminRoutes, ...tasksRoutes, ...emailRoutes,
      ...manageRoutes, ...marketplacesRoutes, ...processesRoutes,
      ...reportsRoutes, ...supplierRoutes, ...UwRoutes,
      redirectToWelcomeRoute,           // '' → /welcome
    ],
  },
  redirectToWelcomeRoute,
  { path: '**', component: PageNotFoundComponent },
];

Two modules opt out of the aggregate and declare their own child routers:

  • auth/auth-routing.module.ts — /login, /logout. These render outside MainLayoutComponent, which is why the login page has no sidebar.

  • shared/shared-routing.module.ts — /reset-password.

Adding a top-level area means: create <module>/<module>-routing.routes.ts exporting routes, then spread it into generalAppRoutes. Do not add a second forRoot.

Route admission

Guard order

The order guards are listed in canActivate is the order they run, and it is consistent across every feature route table:

canActivate: [RouteAccessResolver, FeatureFlagsGeneralGuard, NgxPermissionsGuard]
Guard What it does

RouteAccessResolver

Despite the name it is a CanActivate guard, not a resolver. Selects selectLoggedUserModel; rejects when there is no user, when the user has no loginId, or when the JWT’s token claim is in the past. On rejection it stores the current URL in sessionStorage under loginRedirectUrl, dispatches Logout and navigates to /login. Implementation: src/app/shared/utils/route-access-resolver.ts.

FeatureFlagsGeneralGuard

Waits for the auth slice to carry a user, then runs FeatureFlagsService.initFF() — reconciling build flags, global-settings flags and session overrides. Always resolves true (errors are swallowed with catchError) ⇒ of(true) so a flag-service outage cannot lock users out. In non-production it prints the resolved flag table to the console once. Implementation: src/app/guards/feature-flags-general.guard.ts.

NgxPermissionsGuard

Evaluates data.permissions — { only: … } or { except: [ … ] }, optionally with redirectTo.

Two additional guards appear on specific routes:

Guard Used by

FeatureFlagGuard

Routes whose data.enabledByFeatureFlag names a flag. Implements both CanActivate and CanLoad; navigates to /404 when the flag is off. Used on suppliers/:key/new-underwriting and suppliers/:key/underwriting/documentation.

SubPermissionsGuard

Routes whose data.requireOneOfSubPermissions lists sub-permissions. Checks each with UsersService.checkPermission and passes if any holds; otherwise toasts "Access denied." and navigates to /. Used on the Amazon-lending add-loan and edit-loan routes (MP_LOANS_MODIFY, MP_LOANS_DELETE).

AuthGuard

Only on /welcome. A simpler check than RouteAccessResolver — it just redirects to /login when isLoggedIn is false.

FeatureFlagGuard is listed in both canLoad and canActivate on those routes. canLoad only fires for lazily-loaded children; because nothing is lazily loaded here, only canActivate has an effect. Harmless, but do not read it as evidence of lazy loading.

Permission clause conventions

Most feature areas use the same deny clause:

data: { permissions: { except: [USER_PERMISSION.REST_UW_ACCESS,
                                USER_PERMISSION.ADVANCE_AUDITORS] } }

The suppliers module formalises the variants as reusable spreads in suppliers/suppliers-routing.routes.ts:

Constant Denies

RESTRICTED_UW_ACCESS_CONFIG

REST_UW_ACCESS and ADVANCE_AUDITORS (the common case).

RESTRICTED_AUDITORS_ACCESS_CONFIG

REST_UW_ACCESS only — auditors are allowed (used on general).

RESTRICTED_REST_UW_ACCESS_ONLY_CONFIG

ADVANCE_AUDITORS only — restricted UW users are allowed (used on underwriting).

RESTRICTED_UW_ACCESS_CONFIG_MARKETPLACE_STATEMENTS

As RESTRICTED_UW_ACCESS_CONFIG, plus report metadata in data (reportName: 'mp_statements', reportConfigName: 'v_mp_statements', supplierKeyInQueryParamsNotNeeded: true).

These are spread after the route’s own properties, so a route that spreads one of them and also declares its own data will have the spread win. Watch for that when adding route data to a restricted route.

Only two routes use an allow clause: /admin (only: DEV) and suppliers/:key/underwriting/background-reports (only: FINANCE).

The full table is in the route catalogue; the mechanics are in authorisation.

Route data as configuration

Route data is used as a lightweight configuration channel, not only for permissions:

Key Meaning

permissions

ngx-permissions clause.

enabledByFeatureFlag

Flag name checked by FeatureFlagGuard.

requireOneOfSubPermissions

Sub-permission list checked by SubPermissionsGuard.

reportId

Which external report an UnderwritingReportTabComponent instance renders — ExternalReports.PLAID, BANK_CATEGORIES, ACCESS_ONBOARDING_PREDICTIONS, ACCESS_DAILY_PREDICTIONS. One component, four routes.

reportName, reportConfigName

Which internal report/view a report component renders.

supplierKeyInQueryParamsNotNeeded

Suppresses the supplier-key query-parameter requirement for report tables.

isNew

Distinguishes additional-business-owners/new from the list route using the same component.

This "one component, many routes, differentiated by data`" pattern is used deliberately for report tabs and add/edit pairs. When you see two routes pointing at the same component, check `data before assuming duplication.

Resolvers

Only one resolver is in live use:

SupplierKeyResolver (src/app/shared/resolvers/supplier-key.resolver.ts)

Reads supplierKey from route.parent.params, falling back to route.parent.parent.params, and dispatches SetSupplierKey rather than returning fetched data. It is attached to the suppliers/:supplierKey route so that the store points at the right supplier before any child component renders. SupplierKeyMockResolver is its test double.

Domain data loading is not done in resolvers — it is done in SupplierEffects.navigateToSupplier, which reacts to ROUTER_NAVIGATION. See router-driven state.

Because useHash is on, URL handling in code has to strip the fragment. RouteAccessResolver.handleLogout() does this:

const backUrl = window.location.href
  .replace(window.location.origin, '')
  .replace(/^\/#\/(.*)$/, '/$1');
if (this.loginPageRoute !== backUrl) {
  sessionStorage.setItem(LOGIN_REDIRECT_URL, backUrl);
}

After a successful login, AuthEffects.login reads sessionStorage['loginRedirectUrl'] (defaulting to /) and navigates inside ngZone.run(…​) — required because the Google Identity callback resolves outside Angular’s zone.

Note that loginRedirectUrl is not cleared after use; a later logout overwrites it. See authentication.

Redirect conventions

Every area follows the same shape:

{ path: '', redirectTo: 'list', pathMatch: 'full' },
{ path: 'list',            component: XListComponent },
{ path: 'add' | 'new',     component: XAddComponent },
{ path: ':key/edit',       component: XEditComponent },

Deviations worth knowing:

  • suppliers redirects '' to '../' — i.e. out of the suppliers area entirely.

  • admin uses a ** child redirect to /admin/feature-flags instead of a '' redirect.

  • tasks and emails redirect to a parameterised default: list/0/filter and list respectively.

  • processes has an ordering hazard: :processKey/:paymentKey and :supKey are both declared before approval, so /processes/approval matches :supKey first. This is why the sidebar links to /proceses/approval (the second, misspelled route table) for Balance Approval. Do not reorder without checking both.

The sidebar (src/app/shared/sidenav/) is a hand-maintained mirror of the route table, not generated from it. Menu items are wrapped in *ngxPermissionsExcept / *ngxPermissionsOnly and, where relevant, in *appEnabledByFeatureFlag.

Adding a route does not add a menu entry, and the permission clause on the route and on the menu item are maintained separately. When you add or restrict a route, update sidenav-menu.component.html to match — a mismatch shows users a link that immediately bounces them.

Layout reacts to the URL through the layout slice, which matches urlAfterRedirects against regexes for supplierList, supplierGeneral and supplierDefault to decide whether the CRM panel and supplier-info panel are hidden, closed, open or fixed. See UI composition.