@open-rlb/ng-app
Shell applicativa multi-app per Angular: un core sottile ospita una o più "app" (micro-frontend) e la libreria si occupa di store, router, autenticazione OIDC, ACL, i18n, HTTP, service worker, layout e pagine standard.
1Panoramica
Un progetto basato su @open-rlb/ng-app ha due livelli:
- Il core: l'applicazione Angular che si avvia. Contiene solo la configurazione
(
environment.ts), i provider e il componente radice con<rlb-app-container>. - Le app: unità funzionali descritte da un
AppDescriber(identità, rotte, provider) e registrate nel core conprovideApp(). Ogni app può essere limitata a certi domini e a certe azioni ACL, e può esistere in più istanze (per esempio una per azienda o risorsa).
Tutto il resto (store, router, auth, ACL, traduzioni, gestione errori, layout) è fornito da una sola
chiamata: provideRlbConfig(environment).
2Avvio rapido
Si parte da un workspace Angular senza applicazioni; ng add crea l'app core.
ng new my-workspace --no-create-application
cd my-workspace
ng add @open-rlb/ng-app
npm start
Cosa fa ng add
- Installa le dipendenze della libreria, più
@angular/builde@angular/cdkalla versione Angular del workspace. - Registra il progetto
coreinangular.jsonsottonewProjectRoot, con le configurazioniproduction,stagingedevelopment, gli stili Bootstrap/ng-bootstrap, gli asset serviti dasrc/assetse un budget iniziale di 4 MB. - Crea solo i file necessari all'avvio.
- Imposta gli script di
package.jsone unpostinstallche sincronizza le skill Claude.
projects/core/
tsconfig.app.json
src/index.html
src/main.ts
src/assets/favicon.ico
src/assets/i18n/en.json, it.json
src/environments/environment.ts
src/app/app.config.ts provideRlbConfig + RLB_INIT_PROVIDER
src/app/app-init.provider.ts assegna un id a ogni app registrata
src/app/app.component.ts <rlb-app-container> + configurazione del chrome
src/app/app.routes.ts rotte del core (vuote)
| Script | Comando |
|---|---|
start | ng serve core |
build | ng build core --configuration production |
build:dev | ng build core --configuration development |
build:staging | ng build core --configuration staging |
watch | ng build core --watch --configuration development |
postinstall | ng g @open-rlb/ng-app:sync-skills || echo Skipped Claude skill sync |
Opzioni: --name <nome> (default core), --skip-skills,
--skip-skills-auto-sync. Se il progetto esiste già, lo schematic si ferma con un errore.
Il core minimo
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRlbConfig(environment),
provideRouter(routes),
{ provide: RLB_INIT_PROVIDER, useClass: AppInitProvider },
// provideApp(myAppDescriber), una riga per ogni app
],
};
<!-- template del componente radice -->
<rlb-app-container modal-container-id="modal-c-1" toast-container-ids="toast-c-1" />
ng add
Sostituire in environment.ts i segnaposto OIDC (authority, clientId,
redirectUrl, postLogoutRedirectUri), gli allowedUrls e i
baseUrl degli endpoint.
3Macro-parti
Bootstrap e config
provideRlbConfig, provideApp, token RLB_CFG_*.
Orchestrazione app
AppsService, AppInfo, AppDescriber, provideForDomains, RLB_INIT_PROVIDER.
Store
NgRx: auth, app, navbar, sidebar; signal store AclStore.
Auth
OIDC, interceptor, watchdog del token, guard, direttiva *roles, API account Keycloak.
Shell e layout
rlb-app-container, navbar, sidebar, menu mobile, template base/content/cms.
Pagine standard
Settings, hub delle app, profilo, privacy, termini, cookie, supporto, 404, forbidden.
Servizi accessori
Errori, i18n, cache, CMS Strapi, storage, cookie, PWA, utility.
Pipe, modali, toast
cms, truncate, autolink, modale errori, modale app, toast.
Schematics
ng-add (crea il core) e sync-skills.
4Configurazione
Tutta la configurazione sta in un unico oggetto ProjectConfiguration<T>
(IConfiguration & { production } & T): T permette di aggiungere campi
propri del progetto. Ogni sezione si può iniettare con il suo token.
| Sezione | Campi principali | Token |
|---|---|---|
environment | baseUrl, logLevel, pwaUpdateEnabled, errorDialogName, errorDialogSize, errorToastName, errorToastContainer, httpErrors | RLB_CFG_ENV |
auth | protocol, storage, interceptor, allowedUrls, enableCompanyInterceptor, providers[], renewal | RLB_CFG_AUTH |
i18n | availableLangs, defaultLanguage | RLB_CFG_I18N |
pages | { [chiave]: { path, action? } } | RLB_CFG_PAGES |
endpoints | { [nome]: { baseUrl, healthPath, auth?, wss? } } | via RLB_CFG |
acl | businessIdKey, resourceIdKey, interceptorMapping | RLB_CFG_ACL |
cms | endpoint, chacheDuration, useAppLanguage, markdown | RLB_CFG_CMS |
Messaggi di errore HTTP
ErrorManagementService cerca il messaggio nelle traduzioni in quest'ordine:
errors.http.<status> → errors.http.default → testo del backend
("<status>: <messaggio>"). Ogni gruppo ha title, message e un
messageRetry opzionale che può usare {{minutes}}, {{seconds}} e {{status}}.
environment.httpErrors | Default | Significato |
|---|---|---|
keyPrefix | errors.http | Namespace delle traduzioni. |
defaultRetryMinutes | — | Attesa da mostrare se la risposta non ha un Retry-After leggibile. Un gateway su un'altra origine deve esporlo in Access-Control-Expose-Headers. |
dedupeMs | 1000 | Lo stesso messaggio viene mostrato una volta sola in questa finestra; 0 disattiva. |
5App e micro-frontend
Ogni app si descrive con un AppDescriber e si registra con provideApp():
export const chatDescriber: AppDescriber = {
info: {
type: 'chat',
enabled: true,
actions: ['chat-read', 'chat-admin'],
core: { title: 'Chat', description: '…', url: 'chat', icon: 'bi bi-chat', auth: true },
settings: { title: 'Chat settings', description: '…', url: 'settings/chat', icon: 'bi bi-gear', auth: true },
},
routes,
providers: [],
};
Campo di AppInfo | Effetto |
|---|---|
type | Chiave dell'app; finalizeApp la usa per creare le istanze. |
id | Se presente l'app è già "finalizzata". Senza id la shell non può selezionarla. |
enabled | Se l'app partecipa. |
core / settings | { title, description, url, icon, auth }: landing dell'app e sua pagina impostazioni. auth: false rende le impostazioni visibili anche agli anonimi. |
viewMode | 'app' o 'settings'. |
routes | Riempito da provideApp con i path appiattiti delle rotte; serve a riconoscere l'app dall'URL. |
domains | Hostname su cui l'app è visibile e selezionabile. |
actions | Azioni ACL: l'app è nascosta a chi non ne ha almeno una sulla sua risorsa. |
autoRedirectOnRootEnabled | Con più app, la root reindirizza a questa. |
data | Dati liberi; contiene i valori di businessIdKey e resourceIdKey usati dall'ACL. |
Istanze multiple
Un type può produrre più istanze: ogni AppContextActions.finalizeApp({ appType, appId, data })
con lo stesso tipo e un appId diverso clona l'app. È il meccanismo multi-tenant (un'istanza per
azienda o risorsa ACL).
info.domains decide cosa la shell mostra, non cosa registra: providers e
routes finiscono nell'injector radice ovunque. Un interceptor o un initializer che deve
valere solo per alcuni domini va avvolto così:
providers: [
...provideForDomains(PARTNER_DOMAINS, [
{ provide: HTTP_INTERCEPTORS, useClass: PartnerInterceptor, multi: true },
]),
]
Leggere le app da un componente
private readonly apps = inject(AppsService);
visibleApps = this.apps.apps; // AppInfo[] filtrate per dominio e ACL
current = this.apps.currentApp; // AppInfo | null
currentId = this.apps.currentAppId; // string | undefined
this.apps.checkPermissionInCurrentApp('chat-admin');
this.apps.selectApp(app, 'app', '/chat');
6Avvio del core
provideRlbConfig registra store, effects, rotte di default, OIDC, i18n, HttpClient, service
worker, registri di modali e toast, e tre app initializer. L'ordine di avvio è questo:
- i18n: attende il caricamento delle traduzioni (
LanguageService.ready$). AppsServiceviene costruito subito:- sceglie il provider OIDC: quello già nello store, altrimenti l'unico configurato, altrimenti quello
con l'hostname corrente in
domains. Nessun match o due match: errore in dev, log di errore in produzione; - si mette in ascolto di
NavigationEnd.
- sceglie il provider OIDC: quello già nello store, altrimenti l'unico configurato, altrimenti quello
con l'hostname corrente in
AuthenticationService.checkAuthMultiple():- verifica la sessione OIDC; con
oauth-code-ep-retryprova prima un refresh dal refresh token salvato; - utente autenticato: imposta il provider, carica l'ACL da
endpoints[acl.endpointKey].baseUrl/acl.path, chiamaRLB_INIT_PROVIDER.finalizeApps(resources, store, acl)e ripristina l'URL salvato prima del login; - ospite:
finalizeApps([]); - emette
authReady$e, se autenticato,authenticated$(che avvia il watchdog del token).
- verifica la sessione OIDC; con
Nel frattempo AppContextEffects aggiunge allo store ogni app registrata con provideApp.
Selezione dell'app corrente
A ogni navigazione AppsService aspetta che tutte le app abbiano un id, poi:
- Root con una sola app: redirect a
core.url. - Root con più app: hub delle app (
currentApp = null), a meno che un'app abbiaautoRedirectOnRootEnabled. - Deep link: seleziona l'app i cui
routesocore.urlcorrispondono al path.
L'app scelta viene salvata in localStorage (c-app-id) e il container naviga al suo URL.
id la selezione non avviene mai
Se un'app non ha id (né in info.id né assegnato da finalizeApp),
currentApp resta vuota. Il app-init.provider.ts generato da ng add
assegna a ogni app il suo type come id; per creare un'istanza per risorsa ACL va adattato.
7Shell: navbar e sidebar
Il template della shell si sceglie per rotta di primo livello con data: { template }:
'app' (default) mostra navbar e sidebar, 'basic' solo il contenuto.
Navbar e sidebar si configurano con azioni NgRx, di solito nel componente radice:
this.store.dispatch(NavbarActions.setActionsLayout({ layout: 'dropdown' }));
this.store.dispatch(NavbarActions.setSettingsVisible({ visible: true }));
this.store.dispatch(SidebarActions.setItems({ items: [
{ title: 'Menu' },
{ label: 'Home', url: '/', icon: 'bi bi-house' },
{ label: 'Admin', url: '/admin', icon: 'bi bi-shield', action: 'sysadmin' },
]}));
NavbarActions | Effetto | Default |
|---|---|---|
setVisible | Mostra la navbar. | true |
setHeader | Brand: { type: 'text', text } o { type: 'image', src, alt?, height? }. | null |
setSearchVisible / setSearchText | Campo di ricerca; il testo va nello store. | false |
setLoginVisible | Pulsante di login / profilo. | false |
setSettingsVisible | Impostazioni e account. | false |
setAppsVisible | Launcher delle app (con più di un'app). | false |
setActionsLayout | 'default' (icone) o 'dropdown' (card). | 'dropdown' |
setSeparatorVisible | Separatore prima delle azioni. | true |
setLeftItems / setRightItems / setMobileItems | Attivano i componenti custom per nome. | [] |
SidebarActions | Effetto | Default |
|---|---|---|
setVisible | Mostra la sidebar. | true |
setItems | Voci fino a tre livelli: { title } come intestazione, oppure { label, url | externalUrl, icon, badgeCounter, action, items }. | [] |
setFooterComponent | Componente nel footer, per nome. | null |
La proprietà action delle voci passa per la direttiva *roles: le voci sono filtrate per ACL.
Componenti custom
// nei providers del describer
{ provide: RLB_APP_NAVCOMP, useValue: {
left: [{ name: 'my-item', component: MyItemComponent }],
right: [{ name: 'my-item', component: MyItemComponent }],
mobile: [{ name: 'my-menu', component: MyMenuComponent }], // opzionale
} },
{ provide: RLB_APP_SIDEBARCOMP, useValue: {
footer: [{ name: 'my-footer', component: MyFooterComponent }],
} },
// poi si attivano
this.store.dispatch(NavbarActions.setRightItems({ items: ['my-item'] }));
this.store.dispatch(SidebarActions.setFooterComponent({ name: 'my-footer' }));
Sotto il breakpoint lg i componenti left e right non vengono mostrati: al loro
posto c'è un menu laterale con i mobileItems (che usano right se mobile manca),
la griglia delle app e le impostazioni. Un componente può sapere dove si trova con
inject(RLB_NAV_SURFACE, { optional: true }), che vale 'navbar' o 'mobile-menu'.
Tema, lingua, template di pagina
AppContextActions.setTheme('light' | 'dark')impostadata-bs-themee lo salva.AppContextActions.setLanguageesetSupportedLanguagesagiscono su ngx-translate. Entrambi si cambiano anche da/settings/general.- Template per le pagine:
<rlb-base-template title subtitle breadcrumb>,<rlb-content-template>,<rlb-cms-template contentId>. AppBreadcrumbService.breadcrumbs()costruisce il breadcrumb dadata.breadcrumbdelle rotte.
8Autenticazione
L'autenticazione usa OIDC con authorization code flow e refresh token, tramite angular-auth-oidc-client.
auth: {
protocol: 'oauth',
storage: 'localStorage', // 'cookies' | 'localStorage' | 'sessionStorage'
interceptor: 'oauth-code-ep-retry',
renewal: { publicPaths: ['/register'] },
enableCompanyInterceptor: true,
allowedUrls: ['https://api.example.com'],
providers: [{
configId: 'default',
domains: ['app.example.com'], // un provider per portale, scelto per hostname
authority: 'https://login.example.com/realms/my-realm',
clientId: 'my-client',
redirectUrl: 'https://app.example.com',
postLogoutRedirectUri: 'https://app.example.com',
scope: 'openid profile offline_access',
acl: { endpointKey: 'api', path: 'acl/resources' },
}],
}
Interceptor
interceptor | Comportamento |
|---|---|
'oauth-code-all' | Interceptor della libreria OIDC, sugli allowedUrls. |
'oauth-code-ep' | Aggiunge il bearer agli endpoints con auth: true (non wss). Senza token la richiesta parte comunque, anonima. |
'oauth-code-ep-retry' | Come sopra, ma non invia mai una richiesta autenticata senza token (salvo publicPaths), ripete una volta dopo un 401 e rinnova il token con un watchdog proprio. Spegne il silentRenew della libreria OIDC. |
'none' | Nessun interceptor. |
Con enableCompanyInterceptor le richieste verso gli allowedUrls ricevono come query
param i valori di currentApp.data indicati in acl.interceptorMapping.
Rinnovo del token (oauth-code-ep-retry)
renewal | Default | Significato |
|---|---|---|
renewLeadSeconds | 90 | Anticipo del rinnovo rispetto alla scadenza. |
retryDelaysSeconds | [5, 20, 60] | Attese tra i primi tentativi falliti. |
transientRetrySeconds | 120 | Cadenza dei tentativi successivi. |
maxOutageSeconds | 1800 | Dopo questo tempo il watchdog si ferma; il prossimo 401 riparte da capo. |
autoStart | true | Avvia il watchdog appena l'utente è autenticato. |
restoreOnBoot | true | All'avvio, se l'access token è scaduto ma c'è un refresh token, rinnova invece di considerare l'utente ospite. |
publicPaths | [] | Parti di URL che gli anonimi possono chiamare su endpoint autenticati. |
Servono tre cose: offline_access nello scope, storage: 'localStorage' e
restoreOnBoot attivo (default con oauth-code-ep-retry). Durante un'interruzione del
server di login oauthGuard non rimanda al login finché il watchdog sta ancora provando.
Login, logout e dati utente
- Login e logout con
AuthActions.login()/AuthActions.logout()oppure conAuthFeatureService. Il login salva l'URL corrente e ci torna dopo il rientro. RLB_AUTH_URL_HANDLERpermette di aprire la pagina di login in modo diverso; in Electron succede in automatico.AuthenticationServiceesponeisAuthenticated$,userInfo$,accessToken$,idToken$,refreshToken$,roles$,authReady$,authenticated$.- Esiste anche un flusso con password grant:
OauthPasswordServiceeoauthPasswordGuard.
9Autorizzazione (ACL)
Dopo il login la libreria scarica le risorse dell'utente:
UserResource[] = [
{ companyId: 'acme', resources: [
{ resourceId: 'chat-1', actions: ['chat-read'], friendlyName: 'Chat Acme' },
] },
]
Un'app è collegata a una risorsa tramite data[acl.businessIdKey] e data[acl.resourceIdKey].
Le istanze si creano in RLB_INIT_PROVIDER:
@Injectable()
export class AppInitProvider implements RlbInitProvider {
async finalizeApps(resources: UserResource[], store: Store<BaseState>, acl: AclConfiguration | null) {
for (const company of resources) {
for (const res of company.resources) {
store.dispatch(AppContextActions.finalizeApp({
appType: 'chat',
appId: `chat-${res.resourceId}`,
data: { title: res.friendlyName, [acl!.businessIdKey]: company.companyId, [acl!.resourceIdKey]: res.resourceId },
}));
}
}
}
}
Dove si applica
| Cosa | Strumento | Ambito |
|---|---|---|
| Un'app intera | AppInfo.actions | la risorsa dell'app |
| Una rotta autenticata | canActivate: [oauthGuard] | sessione |
| Una rotta con permesso | canActivate: [permissionGuard], data: { action } | l'app che possiede la rotta |
Una pagina pages.* | pages.x.action + pagePermissionGuard(action) | qualsiasi risorsa |
| Un elemento della UI | *roles | l'app corrente |
action può essere una stringa o un array: basta averne una. Le guard aspettano che l'ACL sia
caricata prima di decidere; un rifiuto porta sempre a /forbidden.
<button *roles="'sysadmin'">Solo admin</button>
<button *roles="['manager', 'director']">Approva</button>
<span *roles="'editor'; not: true">Sola lettura</span>
<button *roles="'convert'; else noConvert">Converti</button>
<ng-template #noConvert>Non disponibile</ng-template>
Senza azione (undefined, '' o []) l'elemento è pubblico.
10Store NgRx
| Slice | Contenuto | Azioni |
|---|---|---|
auth | Provider OIDC corrente, stato di caricamento | AuthActions.login, logout, setCurrentProvider |
app | App registrate, app corrente, lingua, tema | AppContextActions.setLanguage, setSupportedLanguages, setTheme, setCurrentApp, finalizeApp, removeApp |
navbar | Visibilità, header, voci, layout | NavbarActions.* |
sidebar | Voci, visibilità, footer | SidebarActions.* |
AclStore (signal store) | Risorse ACL dell'utente | hasPermission, hasPermissionAnywhere, resources(), loaded() |
private readonly store = inject(Store<BaseState>);
currentApp = this.store.selectSignal(s => s[appContextFeatureKey].currentApp);
I gruppi *Internal sono usati dagli effects della libreria e non vanno dispacciati dall'app.
11Pagine standard
| Rotta | Contenuto | Registrata |
|---|---|---|
/settings | Elenco impostazioni | sempre, con oauthGuard |
/settings/general | Tema e lingua | sempre, con oauthGuard |
/apps | Hub delle app | sempre, con oauthGuard |
/profile | Account Keycloak: profilo, sessioni, credenziali, OTP, password | sempre, con oauthGuard |
/forbidden | Accesso negato | sempre |
/notFound | Pagina non trovata | con pages.notFound |
/privacy, /terms, /cookies, /content | Contenuti legali e CMS | con la chiave in pages |
/support | Modulo di supporto (serve un AbstractSupportService) | con pages.support |
Le voci status e logger compaiono nelle impostazioni ma le rotte vanno create dal progetto,
con pagePermissionGuard se hanno un'action.
12Servizi aggiuntivi
| Servizio | Cosa fa |
|---|---|
ErrorManagementService | showError, showDialog, showToast e l'operatore RxJS manageUI(type, 'dialog' | 'toast' | 'console'). |
LanguageService | Lingua dell'interfaccia e lingua dei contenuti come signal, translate, translateAsync. |
AppLoggerService | Log con livelli e contesto (for('MyClass')); livello da environment.logLevel. |
AppBreadcrumbService | Breadcrumb calcolato dalle rotte. |
AppsService | App visibili, app corrente, selezione, controlli di permesso. |
KeycloakAccountService | API account Keycloak (profilo, dispositivi, credenziali); in caso di errore le letture restituiscono un valore di ripiego. |
KeycloakProfileService | Stesse API con gestione errori in UI, più configureOTP e updatePassword. |
TokenRenewalService | Watchdog del token: start, refresh, isRecoverable. |
StrapiService, CmsPipe, AbstractMdService | Contenuti da Strapi con cache; il markdown si rende implementando AbstractMdService. |
LocalCacheService | getData(obs$, key, ttlSec) con cache in localStorage. |
PwaUpdaterService | Propone l'aggiornamento quando c'è una nuova versione (con pwaUpdateEnabled). |
AppStorageService, CookiesService | Accesso a local/session storage e cookie. |
UtilsService | groupBy, compareArrayUnsort, toPlainDataObject, floatStr, … |
provideForDomains | Registra provider solo su alcuni hostname. |
13Limiti noti
Punti emersi dall'analisi del codice, da tenere presenti finché non vengono corretti:
LocalCacheServicelegge la cache conJSON.parse(key)invece del valore: la cache non viene mai usata e con chiavi non JSONgetDatagenera un errore (impattaStrapiService).CompanyInterceptorusa come nome del query param il valore diinterceptorMappinginvece della chiave.- Alcuni campi di configurazione non sono letti:
appLogo,appTitle,navbarDisabled,ssr,phone,i18n.useLanguageBrowser,storeSelectedLanguage,cookieStoreName,cms.contentLanguages,ProviderConfiguration.roleClaim(roles$legge sempre il claimroles). pages.*.pathnon cambia il percorso: le rotte sono fisse (per esempio/notFound).- Al primo avvio il container imposta tema
darke linguaen, ignorandoi18n.defaultLanguage. - I flag
loginVisible,searchVisible,settingsVisibleeappsVisibledella sidebar sono nello store ma il template non li usa. - Il template
data.templateaccetta solo'app'e'basic', anche se il tipo prevede anche'cms'e'content'.