@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.

Angular 22 zoneless + signals NgRx angular-auth-oidc-client @open-rlb/ng-bootstrap ngx-translate

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 con provideApp(). 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/build e @angular/cdk alla versione Angular del workspace.
  • Registra il progetto core in angular.json sotto newProjectRoot, con le configurazioni production, staging e development, gli stili Bootstrap/ng-bootstrap, gli asset serviti da src/assets e un budget iniziale di 4 MB.
  • Crea solo i file necessari all'avvio.
  • Imposta gli script di package.json e un postinstall che 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)
ScriptComando
startng serve core
buildng build core --configuration production
build:devng build core --configuration development
build:stagingng build core --configuration staging
watchng build core --watch --configuration development
postinstallng 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" />
Dopo 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

public-api.ts, configuration.ts

provideRlbConfig, provideApp, token RLB_CFG_*.

Orchestrazione app

services/apps/

AppsService, AppInfo, AppDescriber, provideForDomains, RLB_INIT_PROVIDER.

Store

store/

NgRx: auth, app, navbar, sidebar; signal store AclStore.

Auth

auth/

OIDC, interceptor, watchdog del token, guard, direttiva *roles, API account Keycloak.

Shell e layout

templates/

rlb-app-container, navbar, sidebar, menu mobile, template base/content/cms.

Pagine standard

pages/

Settings, hub delle app, profilo, privacy, termini, cookie, supporto, 404, forbidden.

Servizi accessori

services/*

Errori, i18n, cache, CMS Strapi, storage, cookie, PWA, utility.

Pipe, modali, toast

pipes/, modals/, toasts/

cms, truncate, autolink, modale errori, modale app, toast.

Schematics

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.

SezioneCampi principaliToken
environmentbaseUrl, logLevel, pwaUpdateEnabled, errorDialogName, errorDialogSize, errorToastName, errorToastContainer, httpErrorsRLB_CFG_ENV
authprotocol, storage, interceptor, allowedUrls, enableCompanyInterceptor, providers[], renewalRLB_CFG_AUTH
i18navailableLangs, defaultLanguageRLB_CFG_I18N
pages{ [chiave]: { path, action? } }RLB_CFG_PAGES
endpoints{ [nome]: { baseUrl, healthPath, auth?, wss? } }via RLB_CFG
aclbusinessIdKey, resourceIdKey, interceptorMappingRLB_CFG_ACL
cmsendpoint, chacheDuration, useAppLanguage, markdownRLB_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.httpErrorsDefaultSignificato
keyPrefixerrors.httpNamespace 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.
dedupeMs1000Lo 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 AppInfoEffetto
typeChiave dell'app; finalizeApp la usa per creare le istanze.
idSe presente l'app è già "finalizzata". Senza id la shell non può selezionarla.
enabledSe 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'.
routesRiempito da provideApp con i path appiattiti delle rotte; serve a riconoscere l'app dall'URL.
domainsHostname su cui l'app è visibile e selezionabile.
actionsAzioni ACL: l'app è nascosta a chi non ne ha almeno una sulla sua risorsa.
autoRedirectOnRootEnabledCon più app, la root reindirizza a questa.
dataDati 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).

I provider del describer valgono su tutti i domini

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:

  1. i18n: attende il caricamento delle traduzioni (LanguageService.ready$).
  2. AppsService viene 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.
  3. AuthenticationService.checkAuthMultiple():
    • verifica la sessione OIDC; con oauth-code-ep-retry prova prima un refresh dal refresh token salvato;
    • utente autenticato: imposta il provider, carica l'ACL da endpoints[acl.endpointKey].baseUrl/acl.path, chiama RLB_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).

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:

  1. Root con una sola app: redirect a core.url.
  2. Root con più app: hub delle app (currentApp = null), a meno che un'app abbia autoRedirectOnRootEnabled.
  3. Deep link: seleziona l'app i cui routes o core.url corrispondono al path.

L'app scelta viene salvata in localStorage (c-app-id) e il container naviga al suo URL.

Senza 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' },
]}));
NavbarActionsEffettoDefault
setVisibleMostra la navbar.true
setHeaderBrand: { type: 'text', text } o { type: 'image', src, alt?, height? }.null
setSearchVisible / setSearchTextCampo di ricerca; il testo va nello store.false
setLoginVisiblePulsante di login / profilo.false
setSettingsVisibleImpostazioni e account.false
setAppsVisibleLauncher delle app (con più di un'app).false
setActionsLayout'default' (icone) o 'dropdown' (card).'dropdown'
setSeparatorVisibleSeparatore prima delle azioni.true
setLeftItems / setRightItems / setMobileItemsAttivano i componenti custom per nome.[]
SidebarActionsEffettoDefault
setVisibleMostra la sidebar.true
setItemsVoci fino a tre livelli: { title } come intestazione, oppure { label, url | externalUrl, icon, badgeCounter, action, items }.[]
setFooterComponentComponente 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') imposta data-bs-theme e lo salva.
  • AppContextActions.setLanguage e setSupportedLanguages agiscono 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 da data.breadcrumb delle 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

interceptorComportamento
'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)

renewalDefaultSignificato
renewLeadSeconds90Anticipo del rinnovo rispetto alla scadenza.
retryDelaysSeconds[5, 20, 60]Attese tra i primi tentativi falliti.
transientRetrySeconds120Cadenza dei tentativi successivi.
maxOutageSeconds1800Dopo questo tempo il watchdog si ferma; il prossimo 401 riparte da capo.
autoStarttrueAvvia il watchdog appena l'utente è autenticato.
restoreOnBoottrueAll'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.
Restare connessi dopo la chiusura del browser

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 con AuthFeatureService. Il login salva l'URL corrente e ci torna dopo il rientro.
  • RLB_AUTH_URL_HANDLER permette di aprire la pagina di login in modo diverso; in Electron succede in automatico.
  • AuthenticationService espone isAuthenticated$, userInfo$, accessToken$, idToken$, refreshToken$, roles$, authReady$, authenticated$.
  • Esiste anche un flusso con password grant: OauthPasswordService e oauthPasswordGuard.

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

CosaStrumentoAmbito
Un'app interaAppInfo.actionsla risorsa dell'app
Una rotta autenticatacanActivate: [oauthGuard]sessione
Una rotta con permessocanActivate: [permissionGuard], data: { action }l'app che possiede la rotta
Una pagina pages.*pages.x.action + pagePermissionGuard(action)qualsiasi risorsa
Un elemento della UI*rolesl'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

SliceContenutoAzioni
authProvider OIDC corrente, stato di caricamentoAuthActions.login, logout, setCurrentProvider
appApp registrate, app corrente, lingua, temaAppContextActions.setLanguage, setSupportedLanguages, setTheme, setCurrentApp, finalizeApp, removeApp
navbarVisibilità, header, voci, layoutNavbarActions.*
sidebarVoci, visibilità, footerSidebarActions.*
AclStore (signal store)Risorse ACL dell'utentehasPermission, 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

RottaContenutoRegistrata
/settingsElenco impostazionisempre, con oauthGuard
/settings/generalTema e linguasempre, con oauthGuard
/appsHub delle appsempre, con oauthGuard
/profileAccount Keycloak: profilo, sessioni, credenziali, OTP, passwordsempre, con oauthGuard
/forbiddenAccesso negatosempre
/notFoundPagina non trovatacon pages.notFound
/privacy, /terms, /cookies, /contentContenuti legali e CMScon la chiave in pages
/supportModulo 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

ServizioCosa fa
ErrorManagementServiceshowError, showDialog, showToast e l'operatore RxJS manageUI(type, 'dialog' | 'toast' | 'console').
LanguageServiceLingua dell'interfaccia e lingua dei contenuti come signal, translate, translateAsync.
AppLoggerServiceLog con livelli e contesto (for('MyClass')); livello da environment.logLevel.
AppBreadcrumbServiceBreadcrumb calcolato dalle rotte.
AppsServiceApp visibili, app corrente, selezione, controlli di permesso.
KeycloakAccountServiceAPI account Keycloak (profilo, dispositivi, credenziali); in caso di errore le letture restituiscono un valore di ripiego.
KeycloakProfileServiceStesse API con gestione errori in UI, più configureOTP e updatePassword.
TokenRenewalServiceWatchdog del token: start, refresh, isRecoverable.
StrapiService, CmsPipe, AbstractMdServiceContenuti da Strapi con cache; il markdown si rende implementando AbstractMdService.
LocalCacheServicegetData(obs$, key, ttlSec) con cache in localStorage.
PwaUpdaterServicePropone l'aggiornamento quando c'è una nuova versione (con pwaUpdateEnabled).
AppStorageService, CookiesServiceAccesso a local/session storage e cookie.
UtilsServicegroupBy, compareArrayUnsort, toPlainDataObject, floatStr, …
provideForDomainsRegistra provider solo su alcuni hostname.

13Limiti noti

Punti emersi dall'analisi del codice, da tenere presenti finché non vengono corretti:

  • LocalCacheService legge la cache con JSON.parse(key) invece del valore: la cache non viene mai usata e con chiavi non JSON getData genera un errore (impatta StrapiService).
  • CompanyInterceptor usa come nome del query param il valore di interceptorMapping invece 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 claim roles).
  • pages.*.path non cambia il percorso: le rotte sono fisse (per esempio /notFound).
  • Al primo avvio il container imposta tema dark e lingua en, ignorando i18n.defaultLanguage.
  • I flag loginVisible, searchVisible, settingsVisible e appsVisible della sidebar sono nello store ma il template non li usa.
  • Il template data.template accetta solo 'app' e 'basic', anche se il tipo prevede anche 'cms' e 'content'.