11 KiB
Notification Deep-Links
Reference for handling external deep-link URLs sent in customer notification emails (e.g. "Manage your subscription", "Update payment method").
Table of Contents
- Overview
- Registered Routes
- How It Works — Full Flow
- Key Files
- Adding a New Notification URL
- Design Decisions
1. Overview
Notification emails link customers to top-level URLs such as /#/manage-subscription.
These URLs must:
- Not require authentication themselves — the customer may be logged out
- Skip the shell layout — they live outside
AppMainComponent - Redirect instantly — no blank-page flash, no component rendered
- Show a contextual notice on the login screen when authentication is required
All of this is handled by a single NotificationRedirectGuard combined with route data.
Adding a new notification URL requires one route entry and zero new files.
2. Registered Routes
All notification routes are declared at the top level in app-routing.module.ts,
outside the AppMainComponent shell:
/#/manage-subscription → /profile/myservices (or /profile/services if no subs)
/#/update-pm → /profile/payment-method-list
/#/update-bill-address → /profile/billing-address
Each route in source:
// app-routing.module.ts
{
path: 'manage-subscription',
component: PageNotFoundComponent, // never rendered — guard always redirects
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'myservices'],
redirectToNoSubs: ['profile', 'services'],
loginNotice: $localize`@@manageSubLoginNotice:Please log in with your Master account to manage subscriptions.`
}
},
{
path: 'update-pm',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'payment-method-list'],
loginNotice: $localize`@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
}
},
{
path: 'update-bill-address',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'billing-address'],
loginNotice: $localize`@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
}
},
PageNotFoundComponentis the placeholder — it is already declared inAppModuleand is never displayed because the guard always returns aUrlTreebefore any component activates.
3. How It Works — Full Flow
Authenticated user
User clicks link in email
│
▼
Browser opens /#/manage-subscription
│
▼
Angular router matches route
│
▼
NotificationRedirectGuard.canActivate()
│
├─ authSvc.loggedIn = true
│
├─[redirectToNoSubs defined AND master AND no subs]
│ └──→ UrlTree: /profile/services
│
└─[all other authenticated cases]
└──→ UrlTree: /profile/myservices
│
▼
AppMainComponent loads normally
/profile/myservices renders
The guard returns a UrlTree synchronously (auth state is rehydrated from
sessionStorage before any guard runs). Angular processes the redirect before
deactivating the current route or activating any component — no blank page, no
shell teardown.
Unauthenticated user
User clicks link in email (not logged in)
│
▼
Browser opens /#/manage-subscription
│
▼
NotificationRedirectGuard.canActivate()
│
├─ authSvc.loggedIn = false
│
└──→ UrlTree: /login?returnUrl=manage-subscription
&loginNotice=<i18n message>
│
▼
LoginComponent constructor reads queryParams
nav.extractedUrl.queryParams['loginNotice']
│
▼
Pushes { severity: 'info', detail: loginNotice }
into this.msgs → rendered by <p-messages>
│
▼
┌──────────────────────────────────────────┐
│ [AgMission logo] │
│ │
│ ℹ Please log in with your Master │
│ account to manage subscriptions. │
│ │
│ Username ________________________ │
│ Password ________________________ │
│ [ LOGIN ] │
└──────────────────────────────────────────┘
│
User logs in
│
▼
authActions.LoginSuccess dispatched
│
▼
AuthEffects.navigateDefault()
router.parseUrl(router.url)
.queryParams['returnUrl']
→ 'manage-subscription'
│
▼
window.location.replace('/#/manage-subscription')
│
▼
NotificationRedirectGuard runs again
(now authenticated)
│
▼
Redirects to /profile/myservices
4. Key Files
src/app/domain/guards/notification-redirect.guard.ts
The single guard that handles all notification deep-links.
Route data contract:
| Field | Type | Required | Description |
|---|---|---|---|
redirectTo |
string[] |
Yes | Router path segments for authenticated users |
redirectToNoSubs |
string[] |
No | Alternate path when master account has no subscriptions |
loginNotice |
string |
No | Message shown in the <p-messages> bar on the login screen |
canActivate(route: ActivatedRouteSnapshot): UrlTree {
const { redirectTo, redirectToNoSubs } = route.data;
if (!this.authSvc.loggedIn) {
const { loginNotice } = route.data;
return this.router.createUrlTree(['/login'], {
queryParams: {
returnUrl: route.url.map(s => s.path).join('/'),
...(loginNotice ? { loginNotice } : {})
}
});
}
const isMaster = !this.authSvc.user?.parent;
if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) {
return this.router.createUrlTree(redirectToNoSubs);
}
return this.router.createUrlTree(redirectTo);
}
src/app/auth/effects/auth.effects.ts — navigateDefault()
After a successful login, reads returnUrl from the current router URL and
replaces the browser location to trigger the notification route again
(now authenticated):
private navigateDefault(lang) {
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
window.location.replace((lang === 'en' ? hash : `/${lang}${hash}`) + returnUrl);
}
Uses router.parseUrl() instead of string splitting — correctly handles all
URL encodings.
If no returnUrl is present (normal login), falls back to 'home' as before.
src/app/auth/login/login.component.ts — constructor
Generic loginNotice handling — no hardcoded route names:
const nav = this.router.getCurrentNavigation();
if (nav) {
const msgs: any[] = [];
if (nav.extras?.state?.changedPwd) {
msgs.push({ severity: 'info', summary: '', detail: globals.pwdChangedOk });
}
const loginNotice =
nav.finalUrl?.queryParams?.['loginNotice'] ??
nav.extractedUrl?.queryParams?.['loginNotice'];
if (loginNotice) {
msgs.push({ severity: 'info', summary: '', detail: loginNotice });
}
if (msgs.length) this.msgs = msgs;
}
Reads from getCurrentNavigation() — the only safe place to read query params
on a login redirect since the router replaces the URL before ngOnInit runs.
5. Adding a New Notification URL
Zero new files required. Add one entry to app-routing.module.ts:
{
path: 'renew-subscription', // URL path: /#/renew-subscription
component: PageNotFoundComponent, // never rendered
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'checkout'], // authenticated destination
// redirectToNoSubs: ['profile', 'services'], // optional alternate
loginNotice: $localize`:@@renewSubLoginNotice:Please log in with your Master account to renew your subscription.`
}
},
That's it. The guard, the login notice, and the post-login redirect all work automatically.
Checklist:
- Add route entry with
redirectTo(required) and optionallyredirectToNoSubs/loginNotice - Add the
loginNoticei18n key to all locale.xlftranslation files if the app is translated - Provide the deep-link URL to the notifications/email team:
https://app.agmission.com/#/renew-subscription
6. Design Decisions
Why a guard returning UrlTree instead of a redirect component?
A component that calls router.navigate() in ngOnInit causes:
- The current shell (
AppMainComponent) to deactivate and re-activate — visible flicker - A blank template to render briefly while
ngOnInitexecutes
A guard returning a UrlTree is processed by Angular before any component is
activated or deactivated. The redirect is invisible and instantaneous.
Why route data instead of a guard per URL?
One guard file per URL creates N files for N routes with identical logic.
Putting the routing targets in data makes the guard a pure engine
and the route definition the configuration — consistent with Angular's
canActivate+data idiom used throughout the app (e.g. RoleGuard + data.roles).
Why PageNotFoundComponent as the placeholder?
Angular requires a component on every non-lazy route. The component is never
rendered (the guard always redirects), so any already-declared component works.
PageNotFoundComponent is the most semantically appropriate fallback if the guard
ever fails to redirect for an unexpected reason.
Why window.location.replace() instead of router.navigate() in navigateDefault?
This was the pre-existing pattern to prevent the login page from appearing in the
browser's Back history after authentication. location.replace replaces the
current history entry rather than pushing a new one.
Sub-accounts vs. Master accounts
Subscription management (/profile/myservices) is only meaningful for master
accounts, but sub-accounts (users with user.parent set) are redirected there
too — the manage-subscription view enforces its own access rules once loaded.
redirectToNoSubs is only evaluated for master accounts with zero subscriptions,
sending them to the /profile/services plan picker instead.