wuic-framework-lib 1.0.20 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/{wuic-framework-lib-chart-list.component-B44k_wZe.mjs → wuic-framework-lib-chart-list.component-B1V8Q-Qq.mjs} +2 -2
- package/fesm2022/{wuic-framework-lib-code-editor.component-D1dy4Dl9.mjs → wuic-framework-lib-code-editor.component-4N95qt3D.mjs} +26 -5
- package/fesm2022/{wuic-framework-lib-designer.component-CnOQbvEH.mjs → wuic-framework-lib-designer.component-pW1YK-oY.mjs} +4 -4
- package/fesm2022/{wuic-framework-lib-first-run-wizard.component-C9lswoxG.mjs → wuic-framework-lib-first-run-wizard.component-CaGePYLi.mjs} +14 -4
- package/fesm2022/{wuic-framework-lib-framework-docs.component-B6qNTs5H.mjs → wuic-framework-lib-framework-docs.component-m0IlS0Sl.mjs} +406 -10
- package/fesm2022/{wuic-framework-lib-pivot-builder.component-DMk26H0M.mjs → wuic-framework-lib-pivot-builder.component-VIs0CIH_.mjs} +19 -16
- package/fesm2022/{wuic-framework-lib-prompt-dialog-fallback-host.component-y3shST6g.mjs → wuic-framework-lib-prompt-dialog-fallback-host.component-Dm9GTZ07.mjs} +3 -3
- package/fesm2022/{wuic-framework-lib-report-designer.component-BnngR2eL.mjs → wuic-framework-lib-report-designer.component-FiAwNkPM.mjs} +2 -2
- package/fesm2022/{wuic-framework-lib-report-viewer.component-LGNRIQh2.mjs → wuic-framework-lib-report-viewer.component-DY0dGMrt.mjs} +2 -2
- package/fesm2022/{wuic-framework-lib-scheduler-list.component-DbcFMwx5.mjs → wuic-framework-lib-scheduler-list.component-3E5m-9Qd.mjs} +31 -5
- package/fesm2022/{wuic-framework-lib-spreadsheet-list-sf.component-DXyRX0YK.mjs → wuic-framework-lib-spreadsheet-list-sf.component-D9P9WbDl.mjs} +483 -24
- package/fesm2022/{wuic-framework-lib-workflow-designer.component-Bm719AWV.mjs → wuic-framework-lib-workflow-designer.component-4RC2EkGA.mjs} +25 -5
- package/fesm2022/{wuic-framework-lib-wuic-framework-lib-CGCZvtKK.mjs → wuic-framework-lib-wuic-framework-lib-BSyGhpM1.mjs} +5082 -3802
- package/fesm2022/wuic-framework-lib.mjs +1 -1
- package/package.json +1 -1
- package/types/wuic-framework-lib.d.ts +1271 -646
|
@@ -10,18 +10,18 @@ import * as i13 from 'primeng/inputtext';
|
|
|
10
10
|
import { InputTextModule } from 'primeng/inputtext';
|
|
11
11
|
import * as i10 from 'primeng/tag';
|
|
12
12
|
import { TagModule } from 'primeng/tag';
|
|
13
|
-
import * as
|
|
13
|
+
import * as i6 from 'primeng/tooltip';
|
|
14
14
|
import { TooltipModule } from 'primeng/tooltip';
|
|
15
15
|
import * as i1$3 from 'primeng/image';
|
|
16
16
|
import { ImageModule } from 'primeng/image';
|
|
17
17
|
import * as i8 from '@ngx-translate/core';
|
|
18
18
|
import { TranslateService, TranslateModule } from '@ngx-translate/core';
|
|
19
|
-
import { r as getThemeOptions, T as TranslationManagerService, U as UserInfoService } from './wuic-framework-lib-wuic-framework-lib-
|
|
19
|
+
import { r as getThemeOptions, T as TranslationManagerService, U as UserInfoService } from './wuic-framework-lib-wuic-framework-lib-BSyGhpM1.mjs';
|
|
20
20
|
import * as i1 from '@angular/router';
|
|
21
21
|
|
|
22
22
|
const frameworkDocsContent = {
|
|
23
23
|
"version": "1.0.0",
|
|
24
|
-
"generatedAt": "2026-05-
|
|
24
|
+
"generatedAt": "2026-05-19T10:32:41.905Z",
|
|
25
25
|
"groups": [
|
|
26
26
|
{
|
|
27
27
|
"id": "primi-passi",
|
|
@@ -915,6 +915,22 @@ const frameworkDocsContent = {
|
|
|
915
915
|
"integration"
|
|
916
916
|
],
|
|
917
917
|
"tier": "pro"
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
"slug": "multi-tenant",
|
|
921
|
+
"title": "Multi-tenant",
|
|
922
|
+
"titles": {
|
|
923
|
+
"en-US": "Multi-tenant",
|
|
924
|
+
"fr-FR": "Multi-tenant",
|
|
925
|
+
"es-ES": "Multi-tenant",
|
|
926
|
+
"de-DE": "Mandantenfaehigkeit"
|
|
927
|
+
},
|
|
928
|
+
"icon": "pi-building",
|
|
929
|
+
"status": "stable",
|
|
930
|
+
"audience": [
|
|
931
|
+
"developer",
|
|
932
|
+
"integration"
|
|
933
|
+
]
|
|
918
934
|
}
|
|
919
935
|
]
|
|
920
936
|
},
|
|
@@ -4315,7 +4331,7 @@ const frameworkDocsContent = {
|
|
|
4315
4331
|
{
|
|
4316
4332
|
"id": "overview",
|
|
4317
4333
|
"title": "Overview",
|
|
4318
|
-
"html": "<h1>Catalogo Componenti</h1><p>Totale cartelle componenti rilevate:
|
|
4334
|
+
"html": "<h1>Catalogo Componenti</h1><p>Totale cartelle componenti rilevate: 50.</p><h2>Componenti disponibili</h2><ul><li>app-settings-editor</li><li>archetype-configurator</li><li>azienda-switcher</li><li>bounded-repeater</li><li>carousel-list</li><li>chart-list</li><li>code-editor</li><li>css-sheet-editor</li><li>dashboard</li><li>data-action-button</li><li>data-repeater</li><li>data-source</li><li>designer</li><li>dynamic-dashboard-template</li><li>dynamic-field-template</li><li>dynamic-form-template</li><li>dynamic-generic-template</li><li>dynamic-repeater-template</li><li>dynamic-template</li><li>error-dialog</li><li>field</li><li>filter-bar</li><li>first-run-wizard</li><li>framework-docs</li><li>image-wrapper</li><li>import-export-button</li><li>kanban-list</li><li>list-grid</li><li>map-list</li><li>meta-menu</li><li>metadata-editor</li><li>notification-bell</li><li>pager</li><li>parametric-dialog</li><li>pivot-builder</li><li>prompt-dialog-fallback-host</li><li>rag-chatbot</li><li>rag-chatbot-fab</li><li>report-designer</li><li>report-viewer</li><li>scheduler-list</li><li>split</li><li>split-area</li><li>spreadsheet-list-sf</li><li>tab-panel-wrapper</li><li>tab-view-wrapper</li><li>tree-list</li><li>upload-dialog</li><li>workflow-designer</li><li>workflow-runner</li></ul><h2>Export da public-api</h2><ul><li>data-source/data-source.component</li><li>azienda-switcher/azienda-switcher.component</li><li>list-grid/list-grid.component</li><li>import-export-button/import-export-button.component</li><li>pager/pager.component</li><li>filter-bar/filter-bar.component</li><li>map-list/map-list.component</li><li>carousel-list/carousel-list.component</li><li>tree-list/tree-list.component</li><li>parametric-dialog/parametric-dialog.component</li><li>upload-dialog/upload-dialog.component</li><li>app-settings-editor/app-settings-editor.component</li><li>data-action-button/data-action-button.component</li><li>meta-menu/meta-menu.component</li><li>field/text-editor/text-editor.component</li><li>field/text-area-editor/text-area-editor.component</li><li>field/number-editor/number-editor.component</li><li>field/lookup-editor/lookup-editor.component</li><li>field/dictionary-editor/dictionary-editor.component</li><li>field/boolean-editor/boolean-editor.component</li><li>field/date-editor/date-editor.component</li><li>field/html-editor/html-editor.component</li><li>field/upload-editor/upload-editor.component</li><li>field/button-editor/button-editor.component</li><li>field/field-editor/field-editor.component</li><li>field/code-area-editor/code-area-editor.component</li><li>dynamic-template/dynamic-template.component</li><li>image-wrapper/image-wrapper.component</li><li>field/color-editor/color-editor.component</li><li>field/property-array-editor/property-array-editor.component</li><li>field/property-object-editor/property-object-editor.component</li><li>field/tree-view-selector/tree-view-selector.component</li><li>workflow-designer/workflow-designer.component</li><li>workflow-runner/workflow-runner.component</li><li>notification-bell/notification-bell.component</li><li>bounded-repeater/bounded-repeater.component</li><li>framework-docs/framework-docs.component</li><li>designer/designer.route.component</li><li>workflow-designer/workflow-designer.route.component</li><li>kanban-list/kanban-list.component</li><li>data-repeater/data-repeater.component</li><li>bounded-repeater/bounded-repeater-pending-changes.guard</li><li>rag-chatbot/rag-chatbot.component</li><li>rag-chatbot-fab/rag-chatbot-fab.component</li><li>error-dialog/error-dialog.component</li></ul>",
|
|
4319
4335
|
"codeSamples": []
|
|
4320
4336
|
}
|
|
4321
4337
|
],
|
|
@@ -6078,6 +6094,82 @@ const frameworkDocsContent = {
|
|
|
6078
6094
|
],
|
|
6079
6095
|
"images": []
|
|
6080
6096
|
},
|
|
6097
|
+
{
|
|
6098
|
+
"slug": "multi-tenant",
|
|
6099
|
+
"lang": "it-IT",
|
|
6100
|
+
"title": "Multi-tenant",
|
|
6101
|
+
"description": "Multi-tenant - documentazione operativa e tecnica",
|
|
6102
|
+
"audience": [
|
|
6103
|
+
"developer",
|
|
6104
|
+
"integration"
|
|
6105
|
+
],
|
|
6106
|
+
"status": "stable",
|
|
6107
|
+
"tags": [
|
|
6108
|
+
"advanced"
|
|
6109
|
+
],
|
|
6110
|
+
"keywords": [
|
|
6111
|
+
"Multi-tenant"
|
|
6112
|
+
],
|
|
6113
|
+
"toc": [
|
|
6114
|
+
{
|
|
6115
|
+
"id": "sec_1",
|
|
6116
|
+
"title": "Scopo"
|
|
6117
|
+
},
|
|
6118
|
+
{
|
|
6119
|
+
"id": "sec_2",
|
|
6120
|
+
"title": "Abilitazione"
|
|
6121
|
+
},
|
|
6122
|
+
{
|
|
6123
|
+
"id": "sec_3",
|
|
6124
|
+
"title": "Modello dati"
|
|
6125
|
+
},
|
|
6126
|
+
{
|
|
6127
|
+
"id": "sec_4",
|
|
6128
|
+
"title": "Login fallback"
|
|
6129
|
+
},
|
|
6130
|
+
{
|
|
6131
|
+
"id": "sec_5",
|
|
6132
|
+
"title": "Switch azienda (superadmin)"
|
|
6133
|
+
},
|
|
6134
|
+
{
|
|
6135
|
+
"id": "sec_6",
|
|
6136
|
+
"title": "Cache tenant-scoped"
|
|
6137
|
+
},
|
|
6138
|
+
{
|
|
6139
|
+
"id": "sec_7",
|
|
6140
|
+
"title": "Anti-hijack"
|
|
6141
|
+
},
|
|
6142
|
+
{
|
|
6143
|
+
"id": "sec_8",
|
|
6144
|
+
"title": "Scheduler tenant-aware"
|
|
6145
|
+
},
|
|
6146
|
+
{
|
|
6147
|
+
"id": "sec_9",
|
|
6148
|
+
"title": "Propagazione scaffolding cross-tenant"
|
|
6149
|
+
},
|
|
6150
|
+
{
|
|
6151
|
+
"id": "sec_10",
|
|
6152
|
+
"title": "Account utente non-superadmin attraverso più tenant"
|
|
6153
|
+
},
|
|
6154
|
+
{
|
|
6155
|
+
"id": "sec_11",
|
|
6156
|
+
"title": "Limiti noti"
|
|
6157
|
+
},
|
|
6158
|
+
{
|
|
6159
|
+
"id": "sec_12",
|
|
6160
|
+
"title": "AppSettings rilevanti"
|
|
6161
|
+
}
|
|
6162
|
+
],
|
|
6163
|
+
"sections": [
|
|
6164
|
+
{
|
|
6165
|
+
"id": "overview",
|
|
6166
|
+
"title": "Overview",
|
|
6167
|
+
"html": "<h1>Multi-tenant</h1><p>Routing connessioni per-utente: ogni azienda ha le proprie connection string verso DB metadati e dati separati, mantenendo retro-compatibilità totale per le installazioni single-tenant esistenti.</p><h2>Scopo</h2><p>Il modulo multi-tenant permette a un singolo backend di servire più aziende — ciascuna su DB metadati e dati distinti — senza duplicare codice o avviare istanze separate. Quando il flag è OFF il comportamento è identico al pre-multi-tenant (zero overhead, cache invariate). Quando il flag è ON il framework:</p><ul><li>risolve a runtime la connection string giusta in base all'<code>id_azienda</code> dell'utente loggato.</li><li>segrega le cache server (<code>Application[]</code>) e client (Dexie + localStorage) per tenant.</li><li>propaga il <code>TenantScope.Current</code> su tutte le query, scheduler e job background.</li></ul><h2>Abilitazione</h2><p>In <code>appsettings.json</code> (sezione <code>AppSettings</code>):</p><ul><li><code>"multiConnectionEnabled": "true"</code> — attiva il routing tenant-aware in <code>MultiTenantHelpers</code> + middleware <code>TenantScope</code> in <code>Startup.cs</code>.</li><li><code>"enableCookieAuthentication": "true"</code> — richiesto dal flusso multi-tenant: il cookie <code>k-user</code> v:2 contiene <code>azienda_id</code> + <code>azienda_id_user</code> per anti-hijack.</li></ul><p>Sono disponibili i template stand-alone <code>appsettings.multi-tenant.mssql.json</code> e <code>appsettings.multi-tenant.mysql.json</code> (6 connection string: 2 default + 4 tenant <code>Tenant1_Meta</code>/<code>Tenant1_Data</code>/<code>Tenant2_Meta</code>/<code>Tenant2_Data</code>).</p><h2>Modello dati</h2><p>Il mapping <code>id_azienda → (data, meta)</code> vive su due colonne della tabella <code>Aziende</code> del DB metadati primario:</p><ul><li><code>Aziende.Connessione_DB_Dati</code>: NOME di una entry in <code>appsettings.ConnectionStrings</code> per il DB dati del tenant (NON la connection string letterale).</li><li><code>Aziende.CONNESSIONE_DB_Meta</code>: NOME di una entry in <code>appsettings.ConnectionStrings</code> per il DB metadati del tenant.</li></ul><p>Esempio popolazione:</p><ul><li><code>Aziende.ID_Azienda=1</code>, <code>Connessione_DB_Dati='Tenant1_Data'</code>, <code>CONNESSIONE_DB_Meta='Tenant1_Meta'</code>.</li><li><code>Aziende.ID_Azienda=100</code> (Sede principale), <code>Connessione_DB_Dati='DataSQLConnection'</code>, <code>CONNESSIONE_DB_Meta='MetaDataSQLConnection'</code> (alias dei default → coperto dallo scope 0).</li></ul><p>Indirezione via nome entry:</p><ul><li>niente password in chiaro nel DB.</li><li>credenziali portabili per ambiente (stesso DB, diversi <code>appsettings.<env>.json</code>).</li><li>coerenza con <code>_metadati__tabelle.md_conn_name</code>.</li></ul><h2>Login fallback</h2><p>Quando un utente NON esiste sul DB metadati primario il login fa fallback via <code>_login_index</code>:</p><p>1. lookup utente su <code>MetaDataSQLConnection</code> (primario) → trovato? login.</p><p>2. miss → query <code>_login_index</code> (<code>SHA-256(LOWER(username))</code> → <code>id_azienda</code>).</p><p>3. per ogni <code>id_azienda</code> candidato: push <code>TenantScope</code>, retry <code>metaQuery.login</code> sul DB metadati del tenant.</p><p>4. primo match wins → cookie emesso con <code>azienda_id</code> del tenant.</p><p>Lo SP <code>sp__login_index_upsert(@username, @idAzienda)</code> popola l'indice. Hash SHA-256 calcolato server-side (allineato col client C# <code>MultiTenantHelpers.ComputeUsernameHash</code> su UTF-8).</p><h2>Switch azienda (superadmin)</h2><p>I superadmin possono cambiare l'azienda attiva runtime via <code>MetaService.switchAzienda(idAzienda)</code>:</p><ul><li>401 se non superadmin.</li><li>400 se l'<code>idAzienda</code> non ha entrambe le colonne <code>Aziende.Connessione_DB_Dati</code> / <code>CONNESSIONE_DB_Meta</code> valorizzate.</li><li>riemette cookie con <code>azienda_id</code> aggiornato, preservando <code>azienda_id_user</code> (snapshot al login).</li><li>componente Angular <code><wuic-azienda-switcher></code> espone il dropdown UI. Si auto-nasconde via <code>@HostBinding('attr.hidden')</code> per utenti non-super o flag OFF.</li></ul><h2>Cache tenant-scoped</h2><p>Server-side (<code>Application[]</code> keys):</p><ul><li><code>MultiTenantHelpers.TenantKey(baseKey)</code> aggiunge suffisso <code>__a<id></code> quando flag ON e <code>TenantScope.Current.AziendaId > 0</code>.</li><li>chiavi affette: <code>userList</code>, <code>roleList</code>, <code>userRoleList</code>, <code>companyList</code>, <code>storedMeta</code>, <code>storedTableMeta</code>, <code>storedTableActionMeta</code>, <code>storedConditionGroupMeta</code>, <code>storedTableStyleMeta</code>, <code>SysInfo</code>.</li></ul><p>Client-side (Angular framework lib):</p><ul><li>Dexie name → <code>MetaDB__a<id></code> e <code>WuicClientSideCrudDB__a<id></code>.</li><li>localStorage keys → <code>menu_<userId>__a<id></code>, <code>wuic_custom_settings_<userId>__a<id></code>, ecc.</li><li>orphan cleanup all'init di <code>MetadataProviderService</code>: enumera <code>indexedDB.databases()</code> e droppa DB di tenant non più correnti.</li></ul><p>Quando flag OFF: TenantKey ritorna la baseKey invariata → cache identica al pre-multi-tenant.</p><h2>Anti-hijack</h2><p><code>RawHelpers.authenticate()</code> ad ogni request:</p><ul><li>richiede cookie v:2 con <code>azienda_id</code> + <code>azienda_id_user</code> (cookie legacy → relogin forzato).</li><li>per utenti non-super, <code>cookie.azienda_id</code> DEVE coincidere con <code>cookie.azienda_id_user</code> (manomissione → relogin + security log).</li><li>i superadmin sono esenti (la loro <code>azienda_id</code> cambia legittimamente via <code>switchAzienda</code>).</li><li>popola <code>TenantScope.Current</code> con <code>(azienda_id, isSuperAdmin)</code> per il resto della request.</li></ul><p>Il middleware <code>HydrateLegacyPrincipalFromKUserCookie</code> in <code>Startup.cs</code> popola <code>TenantScope</code> anche per endpoint AsmxProxy che non chiamano <code>authenticate()</code> esplicitamente (es. <code>readCustomSettings</code>, <code>getMenuByUserID</code>).</p><h2>Scheduler tenant-aware</h2><p><code>SchedulerHostedService</code> enumera le aziende a ogni ciclo (<code>Scheduler:PollSeconds</code>, default 15s):</p><ul><li>scope 0 (primario / no-tenant) — sempre processato.</li><li>più uno scope per ciascuna azienda con mapping conn ≠ default.</li><li>aziende mappate alle entry default (<code>MetaDataSQLConnection</code>) sono skippate (coperte dallo scope 0).</li></ul><p>Tutti e 4 i tipi action sono supportati per-tenant:</p><ul><li><code>1 | sql</code>: connection dati via routing tenant-aware.</li><li><code>2 | webservice</code>: utente <code>__scheduler</code> creato in ogni DB tenant; cookie session per-tenant via <code>switchAzienda</code> HTTP call.</li><li><code>3 | assembly method</code>: <code>TenantScope.Current</code> propagato via AsyncLocal al metodo invocato.</li><li><code>4 | mailing</code>: <code>_mailing_lists</code> / <code>_mail_recipients</code> letti dal DB del tenant corrente; SMTP settings override-able per-tenant via naming convention <code><key>__a<id></code> (es. <code>email-host__a1</code>).</li></ul><p>Lo schema scheduler (<code>scheduler</code>, <code>scheduler_execution</code>) viene validato per-tenant: tenant senza le tabelle vengono skippati silenziosamente fino al restart.</p><h2>Propagazione scaffolding cross-tenant</h2><p>Quando un superadmin scaffolda una tabella o vista su un tenant, di default i metadata (<code>_metadati__tabelle</code> + <code>_metadati__colonne</code>) vengono scritti <strong>solo sul DB metadati del tenant corrente</strong>. Per evitare di dover ripetere manualmente lo scaffolding su ogni tenant, l'endpoint <code>scaffolding.scaffoldTable</code> / <code>scaffolding.scaffoldView</code> espone il parametro booleano <code>propagateToTenants</code>:</p><ul><li><code>false</code> (default): scaffold solo sul tenant corrente (back-compat).</li><li><code>true</code>: dopo aver scaffoldato il tenant corrente, il backend itera su tutti gli altri tenant attivi (<code>MultiTenantHelpers.GetAziendePrimary()</code>) e replica lo scaffolding sul DB metadati di ciascuno. Lo schema viene letto dal DB DATI del tenant target risolto via <code>Aziende.Connessione_DB_Dati</code>. Tenant senza la tabella fisica vengono skippati con esito <code>SKIPPED:not-found</code> → operazione best-effort, non transazionale cross-tenant.</li></ul><p>Sicurezza:</p><ul><li>gated a superadmin con flag <code>multiConnectionEnabled=true</code> — per gli altri il parametro viene silenziosamente ignorato.</li><li>aziende mappate alle entry default (<code>MetaDataSQLConnection</code>) sono skippate (coperte dallo scope 0 del tenant corrente se il superadmin è già lì).</li></ul><p>Il risultato del scaffold include chiavi <code>tenant_<id>:</code> con valore <code>OK</code> / <code>SKIPPED:<reason></code> / <code>FAILED:<message></code> per diagnosi per-tenant.</p><p>UI: il dialog di scaffolding (<code>md_id=1556</code>) espone una checkbox <strong>Propagate to all tenants</strong> sul form; l'action <code>Scaffold Table</code> invia il flag al backend. In single-tenant (<code>multiConnectionEnabled=false</code>) il backend filtra la colonna dalla risposta di <code>getTableMetadata</code> → la checkbox non compare nel form (niente UI inutile da gestire).</p><h2>Account utente non-superadmin attraverso più tenant</h2><p>Il selettore <code><wuic-azienda-switcher></code> è visibile <strong>solo ai superadmin</strong>. Per gli utenti non-superadmin il check anti-hijack (<code>cookie.azienda_id != user.azienda_id_user → reject</code>) impedisce di cambiare tenant runtime: il cookie ha <code>azienda_id_user</code> snapshottato al login e immutabile.</p><p>Convenzione operativa per scenari in cui la stessa persona deve accedere a più tenant come utente standard:</p><ul><li><strong>creare un'utenza separata per ogni tenant</strong>, con <strong>username distinto</strong> (es. <code>mario.rossi@T1</code>, <code>mario.rossi@T2</code>, oppure <code>mario.rossi.t1</code> / <code>mario.rossi.t2</code>).</li><li>ogni utenza vive nel <code>utenti</code> del proprio DB tenant, con <code>id_azienda</code> corrispondente.</li><li><code>_login_index</code> mappa ciascun username al proprio <code>id_azienda</code>: niente ambiguità nel fallback chain.</li><li>l'utente sceglie a quale tenant accedere cambiando username al login.</li></ul><p>Anti-pattern da evitare:</p><ul><li><strong>stesso username su due tenant</strong>: il login fallback prova entrambi i candidati <code>_login_index</code> in ordine di <code>id_azienda</code> ascendente; il primo che valida la password vince. L'utente entra sempre sul tenant con <code>id_azienda</code> più basso e non può raggiungere l'altro senza widget switcher. La password resta condivisa cross-tenant, esposta a brute-force su entrambi i DB.</li></ul><p>Se la persona è un superadmin, basta un'unica utenza sul DB primario (<code>id_azienda</code> = id azienda madre): il widget switcher copre runtime il cambio tenant.</p><h2>Limiti noti</h2><ul><li><strong>DBMS uniforme cross-tenant</strong>: tutti i tenant DEVONO condividere lo stesso DBMS del primario (<code>AppSettings.dbms</code> / <code>meta-dbms</code>). Il routing tenant-aware instrada solo il <em>catalog</em> (quale DB), non <em>quale provider</em>. Es: backend MSSQL + un <code>Tenant2_Meta</code> con connection string MySQL → <code>SqlException</code> al primo accesso al tenant. Una validazione fail-soft (WARN log) viene eseguita all'avvio (<code>MultiTenantHelpers.ValidateTenantConnectionStrings</code>): per ogni cs referenziata da <code>Aziende.{Connessione_DB_Dati,CONNESSIONE_DB_Meta}</code> rileva tramite heuristic (keyword come <code>Port=3306</code>, <code>Initial Catalog=</code>, <code>(HOST=...)</code>, ecc.) il DBMS target e logga warning se differente da quello configurato. Mismatch → boot prosegue ma il tenant disallineato sara' inutilizzabile a runtime.</li><li>l'utente <code>__scheduler</code> creato sui DB tenant ha <code>id_azienda=NULL</code> (non valorizzato): adeguato per il flow webservice (login parte sempre dal primario), ma non risolvibile via <code>_login_index</code> per casi inversi.</li><li>ogni nuovo tenant richiede una entry in <code>appsettings.ConnectionStrings</code> + redeploy/reload config.</li><li>job scheduler in tenant senza tabella <code>scheduler</code> vengono skippati silenziosamente (info-log al primo poll).</li></ul><h2>AppSettings rilevanti</h2><p>Snippet (chiavi multi-tenant):</p><ul><li><code>"AppSettings.multiConnectionEnabled": "true"</code></li><li><code>"AppSettings.enableCookieAuthentication": "true"</code></li><li><code>"ConnectionStrings.MetaDataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.DataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Data": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Data": "..."</code></li><li>(opzionale) <code>"AppSettings.email-host__a1": "smtp.tenant1.example"</code> — override SMTP per tenant 1.</li></ul>",
|
|
6168
|
+
"codeSamples": []
|
|
6169
|
+
}
|
|
6170
|
+
],
|
|
6171
|
+
"images": []
|
|
6172
|
+
},
|
|
6081
6173
|
{
|
|
6082
6174
|
"slug": "troubleshooting",
|
|
6083
6175
|
"lang": "it-IT",
|
|
@@ -9455,7 +9547,7 @@ const frameworkDocsContent = {
|
|
|
9455
9547
|
{
|
|
9456
9548
|
"id": "overview",
|
|
9457
9549
|
"title": "Overview",
|
|
9458
|
-
"html": "<h1>Component Catalog</h1><p>Total component folders detected:
|
|
9550
|
+
"html": "<h1>Component Catalog</h1><p>Total component folders detected: 50.</p><h2>Available components</h2><ul><li>app-settings-editor</li><li>archetype-configurator</li><li>azienda-switcher</li><li>bounded-repeater</li><li>carousel-list</li><li>chart-list</li><li>code-editor</li><li>css-sheet-editor</li><li>dashboard</li><li>data-action-button</li><li>data-repeater</li><li>data-source</li><li>designer</li><li>dynamic-dashboard-template</li><li>dynamic-field-template</li><li>dynamic-form-template</li><li>dynamic-generic-template</li><li>dynamic-repeater-template</li><li>dynamic-template</li><li>error-dialog</li><li>field</li><li>filter-bar</li><li>first-run-wizard</li><li>framework-docs</li><li>image-wrapper</li><li>import-export-button</li><li>kanban-list</li><li>list-grid</li><li>map-list</li><li>meta-menu</li><li>metadata-editor</li><li>notification-bell</li><li>pager</li><li>parametric-dialog</li><li>pivot-builder</li><li>prompt-dialog-fallback-host</li><li>rag-chatbot</li><li>rag-chatbot-fab</li><li>report-designer</li><li>report-viewer</li><li>scheduler-list</li><li>split</li><li>split-area</li><li>spreadsheet-list-sf</li><li>tab-panel-wrapper</li><li>tab-view-wrapper</li><li>tree-list</li><li>upload-dialog</li><li>workflow-designer</li><li>workflow-runner</li></ul><h2>Exports from public-api</h2><ul><li>data-source/data-source.component</li><li>azienda-switcher/azienda-switcher.component</li><li>list-grid/list-grid.component</li><li>import-export-button/import-export-button.component</li><li>pager/pager.component</li><li>filter-bar/filter-bar.component</li><li>map-list/map-list.component</li><li>carousel-list/carousel-list.component</li><li>tree-list/tree-list.component</li><li>parametric-dialog/parametric-dialog.component</li><li>upload-dialog/upload-dialog.component</li><li>app-settings-editor/app-settings-editor.component</li><li>data-action-button/data-action-button.component</li><li>meta-menu/meta-menu.component</li><li>field/text-editor/text-editor.component</li><li>field/text-area-editor/text-area-editor.component</li><li>field/number-editor/number-editor.component</li><li>field/lookup-editor/lookup-editor.component</li><li>field/dictionary-editor/dictionary-editor.component</li><li>field/boolean-editor/boolean-editor.component</li><li>field/date-editor/date-editor.component</li><li>field/html-editor/html-editor.component</li><li>field/upload-editor/upload-editor.component</li><li>field/button-editor/button-editor.component</li><li>field/field-editor/field-editor.component</li><li>field/code-area-editor/code-area-editor.component</li><li>dynamic-template/dynamic-template.component</li><li>image-wrapper/image-wrapper.component</li><li>field/color-editor/color-editor.component</li><li>field/property-array-editor/property-array-editor.component</li><li>field/property-object-editor/property-object-editor.component</li><li>field/tree-view-selector/tree-view-selector.component</li><li>workflow-designer/workflow-designer.component</li><li>workflow-runner/workflow-runner.component</li><li>notification-bell/notification-bell.component</li><li>bounded-repeater/bounded-repeater.component</li><li>framework-docs/framework-docs.component</li><li>designer/designer.route.component</li><li>workflow-designer/workflow-designer.route.component</li><li>kanban-list/kanban-list.component</li><li>data-repeater/data-repeater.component</li><li>bounded-repeater/bounded-repeater-pending-changes.guard</li><li>rag-chatbot/rag-chatbot.component</li><li>rag-chatbot-fab/rag-chatbot-fab.component</li><li>error-dialog/error-dialog.component</li></ul>",
|
|
9459
9551
|
"codeSamples": []
|
|
9460
9552
|
}
|
|
9461
9553
|
],
|
|
@@ -11191,6 +11283,82 @@ const frameworkDocsContent = {
|
|
|
11191
11283
|
],
|
|
11192
11284
|
"images": []
|
|
11193
11285
|
},
|
|
11286
|
+
{
|
|
11287
|
+
"slug": "multi-tenant",
|
|
11288
|
+
"lang": "en-US",
|
|
11289
|
+
"title": "Multi-tenant",
|
|
11290
|
+
"description": "Multi-tenant - documentazione operativa e tecnica",
|
|
11291
|
+
"audience": [
|
|
11292
|
+
"developer",
|
|
11293
|
+
"integration"
|
|
11294
|
+
],
|
|
11295
|
+
"status": "stable",
|
|
11296
|
+
"tags": [
|
|
11297
|
+
"advanced"
|
|
11298
|
+
],
|
|
11299
|
+
"keywords": [
|
|
11300
|
+
"Multi-tenant"
|
|
11301
|
+
],
|
|
11302
|
+
"toc": [
|
|
11303
|
+
{
|
|
11304
|
+
"id": "sec_1",
|
|
11305
|
+
"title": "Scope"
|
|
11306
|
+
},
|
|
11307
|
+
{
|
|
11308
|
+
"id": "sec_2",
|
|
11309
|
+
"title": "Enablement"
|
|
11310
|
+
},
|
|
11311
|
+
{
|
|
11312
|
+
"id": "sec_3",
|
|
11313
|
+
"title": "Data model"
|
|
11314
|
+
},
|
|
11315
|
+
{
|
|
11316
|
+
"id": "sec_4",
|
|
11317
|
+
"title": "Login fallback"
|
|
11318
|
+
},
|
|
11319
|
+
{
|
|
11320
|
+
"id": "sec_5",
|
|
11321
|
+
"title": "Tenant switch (superadmin)"
|
|
11322
|
+
},
|
|
11323
|
+
{
|
|
11324
|
+
"id": "sec_6",
|
|
11325
|
+
"title": "Tenant-scoped caches"
|
|
11326
|
+
},
|
|
11327
|
+
{
|
|
11328
|
+
"id": "sec_7",
|
|
11329
|
+
"title": "Anti-hijack"
|
|
11330
|
+
},
|
|
11331
|
+
{
|
|
11332
|
+
"id": "sec_8",
|
|
11333
|
+
"title": "Tenant-aware scheduler"
|
|
11334
|
+
},
|
|
11335
|
+
{
|
|
11336
|
+
"id": "sec_9",
|
|
11337
|
+
"title": "Cross-tenant scaffolding propagation"
|
|
11338
|
+
},
|
|
11339
|
+
{
|
|
11340
|
+
"id": "sec_10",
|
|
11341
|
+
"title": "Non-superadmin user accounts across multiple tenants"
|
|
11342
|
+
},
|
|
11343
|
+
{
|
|
11344
|
+
"id": "sec_11",
|
|
11345
|
+
"title": "Known limits"
|
|
11346
|
+
},
|
|
11347
|
+
{
|
|
11348
|
+
"id": "sec_12",
|
|
11349
|
+
"title": "Relevant AppSettings"
|
|
11350
|
+
}
|
|
11351
|
+
],
|
|
11352
|
+
"sections": [
|
|
11353
|
+
{
|
|
11354
|
+
"id": "overview",
|
|
11355
|
+
"title": "Overview",
|
|
11356
|
+
"html": "<h1>Multi-tenant</h1><p>Per-user connection routing: each tenant has its own connection strings to separate metadata and data DBs, while preserving full backward compatibility for existing single-tenant installations.</p><h2>Scope</h2><p>The multi-tenant module allows a single backend to serve multiple tenants — each on distinct metadata and data DBs — without duplicating code or starting separate instances. When the flag is OFF behavior is identical to pre-multi-tenant (zero overhead, caches unchanged). When ON the framework:</p><ul><li>resolves the right connection string at runtime based on the logged-in user's <code>id_azienda</code>.</li><li>segregates server caches (<code>Application[]</code>) and client caches (Dexie + localStorage) per tenant.</li><li>propagates <code>TenantScope.Current</code> across all queries, scheduler and background jobs.</li></ul><h2>Enablement</h2><p>In <code>appsettings.json</code> (<code>AppSettings</code> section):</p><ul><li><code>"multiConnectionEnabled": "true"</code> — activates tenant-aware routing in <code>MultiTenantHelpers</code> + <code>TenantScope</code> middleware in <code>Startup.cs</code>.</li><li><code>"enableCookieAuthentication": "true"</code> — required by the multi-tenant flow: the <code>k-user</code> v:2 cookie carries <code>azienda_id</code> + <code>azienda_id_user</code> for anti-hijack.</li></ul><p>Stand-alone templates <code>appsettings.multi-tenant.mssql.json</code> and <code>appsettings.multi-tenant.mysql.json</code> are available (6 connection strings: 2 default + 4 tenant <code>Tenant1_Meta</code>/<code>Tenant1_Data</code>/<code>Tenant2_Meta</code>/<code>Tenant2_Data</code>).</p><h2>Data model</h2><p>The <code>id_azienda → (data, meta)</code> mapping lives on two columns of the <code>Aziende</code> table on the primary metadata DB:</p><ul><li><code>Aziende.Connessione_DB_Dati</code>: NAME of an entry in <code>appsettings.ConnectionStrings</code> for the tenant data DB (NOT the literal connection string).</li><li><code>Aziende.CONNESSIONE_DB_Meta</code>: NAME of an entry in <code>appsettings.ConnectionStrings</code> for the tenant metadata DB.</li></ul><p>Example population:</p><ul><li><code>Aziende.ID_Azienda=1</code>, <code>Connessione_DB_Dati='Tenant1_Data'</code>, <code>CONNESSIONE_DB_Meta='Tenant1_Meta'</code>.</li><li><code>Aziende.ID_Azienda=100</code> (Sede principale), <code>Connessione_DB_Dati='DataSQLConnection'</code>, <code>CONNESSIONE_DB_Meta='MetaDataSQLConnection'</code> (default aliases → covered by scope 0).</li></ul><p>Entry-name indirection:</p><ul><li>no clear-text passwords in the DB.</li><li>credentials portable per environment (same DB, different <code>appsettings.<env>.json</code>).</li><li>consistent with <code>_metadati__tabelle.md_conn_name</code>.</li></ul><h2>Login fallback</h2><p>When a user does NOT exist on the primary metadata DB, login falls back via <code>_login_index</code>:</p><p>1. user lookup on <code>MetaDataSQLConnection</code> (primary) → found? login.</p><p>2. miss → query <code>_login_index</code> (<code>SHA-256(LOWER(username))</code> → <code>id_azienda</code>).</p><p>3. for each candidate <code>id_azienda</code>: push <code>TenantScope</code>, retry <code>metaQuery.login</code> on the tenant metadata DB.</p><p>4. first match wins → cookie issued with the tenant's <code>azienda_id</code>.</p><p>The <code>sp__login_index_upsert(@username, @idAzienda)</code> stored procedure populates the index. SHA-256 hash computed server-side (aligned with C# client <code>MultiTenantHelpers.ComputeUsernameHash</code> on UTF-8).</p><h2>Tenant switch (superadmin)</h2><p>Superadmins can change the active tenant at runtime via <code>MetaService.switchAzienda(idAzienda)</code>:</p><ul><li>401 if not superadmin.</li><li>400 if <code>idAzienda</code> doesn't have both <code>Aziende.Connessione_DB_Dati</code> / <code>CONNESSIONE_DB_Meta</code> columns populated.</li><li>re-emits cookie with updated <code>azienda_id</code>, preserving <code>azienda_id_user</code> (snapshot at login).</li><li>Angular component <code><wuic-azienda-switcher></code> exposes the dropdown UI. Self-hides via <code>@HostBinding('attr.hidden')</code> for non-super users or flag OFF.</li></ul><h2>Tenant-scoped caches</h2><p>Server-side (<code>Application[]</code> keys):</p><ul><li><code>MultiTenantHelpers.TenantKey(baseKey)</code> appends <code>__a<id></code> suffix when flag ON and <code>TenantScope.Current.AziendaId > 0</code>.</li><li>affected keys: <code>userList</code>, <code>roleList</code>, <code>userRoleList</code>, <code>companyList</code>, <code>storedMeta</code>, <code>storedTableMeta</code>, <code>storedTableActionMeta</code>, <code>storedConditionGroupMeta</code>, <code>storedTableStyleMeta</code>, <code>SysInfo</code>.</li></ul><p>Client-side (Angular framework lib):</p><ul><li>Dexie name → <code>MetaDB__a<id></code> and <code>WuicClientSideCrudDB__a<id></code>.</li><li>localStorage keys → <code>menu_<userId>__a<id></code>, <code>wuic_custom_settings_<userId>__a<id></code>, etc.</li><li>orphan cleanup at <code>MetadataProviderService</code> init: enumerates <code>indexedDB.databases()</code> and drops DBs of tenants no longer current.</li></ul><p>When flag OFF: TenantKey returns the baseKey unchanged → caches identical to pre-multi-tenant.</p><h2>Anti-hijack</h2><p><code>RawHelpers.authenticate()</code> on every request:</p><ul><li>requires cookie v:2 with <code>azienda_id</code> + <code>azienda_id_user</code> (legacy cookie → forced relogin).</li><li>for non-super users, <code>cookie.azienda_id</code> MUST match <code>cookie.azienda_id_user</code> (tampering → relogin + security log).</li><li>superadmins are exempt (their <code>azienda_id</code> legitimately changes via <code>switchAzienda</code>).</li><li>populates <code>TenantScope.Current</code> with <code>(azienda_id, isSuperAdmin)</code> for the rest of the request.</li></ul><p>The <code>HydrateLegacyPrincipalFromKUserCookie</code> middleware in <code>Startup.cs</code> also populates <code>TenantScope</code> for AsmxProxy endpoints that don't call <code>authenticate()</code> explicitly (e.g. <code>readCustomSettings</code>, <code>getMenuByUserID</code>).</p><h2>Tenant-aware scheduler</h2><p><code>SchedulerHostedService</code> enumerates tenants on every cycle (<code>Scheduler:PollSeconds</code>, default 15s):</p><ul><li>scope 0 (primary / no-tenant) — always processed.</li><li>plus one scope per tenant with conn mapping ≠ default.</li><li>tenants mapped to default entries (<code>MetaDataSQLConnection</code>) are skipped (covered by scope 0).</li></ul><p>All 4 action types are supported per-tenant:</p><ul><li><code>1 | sql</code>: data connection via tenant-aware routing.</li><li><code>2 | webservice</code>: <code>__scheduler</code> user created in each tenant DB; per-tenant session cookie via <code>switchAzienda</code> HTTP call.</li><li><code>3 | assembly method</code>: <code>TenantScope.Current</code> propagated via AsyncLocal to the invoked method.</li><li><code>4 | mailing</code>: <code>_mailing_lists</code> / <code>_mail_recipients</code> read from the current tenant DB; SMTP settings overridable per-tenant via naming convention <code><key>__a<id></code> (e.g. <code>email-host__a1</code>).</li></ul><p>The scheduler schema (<code>scheduler</code>, <code>scheduler_execution</code>) is validated per-tenant: tenants missing the tables are silently skipped until restart.</p><h2>Cross-tenant scaffolding propagation</h2><p>When a superadmin scaffolds a table or view on one tenant, by default the metadata (<code>_metadati__tabelle</code> + <code>_metadati__colonne</code>) is written <strong>only to the current tenant's metadata DB</strong>. To avoid manually repeating the scaffolding on every tenant, the <code>scaffolding.scaffoldTable</code> / <code>scaffolding.scaffoldView</code> endpoint exposes the boolean parameter <code>propagateToTenants</code>:</p><ul><li><code>false</code> (default): scaffold only on the current tenant (back-compat).</li><li><code>true</code>: after scaffolding the current tenant, the backend iterates over all other active tenants (<code>MultiTenantHelpers.GetAziendePrimary()</code>) and replicates the scaffolding on each one's metadata DB. The schema is read from the target tenant's DATA DB resolved via <code>Aziende.Connessione_DB_Dati</code>. Tenants without the physical table are skipped with outcome <code>SKIPPED:not-found</code> → best-effort operation, not cross-tenant transactional.</li></ul><p>Security:</p><ul><li>gated to superadmin with <code>multiConnectionEnabled=true</code> flag — for others the parameter is silently ignored.</li><li>companies mapped to default entries (<code>MetaDataSQLConnection</code>) are skipped (covered by scope 0 of the current tenant if the superadmin is already there).</li></ul><p>The scaffold result includes <code>tenant_<id>:</code> keys with value <code>OK</code> / <code>SKIPPED:<reason></code> / <code>FAILED:<message></code> for per-tenant diagnosis.</p><p>UI: the scaffolding dialog (<code>md_id=1556</code>) exposes a <strong>Propagate to all tenants</strong> checkbox on the form; the <code>Scaffold Table</code> action sends the flag to the backend. In single-tenant mode (<code>multiConnectionEnabled=false</code>) the backend filters the column out of the <code>getTableMetadata</code> response → the checkbox does not appear in the form (no useless UI to handle).</p><h2>Non-superadmin user accounts across multiple tenants</h2><p>The <code><wuic-azienda-switcher></code> selector is visible <strong>only to superadmins</strong>. For non-superadmin users the anti-hijack check (<code>cookie.azienda_id != user.azienda_id_user → reject</code>) prevents changing tenant at runtime: the cookie has <code>azienda_id_user</code> snapshotted at login and immutable.</p><p>Operational convention for scenarios where the same person needs to access multiple tenants as a standard user:</p><ul><li><strong>create a separate user per tenant</strong>, with a <strong>distinct username</strong> (e.g. <code>mario.rossi@T1</code>, <code>mario.rossi@T2</code>, or <code>mario.rossi.t1</code> / <code>mario.rossi.t2</code>).</li><li>each user lives in <code>utenti</code> of its own tenant DB, with matching <code>id_azienda</code>.</li><li><code>_login_index</code> maps each username to its <code>id_azienda</code>: no ambiguity in the fallback chain.</li><li>the user picks which tenant to access by changing username at login.</li></ul><p>Anti-pattern to avoid:</p><ul><li><strong>same username on two tenants</strong>: the fallback login tries both <code>_login_index</code> candidates in ascending <code>id_azienda</code> order; the first one that validates the password wins. The user always lands on the tenant with the lowest <code>id_azienda</code> and cannot reach the other without the switcher widget. The password remains shared cross-tenant, exposed to brute-force on both DBs.</li></ul><p>If the person is a superadmin, a single user on the primary DB (<code>id_azienda</code> = parent company id) is enough: the switcher widget covers runtime tenant changes.</p><h2>Known limits</h2><ul><li><strong>Uniform DBMS cross-tenant</strong>: all tenants MUST share the same DBMS as the primary (<code>AppSettings.dbms</code> / <code>meta-dbms</code>). The tenant-aware routing only routes the <em>catalog</em> (which DB), not <em>which provider</em>. Example: MSSQL backend + a <code>Tenant2_Meta</code> with a MySQL connection string → <code>SqlException</code> on first tenant access. A fail-soft validation (WARN log) runs at startup (<code>MultiTenantHelpers.ValidateTenantConnectionStrings</code>): for each cs referenced by <code>Aziende.{Connessione_DB_Dati,CONNESSIONE_DB_Meta}</code> it heuristically detects the target DBMS (keywords like <code>Port=3306</code>, <code>Initial Catalog=</code>, <code>(HOST=...)</code>, etc.) and logs a warning if it differs from the configured one. Mismatch → boot proceeds but the mismatched tenant is unusable at runtime.</li><li>the <code>__scheduler</code> user created on tenant DBs has <code>id_azienda=NULL</code> (not populated): adequate for the webservice flow (login always starts on primary) but not resolvable via <code>_login_index</code> for reverse cases.</li><li>each new tenant requires an entry in <code>appsettings.ConnectionStrings</code> + redeploy/reload config.</li><li>scheduler jobs in tenants without the <code>scheduler</code> table are silently skipped (info-log at first poll).</li></ul><h2>Relevant AppSettings</h2><p>Snippet (multi-tenant keys):</p><ul><li><code>"AppSettings.multiConnectionEnabled": "true"</code></li><li><code>"AppSettings.enableCookieAuthentication": "true"</code></li><li><code>"ConnectionStrings.MetaDataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.DataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Data": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Data": "..."</code></li><li>(optional) <code>"AppSettings.email-host__a1": "smtp.tenant1.example"</code> — SMTP override for tenant 1.</li></ul>",
|
|
11357
|
+
"codeSamples": []
|
|
11358
|
+
}
|
|
11359
|
+
],
|
|
11360
|
+
"images": []
|
|
11361
|
+
},
|
|
11194
11362
|
{
|
|
11195
11363
|
"slug": "troubleshooting",
|
|
11196
11364
|
"lang": "en-US",
|
|
@@ -14568,7 +14736,7 @@ const frameworkDocsContent = {
|
|
|
14568
14736
|
{
|
|
14569
14737
|
"id": "overview",
|
|
14570
14738
|
"title": "Overview",
|
|
14571
|
-
"html": "<h1>Component Catalog</h1><p>Total component folders detected:
|
|
14739
|
+
"html": "<h1>Component Catalog</h1><p>Total component folders detected: 50.</p><h2>Available components</h2><ul><li>app-settings-editor</li><li>archetype-configurator</li><li>azienda-switcher</li><li>bounded-repeater</li><li>carousel-list</li><li>chart-list</li><li>code-editor</li><li>css-sheet-editor</li><li>dashboard</li><li>data-action-button</li><li>data-repeater</li><li>data-source</li><li>designer</li><li>dynamic-dashboard-template</li><li>dynamic-field-template</li><li>dynamic-form-template</li><li>dynamic-generic-template</li><li>dynamic-repeater-template</li><li>dynamic-template</li><li>error-dialog</li><li>field</li><li>filter-bar</li><li>first-run-wizard</li><li>framework-docs</li><li>image-wrapper</li><li>import-export-button</li><li>kanban-list</li><li>list-grid</li><li>map-list</li><li>meta-menu</li><li>metadata-editor</li><li>notification-bell</li><li>pager</li><li>parametric-dialog</li><li>pivot-builder</li><li>prompt-dialog-fallback-host</li><li>rag-chatbot</li><li>rag-chatbot-fab</li><li>report-designer</li><li>report-viewer</li><li>scheduler-list</li><li>split</li><li>split-area</li><li>spreadsheet-list-sf</li><li>tab-panel-wrapper</li><li>tab-view-wrapper</li><li>tree-list</li><li>upload-dialog</li><li>workflow-designer</li><li>workflow-runner</li></ul><h2>Exports from public-api</h2><ul><li>data-source/data-source.component</li><li>azienda-switcher/azienda-switcher.component</li><li>list-grid/list-grid.component</li><li>import-export-button/import-export-button.component</li><li>pager/pager.component</li><li>filter-bar/filter-bar.component</li><li>map-list/map-list.component</li><li>carousel-list/carousel-list.component</li><li>tree-list/tree-list.component</li><li>parametric-dialog/parametric-dialog.component</li><li>upload-dialog/upload-dialog.component</li><li>app-settings-editor/app-settings-editor.component</li><li>data-action-button/data-action-button.component</li><li>meta-menu/meta-menu.component</li><li>field/text-editor/text-editor.component</li><li>field/text-area-editor/text-area-editor.component</li><li>field/number-editor/number-editor.component</li><li>field/lookup-editor/lookup-editor.component</li><li>field/dictionary-editor/dictionary-editor.component</li><li>field/boolean-editor/boolean-editor.component</li><li>field/date-editor/date-editor.component</li><li>field/html-editor/html-editor.component</li><li>field/upload-editor/upload-editor.component</li><li>field/button-editor/button-editor.component</li><li>field/field-editor/field-editor.component</li><li>field/code-area-editor/code-area-editor.component</li><li>dynamic-template/dynamic-template.component</li><li>image-wrapper/image-wrapper.component</li><li>field/color-editor/color-editor.component</li><li>field/property-array-editor/property-array-editor.component</li><li>field/property-object-editor/property-object-editor.component</li><li>field/tree-view-selector/tree-view-selector.component</li><li>workflow-designer/workflow-designer.component</li><li>workflow-runner/workflow-runner.component</li><li>notification-bell/notification-bell.component</li><li>bounded-repeater/bounded-repeater.component</li><li>framework-docs/framework-docs.component</li><li>designer/designer.route.component</li><li>workflow-designer/workflow-designer.route.component</li><li>kanban-list/kanban-list.component</li><li>data-repeater/data-repeater.component</li><li>bounded-repeater/bounded-repeater-pending-changes.guard</li><li>rag-chatbot/rag-chatbot.component</li><li>rag-chatbot-fab/rag-chatbot-fab.component</li><li>error-dialog/error-dialog.component</li></ul>",
|
|
14572
14740
|
"codeSamples": []
|
|
14573
14741
|
}
|
|
14574
14742
|
],
|
|
@@ -16315,6 +16483,82 @@ const frameworkDocsContent = {
|
|
|
16315
16483
|
],
|
|
16316
16484
|
"images": []
|
|
16317
16485
|
},
|
|
16486
|
+
{
|
|
16487
|
+
"slug": "multi-tenant",
|
|
16488
|
+
"lang": "fr-FR",
|
|
16489
|
+
"title": "Multi-tenant",
|
|
16490
|
+
"description": "Multi-tenant - documentazione operativa e tecnica",
|
|
16491
|
+
"audience": [
|
|
16492
|
+
"developer",
|
|
16493
|
+
"integration"
|
|
16494
|
+
],
|
|
16495
|
+
"status": "stable",
|
|
16496
|
+
"tags": [
|
|
16497
|
+
"advanced"
|
|
16498
|
+
],
|
|
16499
|
+
"keywords": [
|
|
16500
|
+
"Multi-tenant"
|
|
16501
|
+
],
|
|
16502
|
+
"toc": [
|
|
16503
|
+
{
|
|
16504
|
+
"id": "sec_1",
|
|
16505
|
+
"title": "Scope"
|
|
16506
|
+
},
|
|
16507
|
+
{
|
|
16508
|
+
"id": "sec_2",
|
|
16509
|
+
"title": "Activation"
|
|
16510
|
+
},
|
|
16511
|
+
{
|
|
16512
|
+
"id": "sec_3",
|
|
16513
|
+
"title": "Modèle de données"
|
|
16514
|
+
},
|
|
16515
|
+
{
|
|
16516
|
+
"id": "sec_4",
|
|
16517
|
+
"title": "Fallback de login"
|
|
16518
|
+
},
|
|
16519
|
+
{
|
|
16520
|
+
"id": "sec_5",
|
|
16521
|
+
"title": "Changement de locataire (superadmin)"
|
|
16522
|
+
},
|
|
16523
|
+
{
|
|
16524
|
+
"id": "sec_6",
|
|
16525
|
+
"title": "Caches scoped par locataire"
|
|
16526
|
+
},
|
|
16527
|
+
{
|
|
16528
|
+
"id": "sec_7",
|
|
16529
|
+
"title": "Anti-hijack"
|
|
16530
|
+
},
|
|
16531
|
+
{
|
|
16532
|
+
"id": "sec_8",
|
|
16533
|
+
"title": "Scheduler tenant-aware"
|
|
16534
|
+
},
|
|
16535
|
+
{
|
|
16536
|
+
"id": "sec_9",
|
|
16537
|
+
"title": "Propagation du scaffolding cross-locataire"
|
|
16538
|
+
},
|
|
16539
|
+
{
|
|
16540
|
+
"id": "sec_10",
|
|
16541
|
+
"title": "Comptes utilisateur non-superadmin sur plusieurs locataires"
|
|
16542
|
+
},
|
|
16543
|
+
{
|
|
16544
|
+
"id": "sec_11",
|
|
16545
|
+
"title": "Limitations connues"
|
|
16546
|
+
},
|
|
16547
|
+
{
|
|
16548
|
+
"id": "sec_12",
|
|
16549
|
+
"title": "AppSettings pertinents"
|
|
16550
|
+
}
|
|
16551
|
+
],
|
|
16552
|
+
"sections": [
|
|
16553
|
+
{
|
|
16554
|
+
"id": "overview",
|
|
16555
|
+
"title": "Overview",
|
|
16556
|
+
"html": "<h1>Multi-tenant</h1><p>Routage des connexions par utilisateur : chaque locataire dispose de ses propres chaînes de connexion vers des bases de métadonnées et de données distinctes, en préservant une compatibilité ascendante totale pour les installations mono-locataire existantes.</p><h2>Scope</h2><p>Le module multi-tenant permet à un backend unique de servir plusieurs locataires — chacun sur des bases métadonnées et données distinctes — sans dupliquer le code ni démarrer des instances séparées. Quand le flag est OFF le comportement est identique au pré-multi-tenant (aucun overhead, caches inchangés). Quand il est ON le framework :</p><ul><li>résout à l'exécution la bonne chaîne de connexion selon l'<code>id_azienda</code> de l'utilisateur connecté.</li><li>segrège les caches serveur (<code>Application[]</code>) et client (Dexie + localStorage) par locataire.</li><li>propage <code>TenantScope.Current</code> sur toutes les requêtes, le scheduler et les jobs en arrière-plan.</li></ul><h2>Activation</h2><p>Dans <code>appsettings.json</code> (section <code>AppSettings</code>) :</p><ul><li><code>"multiConnectionEnabled": "true"</code> — active le routage tenant-aware dans <code>MultiTenantHelpers</code> + middleware <code>TenantScope</code> dans <code>Startup.cs</code>.</li><li><code>"enableCookieAuthentication": "true"</code> — requis par le flux multi-tenant : le cookie <code>k-user</code> v:2 transporte <code>azienda_id</code> + <code>azienda_id_user</code> pour l'anti-hijack.</li></ul><p>Les modèles autonomes <code>appsettings.multi-tenant.mssql.json</code> et <code>appsettings.multi-tenant.mysql.json</code> sont disponibles (6 chaînes de connexion : 2 par défaut + 4 tenant <code>Tenant1_Meta</code>/<code>Tenant1_Data</code>/<code>Tenant2_Meta</code>/<code>Tenant2_Data</code>).</p><h2>Modèle de données</h2><p>Le mapping <code>id_azienda → (data, meta)</code> se trouve sur deux colonnes de la table <code>Aziende</code> de la base métadonnées primaire :</p><ul><li><code>Aziende.Connessione_DB_Dati</code> : NOM d'une entrée dans <code>appsettings.ConnectionStrings</code> pour la base données du locataire (PAS la chaîne de connexion littérale).</li><li><code>Aziende.CONNESSIONE_DB_Meta</code> : NOM d'une entrée dans <code>appsettings.ConnectionStrings</code> pour la base métadonnées du locataire.</li></ul><p>Exemple de peuplement :</p><ul><li><code>Aziende.ID_Azienda=1</code>, <code>Connessione_DB_Dati='Tenant1_Data'</code>, <code>CONNESSIONE_DB_Meta='Tenant1_Meta'</code>.</li><li><code>Aziende.ID_Azienda=100</code> (Sede principale), <code>Connessione_DB_Dati='DataSQLConnection'</code>, <code>CONNESSIONE_DB_Meta='MetaDataSQLConnection'</code> (alias par défaut → couvert par scope 0).</li></ul><p>Indirection par nom d'entrée :</p><ul><li>aucun mot de passe en clair dans la base.</li><li>identifiants portables par environnement (même base, différents <code>appsettings.<env>.json</code>).</li><li>cohérent avec <code>_metadati__tabelle.md_conn_name</code>.</li></ul><h2>Fallback de login</h2><p>Quand un utilisateur N'EXISTE PAS dans la base métadonnées primaire, le login fait un fallback via <code>_login_index</code> :</p><p>1. lookup utilisateur sur <code>MetaDataSQLConnection</code> (primaire) → trouvé ? login.</p><p>2. miss → requête <code>_login_index</code> (<code>SHA-256(LOWER(username))</code> → <code>id_azienda</code>).</p><p>3. pour chaque <code>id_azienda</code> candidat : push <code>TenantScope</code>, retry <code>metaQuery.login</code> sur la base métadonnées du locataire.</p><p>4. premier match gagne → cookie émis avec l'<code>azienda_id</code> du locataire.</p><p>La procédure stockée <code>sp__login_index_upsert(@username, @idAzienda)</code> peuple l'index. Hash SHA-256 calculé côté serveur (aligné avec le client C# <code>MultiTenantHelpers.ComputeUsernameHash</code> en UTF-8).</p><h2>Changement de locataire (superadmin)</h2><p>Les superadmins peuvent changer le locataire actif à l'exécution via <code>MetaService.switchAzienda(idAzienda)</code> :</p><ul><li>401 si pas superadmin.</li><li>400 si <code>idAzienda</code> n'a pas les deux colonnes <code>Aziende.Connessione_DB_Dati</code> / <code>CONNESSIONE_DB_Meta</code> renseignées.</li><li>réémet le cookie avec <code>azienda_id</code> mis à jour, en préservant <code>azienda_id_user</code> (snapshot au login).</li><li>le composant Angular <code><wuic-azienda-switcher></code> expose l'UI dropdown. Se masque automatiquement via <code>@HostBinding('attr.hidden')</code> pour les utilisateurs non-super ou si flag OFF.</li></ul><h2>Caches scoped par locataire</h2><p>Côté serveur (clés <code>Application[]</code>) :</p><ul><li><code>MultiTenantHelpers.TenantKey(baseKey)</code> ajoute le suffixe <code>__a<id></code> quand flag ON et <code>TenantScope.Current.AziendaId > 0</code>.</li><li>clés concernées : <code>userList</code>, <code>roleList</code>, <code>userRoleList</code>, <code>companyList</code>, <code>storedMeta</code>, <code>storedTableMeta</code>, <code>storedTableActionMeta</code>, <code>storedConditionGroupMeta</code>, <code>storedTableStyleMeta</code>, <code>SysInfo</code>.</li></ul><p>Côté client (lib framework Angular) :</p><ul><li>nom Dexie → <code>MetaDB__a<id></code> et <code>WuicClientSideCrudDB__a<id></code>.</li><li>clés localStorage → <code>menu_<userId>__a<id></code>, <code>wuic_custom_settings_<userId>__a<id></code>, etc.</li><li>cleanup orphans à l'init de <code>MetadataProviderService</code> : énumère <code>indexedDB.databases()</code> et supprime les bases des locataires non courants.</li></ul><p>Quand flag OFF : TenantKey retourne la baseKey inchangée → caches identiques au pré-multi-tenant.</p><h2>Anti-hijack</h2><p><code>RawHelpers.authenticate()</code> à chaque requête :</p><ul><li>exige un cookie v:2 avec <code>azienda_id</code> + <code>azienda_id_user</code> (cookie legacy → relogin forcé).</li><li>pour les utilisateurs non-super, <code>cookie.azienda_id</code> DOIT correspondre à <code>cookie.azienda_id_user</code> (manipulation → relogin + log de sécurité).</li><li>les superadmins sont exemptés (leur <code>azienda_id</code> change légitimement via <code>switchAzienda</code>).</li><li>peuple <code>TenantScope.Current</code> avec <code>(azienda_id, isSuperAdmin)</code> pour le reste de la requête.</li></ul><p>Le middleware <code>HydrateLegacyPrincipalFromKUserCookie</code> dans <code>Startup.cs</code> peuple aussi <code>TenantScope</code> pour les endpoints AsmxProxy qui n'appellent pas <code>authenticate()</code> explicitement (par ex. <code>readCustomSettings</code>, <code>getMenuByUserID</code>).</p><h2>Scheduler tenant-aware</h2><p><code>SchedulerHostedService</code> énumère les locataires à chaque cycle (<code>Scheduler:PollSeconds</code>, défaut 15s) :</p><ul><li>scope 0 (primaire / no-tenant) — toujours traité.</li><li>plus un scope par locataire avec mapping conn ≠ par défaut.</li><li>locataires mappés sur les entrées par défaut (<code>MetaDataSQLConnection</code>) sont skippés (couverts par scope 0).</li></ul><p>Les 4 types d'action sont supportés par locataire :</p><ul><li><code>1 | sql</code> : connexion données via routage tenant-aware.</li><li><code>2 | webservice</code> : utilisateur <code>__scheduler</code> créé dans chaque base locataire ; cookie session par locataire via appel HTTP <code>switchAzienda</code>.</li><li><code>3 | assembly method</code> : <code>TenantScope.Current</code> propagé via AsyncLocal à la méthode invoquée.</li><li><code>4 | mailing</code> : <code>_mailing_lists</code> / <code>_mail_recipients</code> lus depuis la base du locataire courant ; paramètres SMTP overridables par locataire via convention de nommage <code><key>__a<id></code> (par ex. <code>email-host__a1</code>).</li></ul><p>Le schéma scheduler (<code>scheduler</code>, <code>scheduler_execution</code>) est validé par locataire : locataires sans les tables sont skippés silencieusement jusqu'au redémarrage.</p><h2>Propagation du scaffolding cross-locataire</h2><p>Quand un superadmin scaffold une table ou une vue sur un locataire, par défaut les métadonnées (<code>_metadati__tabelle</code> + <code>_metadati__colonne</code>) sont écrites <strong>uniquement sur la base de métadonnées du locataire courant</strong>. Pour éviter de répéter manuellement le scaffolding sur chaque locataire, l'endpoint <code>scaffolding.scaffoldTable</code> / <code>scaffolding.scaffoldView</code> expose le paramètre booléen <code>propagateToTenants</code> :</p><ul><li><code>false</code> (par défaut) : scaffold uniquement sur le locataire courant (rétro-compat).</li><li><code>true</code> : après avoir scaffold le locataire courant, le backend itère sur tous les autres locataires actifs (<code>MultiTenantHelpers.GetAziendePrimary()</code>) et réplique le scaffolding sur la base métadonnées de chacun. Le schéma est lu depuis la base DONNÉES du locataire cible résolue via <code>Aziende.Connessione_DB_Dati</code>. Locataires sans la table physique sont skippés avec issue <code>SKIPPED:not-found</code> → opération best-effort, non transactionnelle cross-locataire.</li></ul><p>Sécurité :</p><ul><li>restreint au superadmin avec flag <code>multiConnectionEnabled=true</code> — pour les autres le paramètre est silencieusement ignoré.</li><li>entreprises mappées aux entrées par défaut (<code>MetaDataSQLConnection</code>) sont skippées (couvertes par le scope 0 du locataire courant si le superadmin y est déjà).</li></ul><p>Le résultat du scaffold inclut des clés <code>tenant_<id>:</code> avec valeur <code>OK</code> / <code>SKIPPED:<reason></code> / <code>FAILED:<message></code> pour le diagnostic par-locataire.</p><p>UI : le dialog de scaffolding (<code>md_id=1556</code>) expose une checkbox <strong>Propagate to all tenants</strong> dans le formulaire ; l'action <code>Scaffold Table</code> envoie le flag au backend. En mode mono-locataire (<code>multiConnectionEnabled=false</code>) le backend filtre la colonne de la réponse <code>getTableMetadata</code> → la checkbox n'apparaît pas dans le formulaire (aucune UI inutile à gérer).</p><h2>Comptes utilisateur non-superadmin sur plusieurs locataires</h2><p>Le sélecteur <code><wuic-azienda-switcher></code> est visible <strong>uniquement aux superadmins</strong>. Pour les utilisateurs non-superadmin, le contrôle anti-hijack (<code>cookie.azienda_id != user.azienda_id_user → reject</code>) empêche de changer de locataire à l'exécution : le cookie a <code>azienda_id_user</code> capturé au login et immuable.</p><p>Convention opérationnelle pour les scénarios où la même personne doit accéder à plusieurs locataires en tant qu'utilisateur standard :</p><ul><li><strong>créer un utilisateur distinct par locataire</strong>, avec un <strong>nom d'utilisateur différent</strong> (par ex. <code>mario.rossi@T1</code>, <code>mario.rossi@T2</code>, ou <code>mario.rossi.t1</code> / <code>mario.rossi.t2</code>).</li><li>chaque utilisateur vit dans <code>utenti</code> de sa propre base locataire, avec <code>id_azienda</code> correspondant.</li><li><code>_login_index</code> mappe chaque nom d'utilisateur à son <code>id_azienda</code> : aucune ambiguïté dans la fallback chain.</li><li>l'utilisateur choisit quel locataire utiliser en changeant le nom d'utilisateur au login.</li></ul><p>Anti-pattern à éviter :</p><ul><li><strong>même nom d'utilisateur sur deux locataires</strong> : le login fallback essaie les deux candidats <code>_login_index</code> par ordre croissant de <code>id_azienda</code> ; le premier qui valide le mot de passe gagne. L'utilisateur arrive toujours sur le locataire avec l'<code>id_azienda</code> le plus bas et ne peut pas atteindre l'autre sans le widget switcher. Le mot de passe reste partagé cross-locataire, exposé au brute-force sur les deux bases.</li></ul><p>Si la personne est superadmin, un utilisateur unique sur la base primaire (<code>id_azienda</code> = id de l'entreprise mère) suffit : le widget switcher couvre le changement de locataire à l'exécution.</p><h2>Limitations connues</h2><ul><li><strong>SGBD uniforme cross-locataire</strong> : tous les locataires DOIVENT partager le même SGBD que le primaire (<code>AppSettings.dbms</code> / <code>meta-dbms</code>). Le routage tenant-aware n'instradera que le <em>catalog</em> (quelle base), pas <em>quel provider</em>. Exemple : backend MSSQL + un <code>Tenant2_Meta</code> avec une connection string MySQL → <code>SqlException</code> au premier accès au locataire. Une validation fail-soft (WARN log) est exécutée au démarrage (<code>MultiTenantHelpers.ValidateTenantConnectionStrings</code>) : pour chaque cs référencée par <code>Aziende.{Connessione_DB_Dati,CONNESSIONE_DB_Meta}</code> détecte heuristiquement le SGBD cible (mots-clés comme <code>Port=3306</code>, <code>Initial Catalog=</code>, <code>(HOST=...)</code>, etc.) et logge un warning si différent de celui configuré. Mismatch → le boot continue mais le locataire désaligné sera inutilisable à l'exécution.</li><li>l'utilisateur <code>__scheduler</code> créé sur les bases locataires a <code>id_azienda=NULL</code> (non renseigné) : adéquat pour le flux webservice (le login part toujours du primaire) mais non résolvable via <code>_login_index</code> pour les cas inverses.</li><li>chaque nouveau locataire requiert une entrée dans <code>appsettings.ConnectionStrings</code> + redéploiement/reload config.</li><li>les jobs scheduler dans les locataires sans table <code>scheduler</code> sont skippés silencieusement (info-log au premier poll).</li></ul><h2>AppSettings pertinents</h2><p>Snippet (clés multi-tenant) :</p><ul><li><code>"AppSettings.multiConnectionEnabled": "true"</code></li><li><code>"AppSettings.enableCookieAuthentication": "true"</code></li><li><code>"ConnectionStrings.MetaDataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.DataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Data": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Data": "..."</code></li><li>(optionnel) <code>"AppSettings.email-host__a1": "smtp.tenant1.example"</code> — override SMTP pour le locataire 1.</li></ul>",
|
|
16557
|
+
"codeSamples": []
|
|
16558
|
+
}
|
|
16559
|
+
],
|
|
16560
|
+
"images": []
|
|
16561
|
+
},
|
|
16318
16562
|
{
|
|
16319
16563
|
"slug": "troubleshooting",
|
|
16320
16564
|
"lang": "fr-FR",
|
|
@@ -19692,7 +19936,7 @@ const frameworkDocsContent = {
|
|
|
19692
19936
|
{
|
|
19693
19937
|
"id": "overview",
|
|
19694
19938
|
"title": "Overview",
|
|
19695
|
-
"html": "<h1>Component Catalog</h1><p>Total component folders detected:
|
|
19939
|
+
"html": "<h1>Component Catalog</h1><p>Total component folders detected: 50.</p><h2>Available components</h2><ul><li>app-settings-editor</li><li>archetype-configurator</li><li>azienda-switcher</li><li>bounded-repeater</li><li>carousel-list</li><li>chart-list</li><li>code-editor</li><li>css-sheet-editor</li><li>dashboard</li><li>data-action-button</li><li>data-repeater</li><li>data-source</li><li>designer</li><li>dynamic-dashboard-template</li><li>dynamic-field-template</li><li>dynamic-form-template</li><li>dynamic-generic-template</li><li>dynamic-repeater-template</li><li>dynamic-template</li><li>error-dialog</li><li>field</li><li>filter-bar</li><li>first-run-wizard</li><li>framework-docs</li><li>image-wrapper</li><li>import-export-button</li><li>kanban-list</li><li>list-grid</li><li>map-list</li><li>meta-menu</li><li>metadata-editor</li><li>notification-bell</li><li>pager</li><li>parametric-dialog</li><li>pivot-builder</li><li>prompt-dialog-fallback-host</li><li>rag-chatbot</li><li>rag-chatbot-fab</li><li>report-designer</li><li>report-viewer</li><li>scheduler-list</li><li>split</li><li>split-area</li><li>spreadsheet-list-sf</li><li>tab-panel-wrapper</li><li>tab-view-wrapper</li><li>tree-list</li><li>upload-dialog</li><li>workflow-designer</li><li>workflow-runner</li></ul><h2>Exports from public-api</h2><ul><li>data-source/data-source.component</li><li>azienda-switcher/azienda-switcher.component</li><li>list-grid/list-grid.component</li><li>import-export-button/import-export-button.component</li><li>pager/pager.component</li><li>filter-bar/filter-bar.component</li><li>map-list/map-list.component</li><li>carousel-list/carousel-list.component</li><li>tree-list/tree-list.component</li><li>parametric-dialog/parametric-dialog.component</li><li>upload-dialog/upload-dialog.component</li><li>app-settings-editor/app-settings-editor.component</li><li>data-action-button/data-action-button.component</li><li>meta-menu/meta-menu.component</li><li>field/text-editor/text-editor.component</li><li>field/text-area-editor/text-area-editor.component</li><li>field/number-editor/number-editor.component</li><li>field/lookup-editor/lookup-editor.component</li><li>field/dictionary-editor/dictionary-editor.component</li><li>field/boolean-editor/boolean-editor.component</li><li>field/date-editor/date-editor.component</li><li>field/html-editor/html-editor.component</li><li>field/upload-editor/upload-editor.component</li><li>field/button-editor/button-editor.component</li><li>field/field-editor/field-editor.component</li><li>field/code-area-editor/code-area-editor.component</li><li>dynamic-template/dynamic-template.component</li><li>image-wrapper/image-wrapper.component</li><li>field/color-editor/color-editor.component</li><li>field/property-array-editor/property-array-editor.component</li><li>field/property-object-editor/property-object-editor.component</li><li>field/tree-view-selector/tree-view-selector.component</li><li>workflow-designer/workflow-designer.component</li><li>workflow-runner/workflow-runner.component</li><li>notification-bell/notification-bell.component</li><li>bounded-repeater/bounded-repeater.component</li><li>framework-docs/framework-docs.component</li><li>designer/designer.route.component</li><li>workflow-designer/workflow-designer.route.component</li><li>kanban-list/kanban-list.component</li><li>data-repeater/data-repeater.component</li><li>bounded-repeater/bounded-repeater-pending-changes.guard</li><li>rag-chatbot/rag-chatbot.component</li><li>rag-chatbot-fab/rag-chatbot-fab.component</li><li>error-dialog/error-dialog.component</li></ul>",
|
|
19696
19940
|
"codeSamples": []
|
|
19697
19941
|
}
|
|
19698
19942
|
],
|
|
@@ -21439,6 +21683,82 @@ const frameworkDocsContent = {
|
|
|
21439
21683
|
],
|
|
21440
21684
|
"images": []
|
|
21441
21685
|
},
|
|
21686
|
+
{
|
|
21687
|
+
"slug": "multi-tenant",
|
|
21688
|
+
"lang": "es-ES",
|
|
21689
|
+
"title": "Multi-tenant",
|
|
21690
|
+
"description": "Multi-tenant - documentazione operativa e tecnica",
|
|
21691
|
+
"audience": [
|
|
21692
|
+
"developer",
|
|
21693
|
+
"integration"
|
|
21694
|
+
],
|
|
21695
|
+
"status": "stable",
|
|
21696
|
+
"tags": [
|
|
21697
|
+
"advanced"
|
|
21698
|
+
],
|
|
21699
|
+
"keywords": [
|
|
21700
|
+
"Multi-tenant"
|
|
21701
|
+
],
|
|
21702
|
+
"toc": [
|
|
21703
|
+
{
|
|
21704
|
+
"id": "sec_1",
|
|
21705
|
+
"title": "Scope"
|
|
21706
|
+
},
|
|
21707
|
+
{
|
|
21708
|
+
"id": "sec_2",
|
|
21709
|
+
"title": "Activación"
|
|
21710
|
+
},
|
|
21711
|
+
{
|
|
21712
|
+
"id": "sec_3",
|
|
21713
|
+
"title": "Modelo de datos"
|
|
21714
|
+
},
|
|
21715
|
+
{
|
|
21716
|
+
"id": "sec_4",
|
|
21717
|
+
"title": "Fallback de login"
|
|
21718
|
+
},
|
|
21719
|
+
{
|
|
21720
|
+
"id": "sec_5",
|
|
21721
|
+
"title": "Cambio de inquilino (superadmin)"
|
|
21722
|
+
},
|
|
21723
|
+
{
|
|
21724
|
+
"id": "sec_6",
|
|
21725
|
+
"title": "Cachés tenant-scoped"
|
|
21726
|
+
},
|
|
21727
|
+
{
|
|
21728
|
+
"id": "sec_7",
|
|
21729
|
+
"title": "Anti-hijack"
|
|
21730
|
+
},
|
|
21731
|
+
{
|
|
21732
|
+
"id": "sec_8",
|
|
21733
|
+
"title": "Scheduler tenant-aware"
|
|
21734
|
+
},
|
|
21735
|
+
{
|
|
21736
|
+
"id": "sec_9",
|
|
21737
|
+
"title": "Propagación del scaffolding cross-inquilino"
|
|
21738
|
+
},
|
|
21739
|
+
{
|
|
21740
|
+
"id": "sec_10",
|
|
21741
|
+
"title": "Cuentas de usuario no-superadmin a través de varios inquilinos"
|
|
21742
|
+
},
|
|
21743
|
+
{
|
|
21744
|
+
"id": "sec_11",
|
|
21745
|
+
"title": "Limitaciones conocidas"
|
|
21746
|
+
},
|
|
21747
|
+
{
|
|
21748
|
+
"id": "sec_12",
|
|
21749
|
+
"title": "AppSettings relevantes"
|
|
21750
|
+
}
|
|
21751
|
+
],
|
|
21752
|
+
"sections": [
|
|
21753
|
+
{
|
|
21754
|
+
"id": "overview",
|
|
21755
|
+
"title": "Overview",
|
|
21756
|
+
"html": "<h1>Multi-tenant</h1><p>Enrutamiento de conexiones por usuario: cada inquilino tiene sus propias cadenas de conexión hacia bases de metadatos y datos separadas, preservando la compatibilidad total hacia atrás para las instalaciones mono-inquilino existentes.</p><h2>Scope</h2><p>El módulo multi-tenant permite que un único backend sirva a varios inquilinos — cada uno en bases de metadatos y datos distintas — sin duplicar código ni arrancar instancias separadas. Cuando el flag está OFF el comportamiento es idéntico al pre-multi-tenant (sin overhead, cachés sin cambios). Cuando está ON el framework:</p><ul><li>resuelve en tiempo de ejecución la cadena de conexión correcta según el <code>id_azienda</code> del usuario autenticado.</li><li>segrega las cachés del servidor (<code>Application[]</code>) y del cliente (Dexie + localStorage) por inquilino.</li><li>propaga <code>TenantScope.Current</code> en todas las consultas, scheduler y trabajos en segundo plano.</li></ul><h2>Activación</h2><p>En <code>appsettings.json</code> (sección <code>AppSettings</code>):</p><ul><li><code>"multiConnectionEnabled": "true"</code> — activa el enrutamiento tenant-aware en <code>MultiTenantHelpers</code> + middleware <code>TenantScope</code> en <code>Startup.cs</code>.</li><li><code>"enableCookieAuthentication": "true"</code> — requerido por el flujo multi-tenant: la cookie <code>k-user</code> v:2 lleva <code>azienda_id</code> + <code>azienda_id_user</code> para anti-hijack.</li></ul><p>Están disponibles las plantillas stand-alone <code>appsettings.multi-tenant.mssql.json</code> y <code>appsettings.multi-tenant.mysql.json</code> (6 cadenas de conexión: 2 default + 4 tenant <code>Tenant1_Meta</code>/<code>Tenant1_Data</code>/<code>Tenant2_Meta</code>/<code>Tenant2_Data</code>).</p><h2>Modelo de datos</h2><p>El mapeo <code>id_azienda → (data, meta)</code> vive en dos columnas de la tabla <code>Aziende</code> de la base de metadatos primaria:</p><ul><li><code>Aziende.Connessione_DB_Dati</code>: NOMBRE de una entrada en <code>appsettings.ConnectionStrings</code> para la base de datos del inquilino (NO la cadena de conexión literal).</li><li><code>Aziende.CONNESSIONE_DB_Meta</code>: NOMBRE de una entrada en <code>appsettings.ConnectionStrings</code> para la base de metadatos del inquilino.</li></ul><p>Ejemplo de poblamiento:</p><ul><li><code>Aziende.ID_Azienda=1</code>, <code>Connessione_DB_Dati='Tenant1_Data'</code>, <code>CONNESSIONE_DB_Meta='Tenant1_Meta'</code>.</li><li><code>Aziende.ID_Azienda=100</code> (Sede principale), <code>Connessione_DB_Dati='DataSQLConnection'</code>, <code>CONNESSIONE_DB_Meta='MetaDataSQLConnection'</code> (aliases default → cubierto por scope 0).</li></ul><p>Indirección por nombre de entrada:</p><ul><li>ninguna contraseña en claro en la base.</li><li>credenciales portables por entorno (misma base, distintos <code>appsettings.<env>.json</code>).</li><li>coherente con <code>_metadati__tabelle.md_conn_name</code>.</li></ul><h2>Fallback de login</h2><p>Cuando un usuario NO existe en la base de metadatos primaria, el login hace fallback vía <code>_login_index</code>:</p><p>1. lookup usuario en <code>MetaDataSQLConnection</code> (primaria) → encontrado? login.</p><p>2. miss → consulta <code>_login_index</code> (<code>SHA-256(LOWER(username))</code> → <code>id_azienda</code>).</p><p>3. para cada <code>id_azienda</code> candidato: push <code>TenantScope</code>, reintenta <code>metaQuery.login</code> en la base de metadatos del inquilino.</p><p>4. primer match gana → cookie emitida con el <code>azienda_id</code> del inquilino.</p><p>El procedimiento <code>sp__login_index_upsert(@username, @idAzienda)</code> pobla el índice. Hash SHA-256 calculado del lado servidor (alineado con el cliente C# <code>MultiTenantHelpers.ComputeUsernameHash</code> en UTF-8).</p><h2>Cambio de inquilino (superadmin)</h2><p>Los superadmins pueden cambiar el inquilino activo en tiempo de ejecución vía <code>MetaService.switchAzienda(idAzienda)</code>:</p><ul><li>401 si no es superadmin.</li><li>400 si el <code>idAzienda</code> no tiene ambas columnas <code>Aziende.Connessione_DB_Dati</code> / <code>CONNESSIONE_DB_Meta</code> rellenas.</li><li>reemite cookie con <code>azienda_id</code> actualizado, preservando <code>azienda_id_user</code> (snapshot al login).</li><li>el componente Angular <code><wuic-azienda-switcher></code> expone el dropdown UI. Se auto-oculta vía <code>@HostBinding('attr.hidden')</code> para usuarios no-super o flag OFF.</li></ul><h2>Cachés tenant-scoped</h2><p>Lado servidor (claves <code>Application[]</code>):</p><ul><li><code>MultiTenantHelpers.TenantKey(baseKey)</code> añade el sufijo <code>__a<id></code> cuando el flag está ON y <code>TenantScope.Current.AziendaId > 0</code>.</li><li>claves afectadas: <code>userList</code>, <code>roleList</code>, <code>userRoleList</code>, <code>companyList</code>, <code>storedMeta</code>, <code>storedTableMeta</code>, <code>storedTableActionMeta</code>, <code>storedConditionGroupMeta</code>, <code>storedTableStyleMeta</code>, <code>SysInfo</code>.</li></ul><p>Lado cliente (lib framework Angular):</p><ul><li>nombre Dexie → <code>MetaDB__a<id></code> y <code>WuicClientSideCrudDB__a<id></code>.</li><li>claves localStorage → <code>menu_<userId>__a<id></code>, <code>wuic_custom_settings_<userId>__a<id></code>, etc.</li><li>limpieza huérfanos al init de <code>MetadataProviderService</code>: enumera <code>indexedDB.databases()</code> y elimina las DB de inquilinos ya no corrientes.</li></ul><p>Cuando flag OFF: TenantKey retorna la baseKey sin cambios → cachés idénticos al pre-multi-tenant.</p><h2>Anti-hijack</h2><p><code>RawHelpers.authenticate()</code> en cada petición:</p><ul><li>requiere cookie v:2 con <code>azienda_id</code> + <code>azienda_id_user</code> (cookie legacy → relogin forzado).</li><li>para usuarios no-super, <code>cookie.azienda_id</code> DEBE coincidir con <code>cookie.azienda_id_user</code> (manipulación → relogin + log de seguridad).</li><li>los superadmins están exentos (su <code>azienda_id</code> cambia legítimamente vía <code>switchAzienda</code>).</li><li>pobla <code>TenantScope.Current</code> con <code>(azienda_id, isSuperAdmin)</code> para el resto de la petición.</li></ul><p>El middleware <code>HydrateLegacyPrincipalFromKUserCookie</code> en <code>Startup.cs</code> también pobla <code>TenantScope</code> para endpoints AsmxProxy que no llaman a <code>authenticate()</code> explícitamente (por ej. <code>readCustomSettings</code>, <code>getMenuByUserID</code>).</p><h2>Scheduler tenant-aware</h2><p><code>SchedulerHostedService</code> enumera los inquilinos en cada ciclo (<code>Scheduler:PollSeconds</code>, default 15s):</p><ul><li>scope 0 (primario / sin-tenant) — siempre procesado.</li><li>más un scope por cada inquilino con mapping conn ≠ default.</li><li>inquilinos mapeados a las entradas default (<code>MetaDataSQLConnection</code>) son saltados (cubiertos por scope 0).</li></ul><p>Los 4 tipos de acción se soportan por inquilino:</p><ul><li><code>1 | sql</code>: conexión datos vía enrutamiento tenant-aware.</li><li><code>2 | webservice</code>: usuario <code>__scheduler</code> creado en cada base inquilino; cookie session por inquilino vía llamada HTTP <code>switchAzienda</code>.</li><li><code>3 | assembly method</code>: <code>TenantScope.Current</code> propagado vía AsyncLocal al método invocado.</li><li><code>4 | mailing</code>: <code>_mailing_lists</code> / <code>_mail_recipients</code> leídos desde la base del inquilino corriente; ajustes SMTP overridables por inquilino vía convención de naming <code><key>__a<id></code> (por ej. <code>email-host__a1</code>).</li></ul><p>El esquema scheduler (<code>scheduler</code>, <code>scheduler_execution</code>) se valida por inquilino: inquilinos sin las tablas se saltan silenciosamente hasta el restart.</p><h2>Propagación del scaffolding cross-inquilino</h2><p>Cuando un superadmin hace scaffolding de una tabla o vista en un inquilino, por defecto los metadata (<code>_metadati__tabelle</code> + <code>_metadati__colonne</code>) se escriben <strong>solo en la base de metadata del inquilino corriente</strong>. Para evitar repetir manualmente el scaffolding en cada inquilino, el endpoint <code>scaffolding.scaffoldTable</code> / <code>scaffolding.scaffoldView</code> expone el parámetro booleano <code>propagateToTenants</code>:</p><ul><li><code>false</code> (por defecto): scaffold solo sobre el inquilino corriente (back-compat).</li><li><code>true</code>: después de scaffold del inquilino corriente, el backend itera sobre todos los demás inquilinos activos (<code>MultiTenantHelpers.GetAziendePrimary()</code>) y replica el scaffolding sobre la base de metadata de cada uno. El esquema se lee desde la base DATOS del inquilino destino resuelta vía <code>Aziende.Connessione_DB_Dati</code>. Inquilinos sin la tabla física son skippados con resultado <code>SKIPPED:not-found</code> → operación best-effort, no transaccional cross-inquilino.</li></ul><p>Seguridad:</p><ul><li>restringido a superadmin con flag <code>multiConnectionEnabled=true</code> — para los demás el parámetro se ignora silenciosamente.</li><li>empresas mapeadas a las entradas default (<code>MetaDataSQLConnection</code>) son skippadas (cubiertas por scope 0 del inquilino corriente si el superadmin ya está ahí).</li></ul><p>El resultado del scaffold incluye claves <code>tenant_<id>:</code> con valor <code>OK</code> / <code>SKIPPED:<reason></code> / <code>FAILED:<message></code> para diagnosis por-inquilino.</p><p>UI: el dialog de scaffolding (<code>md_id=1556</code>) expone una checkbox <strong>Propagate to all tenants</strong> en el form; la action <code>Scaffold Table</code> envía el flag al backend. En modo mono-inquilino (<code>multiConnectionEnabled=false</code>) el backend filtra la columna de la respuesta <code>getTableMetadata</code> → la checkbox no aparece en el form (ninguna UI inútil que gestionar).</p><h2>Cuentas de usuario no-superadmin a través de varios inquilinos</h2><p>El selector <code><wuic-azienda-switcher></code> es visible <strong>solo para superadmins</strong>. Para los usuarios no-superadmin el chequeo anti-hijack (<code>cookie.azienda_id != user.azienda_id_user → reject</code>) impide cambiar de inquilino en tiempo de ejecución: la cookie lleva <code>azienda_id_user</code> capturado en el login e inmutable.</p><p>Convención operativa para escenarios en los que la misma persona necesita acceder a varios inquilinos como usuario estándar:</p><ul><li><strong>crear un usuario separado por inquilino</strong>, con un <strong>username distinto</strong> (por ej. <code>mario.rossi@T1</code>, <code>mario.rossi@T2</code>, o <code>mario.rossi.t1</code> / <code>mario.rossi.t2</code>).</li><li>cada usuario vive en <code>utenti</code> de su propia base inquilino, con <code>id_azienda</code> correspondiente.</li><li><code>_login_index</code> mapea cada username a su <code>id_azienda</code>: ninguna ambigüedad en la fallback chain.</li><li>el usuario elige a qué inquilino acceder cambiando el username en el login.</li></ul><p>Anti-pattern a evitar:</p><ul><li><strong>mismo username en dos inquilinos</strong>: el login fallback intenta ambos candidatos <code>_login_index</code> en orden ascendente de <code>id_azienda</code>; el primero que valida la contraseña gana. El usuario aterriza siempre en el inquilino con <code>id_azienda</code> más bajo y no puede alcanzar el otro sin el widget switcher. La contraseña queda compartida cross-inquilino, expuesta a brute-force sobre ambas bases.</li></ul><p>Si la persona es superadmin, un único usuario en la base primaria (<code>id_azienda</code> = id de la empresa madre) es suficiente: el widget switcher cubre el cambio de inquilino en tiempo de ejecución.</p><h2>Limitaciones conocidas</h2><ul><li><strong>DBMS uniforme cross-inquilino</strong>: todos los inquilinos DEBEN compartir el mismo DBMS que el primario (<code>AppSettings.dbms</code> / <code>meta-dbms</code>). El routing tenant-aware solo enruta el <em>catalog</em> (qué base), no <em>qué provider</em>. Ejemplo: backend MSSQL + un <code>Tenant2_Meta</code> con connection string MySQL → <code>SqlException</code> en el primer acceso al inquilino. Una validación fail-soft (WARN log) se ejecuta al arranque (<code>MultiTenantHelpers.ValidateTenantConnectionStrings</code>): para cada cs referenciada por <code>Aziende.{Connessione_DB_Dati,CONNESSIONE_DB_Meta}</code> detecta heurísticamente el DBMS objetivo (palabras clave como <code>Port=3306</code>, <code>Initial Catalog=</code>, <code>(HOST=...)</code>, etc.) y loggea un warning si difiere del configurado. Mismatch → el boot continúa pero el inquilino desalineado será inutilizable en runtime.</li><li>el usuario <code>__scheduler</code> creado en las bases inquilino tiene <code>id_azienda=NULL</code> (no rellenado): adecuado para el flujo webservice (el login siempre parte del primario) pero no resolvible vía <code>_login_index</code> para casos inversos.</li><li>cada nuevo inquilino requiere una entrada en <code>appsettings.ConnectionStrings</code> + redeploy/reload config.</li><li>los jobs scheduler en inquilinos sin tabla <code>scheduler</code> se saltan silenciosamente (info-log al primer poll).</li></ul><h2>AppSettings relevantes</h2><p>Snippet (claves multi-tenant):</p><ul><li><code>"AppSettings.multiConnectionEnabled": "true"</code></li><li><code>"AppSettings.enableCookieAuthentication": "true"</code></li><li><code>"ConnectionStrings.MetaDataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.DataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Data": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Data": "..."</code></li><li>(opcional) <code>"AppSettings.email-host__a1": "smtp.tenant1.example"</code> — override SMTP para tenant 1.</li></ul>",
|
|
21757
|
+
"codeSamples": []
|
|
21758
|
+
}
|
|
21759
|
+
],
|
|
21760
|
+
"images": []
|
|
21761
|
+
},
|
|
21442
21762
|
{
|
|
21443
21763
|
"slug": "troubleshooting",
|
|
21444
21764
|
"lang": "es-ES",
|
|
@@ -24816,7 +25136,7 @@ const frameworkDocsContent = {
|
|
|
24816
25136
|
{
|
|
24817
25137
|
"id": "overview",
|
|
24818
25138
|
"title": "Overview",
|
|
24819
|
-
"html": "<h1>Component Catalog</h1><p>Total component folders detected:
|
|
25139
|
+
"html": "<h1>Component Catalog</h1><p>Total component folders detected: 50.</p><h2>Available components</h2><ul><li>app-settings-editor</li><li>archetype-configurator</li><li>azienda-switcher</li><li>bounded-repeater</li><li>carousel-list</li><li>chart-list</li><li>code-editor</li><li>css-sheet-editor</li><li>dashboard</li><li>data-action-button</li><li>data-repeater</li><li>data-source</li><li>designer</li><li>dynamic-dashboard-template</li><li>dynamic-field-template</li><li>dynamic-form-template</li><li>dynamic-generic-template</li><li>dynamic-repeater-template</li><li>dynamic-template</li><li>error-dialog</li><li>field</li><li>filter-bar</li><li>first-run-wizard</li><li>framework-docs</li><li>image-wrapper</li><li>import-export-button</li><li>kanban-list</li><li>list-grid</li><li>map-list</li><li>meta-menu</li><li>metadata-editor</li><li>notification-bell</li><li>pager</li><li>parametric-dialog</li><li>pivot-builder</li><li>prompt-dialog-fallback-host</li><li>rag-chatbot</li><li>rag-chatbot-fab</li><li>report-designer</li><li>report-viewer</li><li>scheduler-list</li><li>split</li><li>split-area</li><li>spreadsheet-list-sf</li><li>tab-panel-wrapper</li><li>tab-view-wrapper</li><li>tree-list</li><li>upload-dialog</li><li>workflow-designer</li><li>workflow-runner</li></ul><h2>Exports from public-api</h2><ul><li>data-source/data-source.component</li><li>azienda-switcher/azienda-switcher.component</li><li>list-grid/list-grid.component</li><li>import-export-button/import-export-button.component</li><li>pager/pager.component</li><li>filter-bar/filter-bar.component</li><li>map-list/map-list.component</li><li>carousel-list/carousel-list.component</li><li>tree-list/tree-list.component</li><li>parametric-dialog/parametric-dialog.component</li><li>upload-dialog/upload-dialog.component</li><li>app-settings-editor/app-settings-editor.component</li><li>data-action-button/data-action-button.component</li><li>meta-menu/meta-menu.component</li><li>field/text-editor/text-editor.component</li><li>field/text-area-editor/text-area-editor.component</li><li>field/number-editor/number-editor.component</li><li>field/lookup-editor/lookup-editor.component</li><li>field/dictionary-editor/dictionary-editor.component</li><li>field/boolean-editor/boolean-editor.component</li><li>field/date-editor/date-editor.component</li><li>field/html-editor/html-editor.component</li><li>field/upload-editor/upload-editor.component</li><li>field/button-editor/button-editor.component</li><li>field/field-editor/field-editor.component</li><li>field/code-area-editor/code-area-editor.component</li><li>dynamic-template/dynamic-template.component</li><li>image-wrapper/image-wrapper.component</li><li>field/color-editor/color-editor.component</li><li>field/property-array-editor/property-array-editor.component</li><li>field/property-object-editor/property-object-editor.component</li><li>field/tree-view-selector/tree-view-selector.component</li><li>workflow-designer/workflow-designer.component</li><li>workflow-runner/workflow-runner.component</li><li>notification-bell/notification-bell.component</li><li>bounded-repeater/bounded-repeater.component</li><li>framework-docs/framework-docs.component</li><li>designer/designer.route.component</li><li>workflow-designer/workflow-designer.route.component</li><li>kanban-list/kanban-list.component</li><li>data-repeater/data-repeater.component</li><li>bounded-repeater/bounded-repeater-pending-changes.guard</li><li>rag-chatbot/rag-chatbot.component</li><li>rag-chatbot-fab/rag-chatbot-fab.component</li><li>error-dialog/error-dialog.component</li></ul>",
|
|
24820
25140
|
"codeSamples": []
|
|
24821
25141
|
}
|
|
24822
25142
|
],
|
|
@@ -26563,6 +26883,82 @@ const frameworkDocsContent = {
|
|
|
26563
26883
|
],
|
|
26564
26884
|
"images": []
|
|
26565
26885
|
},
|
|
26886
|
+
{
|
|
26887
|
+
"slug": "multi-tenant",
|
|
26888
|
+
"lang": "de-DE",
|
|
26889
|
+
"title": "Mandantenfaehigkeit",
|
|
26890
|
+
"description": "Mandantenfaehigkeit - documentazione operativa e tecnica",
|
|
26891
|
+
"audience": [
|
|
26892
|
+
"developer",
|
|
26893
|
+
"integration"
|
|
26894
|
+
],
|
|
26895
|
+
"status": "stable",
|
|
26896
|
+
"tags": [
|
|
26897
|
+
"advanced"
|
|
26898
|
+
],
|
|
26899
|
+
"keywords": [
|
|
26900
|
+
"Mandantenfaehigkeit"
|
|
26901
|
+
],
|
|
26902
|
+
"toc": [
|
|
26903
|
+
{
|
|
26904
|
+
"id": "sec_1",
|
|
26905
|
+
"title": "Geltungsbereich"
|
|
26906
|
+
},
|
|
26907
|
+
{
|
|
26908
|
+
"id": "sec_2",
|
|
26909
|
+
"title": "Aktivierung"
|
|
26910
|
+
},
|
|
26911
|
+
{
|
|
26912
|
+
"id": "sec_3",
|
|
26913
|
+
"title": "Datenmodell"
|
|
26914
|
+
},
|
|
26915
|
+
{
|
|
26916
|
+
"id": "sec_4",
|
|
26917
|
+
"title": "Login-Fallback"
|
|
26918
|
+
},
|
|
26919
|
+
{
|
|
26920
|
+
"id": "sec_5",
|
|
26921
|
+
"title": "Mandantenwechsel (Superadmin)"
|
|
26922
|
+
},
|
|
26923
|
+
{
|
|
26924
|
+
"id": "sec_6",
|
|
26925
|
+
"title": "Tenant-scoped Caches"
|
|
26926
|
+
},
|
|
26927
|
+
{
|
|
26928
|
+
"id": "sec_7",
|
|
26929
|
+
"title": "Anti-Hijack"
|
|
26930
|
+
},
|
|
26931
|
+
{
|
|
26932
|
+
"id": "sec_8",
|
|
26933
|
+
"title": "Tenant-aware Scheduler"
|
|
26934
|
+
},
|
|
26935
|
+
{
|
|
26936
|
+
"id": "sec_9",
|
|
26937
|
+
"title": "Cross-Mandant Scaffolding-Propagation"
|
|
26938
|
+
},
|
|
26939
|
+
{
|
|
26940
|
+
"id": "sec_10",
|
|
26941
|
+
"title": "Nicht-Superadmin Benutzerkonten ueber mehrere Mandanten"
|
|
26942
|
+
},
|
|
26943
|
+
{
|
|
26944
|
+
"id": "sec_11",
|
|
26945
|
+
"title": "Bekannte Einschraenkungen"
|
|
26946
|
+
},
|
|
26947
|
+
{
|
|
26948
|
+
"id": "sec_12",
|
|
26949
|
+
"title": "Relevante AppSettings"
|
|
26950
|
+
}
|
|
26951
|
+
],
|
|
26952
|
+
"sections": [
|
|
26953
|
+
{
|
|
26954
|
+
"id": "overview",
|
|
26955
|
+
"title": "Overview",
|
|
26956
|
+
"html": "<h1>Multi-tenant</h1><p>Verbindungsrouting pro Benutzer: jeder Mandant verfuegt ueber eigene Connection Strings zu getrennten Metadaten- und Datenbanken, bei voller Rueckwaertskompatibilitaet fuer bestehende Single-Tenant-Installationen.</p><h2>Geltungsbereich</h2><p>Das Multi-Tenant-Modul ermoeglicht es einem einzigen Backend, mehrere Mandanten zu bedienen — jeder auf eigenen Metadaten- und Datenbanken — ohne Code zu duplizieren oder separate Instanzen zu starten. Wenn das Flag OFF ist, ist das Verhalten identisch mit dem pre-Multi-Tenant (kein Overhead, Caches unveraendert). Bei ON:</p><ul><li>ermittelt das Framework zur Laufzeit die korrekte Connection String basierend auf <code>id_azienda</code> des angemeldeten Benutzers.</li><li>trennt Server-Caches (<code>Application[]</code>) und Client-Caches (Dexie + localStorage) pro Mandant.</li><li>propagiert <code>TenantScope.Current</code> ueber alle Abfragen, Scheduler und Background-Jobs.</li></ul><h2>Aktivierung</h2><p>In <code>appsettings.json</code> (Abschnitt <code>AppSettings</code>):</p><ul><li><code>"multiConnectionEnabled": "true"</code> — aktiviert das tenant-aware Routing in <code>MultiTenantHelpers</code> + <code>TenantScope</code> Middleware in <code>Startup.cs</code>.</li><li><code>"enableCookieAuthentication": "true"</code> — vom Multi-Tenant-Flow benoetigt: der <code>k-user</code> v:2 Cookie traegt <code>azienda_id</code> + <code>azienda_id_user</code> fuer Anti-Hijack.</li></ul><p>Stand-alone Vorlagen <code>appsettings.multi-tenant.mssql.json</code> und <code>appsettings.multi-tenant.mysql.json</code> sind verfuegbar (6 Connection Strings: 2 Default + 4 Tenant <code>Tenant1_Meta</code>/<code>Tenant1_Data</code>/<code>Tenant2_Meta</code>/<code>Tenant2_Data</code>).</p><h2>Datenmodell</h2><p>Das Mapping <code>id_azienda → (data, meta)</code> liegt auf zwei Spalten der Tabelle <code>Aziende</code> der primaeren Metadatenbank:</p><ul><li><code>Aziende.Connessione_DB_Dati</code>: NAME eines Eintrags in <code>appsettings.ConnectionStrings</code> fuer die Daten-DB des Mandanten (NICHT der literale Connection String).</li><li><code>Aziende.CONNESSIONE_DB_Meta</code>: NAME eines Eintrags in <code>appsettings.ConnectionStrings</code> fuer die Metadatenbank des Mandanten.</li></ul><p>Beispielbefuellung:</p><ul><li><code>Aziende.ID_Azienda=1</code>, <code>Connessione_DB_Dati='Tenant1_Data'</code>, <code>CONNESSIONE_DB_Meta='Tenant1_Meta'</code>.</li><li><code>Aziende.ID_Azienda=100</code> (Sede principale), <code>Connessione_DB_Dati='DataSQLConnection'</code>, <code>CONNESSIONE_DB_Meta='MetaDataSQLConnection'</code> (Default-Aliase → durch Scope 0 abgedeckt).</li></ul><p>Indirektion via Eintragsname:</p><ul><li>keine Klartextpasswoerter in der DB.</li><li>portable Credentials pro Umgebung (gleiche DB, unterschiedliche <code>appsettings.<env>.json</code>).</li><li>konsistent mit <code>_metadati__tabelle.md_conn_name</code>.</li></ul><h2>Login-Fallback</h2><p>Wenn ein Benutzer auf der primaeren Metadatenbank NICHT existiert, faellt der Login ueber <code>_login_index</code> zurueck:</p><p>1. Benutzer-Lookup auf <code>MetaDataSQLConnection</code> (primaer) → gefunden? Login.</p><p>2. Miss → Abfrage <code>_login_index</code> (<code>SHA-256(LOWER(username))</code> → <code>id_azienda</code>).</p><p>3. fuer jedes Kandidat-<code>id_azienda</code>: Push <code>TenantScope</code>, Retry <code>metaQuery.login</code> auf der Metadatenbank des Mandanten.</p><p>4. Erster Match gewinnt → Cookie mit Mandanten-<code>azienda_id</code> ausgestellt.</p><p>Die Stored Procedure <code>sp__login_index_upsert(@username, @idAzienda)</code> befuellt den Index. SHA-256 Hash serverseitig berechnet (abgestimmt mit C#-Client <code>MultiTenantHelpers.ComputeUsernameHash</code> in UTF-8).</p><h2>Mandantenwechsel (Superadmin)</h2><p>Superadmins koennen den aktiven Mandanten zur Laufzeit ueber <code>MetaService.switchAzienda(idAzienda)</code> aendern:</p><ul><li>401 wenn kein Superadmin.</li><li>400 wenn <code>idAzienda</code> nicht beide Spalten <code>Aziende.Connessione_DB_Dati</code> / <code>CONNESSIONE_DB_Meta</code> befuellt hat.</li><li>emittiert Cookie mit aktualisierter <code>azienda_id</code> neu, unter Beibehaltung von <code>azienda_id_user</code> (Snapshot beim Login).</li><li>Angular-Komponente <code><wuic-azienda-switcher></code> exponiert das Dropdown-UI. Versteckt sich automatisch via <code>@HostBinding('attr.hidden')</code> fuer Nicht-Super-Benutzer oder Flag OFF.</li></ul><h2>Tenant-scoped Caches</h2><p>Serverseite (<code>Application[]</code> Keys):</p><ul><li><code>MultiTenantHelpers.TenantKey(baseKey)</code> haengt Suffix <code>__a<id></code> an, wenn Flag ON und <code>TenantScope.Current.AziendaId > 0</code>.</li><li>betroffene Keys: <code>userList</code>, <code>roleList</code>, <code>userRoleList</code>, <code>companyList</code>, <code>storedMeta</code>, <code>storedTableMeta</code>, <code>storedTableActionMeta</code>, <code>storedConditionGroupMeta</code>, <code>storedTableStyleMeta</code>, <code>SysInfo</code>.</li></ul><p>Clientseite (Angular Framework Lib):</p><ul><li>Dexie-Name → <code>MetaDB__a<id></code> und <code>WuicClientSideCrudDB__a<id></code>.</li><li>localStorage Keys → <code>menu_<userId>__a<id></code>, <code>wuic_custom_settings_<userId>__a<id></code>, usw.</li><li>Orphan Cleanup beim Init von <code>MetadataProviderService</code>: zaehlt <code>indexedDB.databases()</code> auf und loescht DBs von Mandanten, die nicht mehr aktuell sind.</li></ul><p>Bei Flag OFF: TenantKey gibt baseKey unveraendert zurueck → Caches identisch zum pre-Multi-Tenant.</p><h2>Anti-Hijack</h2><p><code>RawHelpers.authenticate()</code> bei jeder Anfrage:</p><ul><li>verlangt Cookie v:2 mit <code>azienda_id</code> + <code>azienda_id_user</code> (Legacy-Cookie → erzwungenes Relogin).</li><li>fuer Nicht-Super-Benutzer MUSS <code>cookie.azienda_id</code> mit <code>cookie.azienda_id_user</code> uebereinstimmen (Manipulation → Relogin + Security Log).</li><li>Superadmins sind ausgenommen (ihr <code>azienda_id</code> aendert sich legitim via <code>switchAzienda</code>).</li><li>befuellt <code>TenantScope.Current</code> mit <code>(azienda_id, isSuperAdmin)</code> fuer den Rest des Requests.</li></ul><p>Die Middleware <code>HydrateLegacyPrincipalFromKUserCookie</code> in <code>Startup.cs</code> befuellt <code>TenantScope</code> auch fuer AsmxProxy-Endpoints, die <code>authenticate()</code> nicht explizit aufrufen (z.B. <code>readCustomSettings</code>, <code>getMenuByUserID</code>).</p><h2>Tenant-aware Scheduler</h2><p><code>SchedulerHostedService</code> zaehlt die Mandanten in jedem Zyklus auf (<code>Scheduler:PollSeconds</code>, Default 15s):</p><ul><li>Scope 0 (primaer / no-tenant) — immer verarbeitet.</li><li>plus ein Scope pro Mandant mit Conn-Mapping ≠ Default.</li><li>Mandanten, die auf die Default-Eintraege (<code>MetaDataSQLConnection</code>) gemappt sind, werden uebersprungen (durch Scope 0 abgedeckt).</li></ul><p>Alle 4 Action-Types werden pro Mandant unterstuetzt:</p><ul><li><code>1 | sql</code>: Datenverbindung via tenant-aware Routing.</li><li><code>2 | webservice</code>: Benutzer <code>__scheduler</code> in jeder Mandanten-DB erstellt; Session-Cookie pro Mandant via <code>switchAzienda</code> HTTP-Aufruf.</li><li><code>3 | assembly method</code>: <code>TenantScope.Current</code> via AsyncLocal an die aufgerufene Methode propagiert.</li><li><code>4 | mailing</code>: <code>_mailing_lists</code> / <code>_mail_recipients</code> aus der aktuellen Mandanten-DB gelesen; SMTP-Einstellungen pro Mandant ueberschreibbar via Naming-Konvention <code><key>__a<id></code> (z.B. <code>email-host__a1</code>).</li></ul><p>Das Scheduler-Schema (<code>scheduler</code>, <code>scheduler_execution</code>) wird pro Mandant validiert: Mandanten ohne die Tabellen werden bis zum Restart stillschweigend uebersprungen.</p><h2>Cross-Mandant Scaffolding-Propagation</h2><p>Wenn ein Superadmin eine Tabelle oder View auf einem Mandanten scaffolded, werden die Metadaten (<code>_metadati__tabelle</code> + <code>_metadati__colonne</code>) standardmaessig <strong>nur in die Metadatenbank des aktuellen Mandanten</strong> geschrieben. Um das manuelle Wiederholen des Scaffoldings auf jedem Mandanten zu vermeiden, exponiert der Endpoint <code>scaffolding.scaffoldTable</code> / <code>scaffolding.scaffoldView</code> den booleschen Parameter <code>propagateToTenants</code>:</p><ul><li><code>false</code> (default): scaffolding nur auf dem aktuellen Mandanten (Rueckwaertskompatibilitaet).</li><li><code>true</code>: nach dem Scaffolding des aktuellen Mandanten iteriert das Backend ueber alle anderen aktiven Mandanten (<code>MultiTenantHelpers.GetAziendePrimary()</code>) und repliziert das Scaffolding auf der Metadatenbank jedes einzelnen. Das Schema wird aus der DATEN-DB des Ziel-Mandanten gelesen, aufgeloest via <code>Aziende.Connessione_DB_Dati</code>. Mandanten ohne die physische Tabelle werden mit Ergebnis <code>SKIPPED:not-found</code> uebersprungen → best-effort Operation, nicht cross-Mandant transaktional.</li></ul><p>Sicherheit:</p><ul><li>nur fuer Superadmin mit Flag <code>multiConnectionEnabled=true</code> — fuer andere wird der Parameter stillschweigend ignoriert.</li><li>Firmen, die auf die Default-Eintraege (<code>MetaDataSQLConnection</code>) gemappt sind, werden uebersprungen (durch Scope 0 des aktuellen Mandanten abgedeckt, wenn der Superadmin schon dort ist).</li></ul><p>Das Scaffold-Ergebnis enthaelt <code>tenant_<id>:</code> Keys mit Wert <code>OK</code> / <code>SKIPPED:<reason></code> / <code>FAILED:<message></code> zur Diagnose pro Mandant.</p><p>UI: der Scaffolding-Dialog (<code>md_id=1556</code>) zeigt eine Checkbox <strong>Propagate to all tenants</strong> im Formular; die Action <code>Scaffold Table</code> sendet das Flag an das Backend. Im Single-Tenant-Modus (<code>multiConnectionEnabled=false</code>) filtert das Backend die Spalte aus der <code>getTableMetadata</code>-Antwort → die Checkbox erscheint nicht im Formular (keine unnoetige UI).</p><h2>Nicht-Superadmin Benutzerkonten ueber mehrere Mandanten</h2><p>Der Selektor <code><wuic-azienda-switcher></code> ist <strong>nur fuer Superadmins</strong> sichtbar. Fuer Nicht-Superadmin-Benutzer verhindert die Anti-Hijack-Pruefung (<code>cookie.azienda_id != user.azienda_id_user → reject</code>) den Mandantenwechsel zur Laufzeit: der Cookie haelt <code>azienda_id_user</code> zum Login-Zeitpunkt als Snapshot und unveraenderbar.</p><p>Operative Konvention fuer Szenarien, in denen dieselbe Person mehrere Mandanten als Standardbenutzer erreichen muss:</p><ul><li><strong>einen separaten Benutzer pro Mandant</strong> anlegen, mit <strong>unterschiedlichem Benutzernamen</strong> (z.B. <code>mario.rossi@T1</code>, <code>mario.rossi@T2</code>, oder <code>mario.rossi.t1</code> / <code>mario.rossi.t2</code>).</li><li>jeder Benutzer lebt in der <code>utenti</code> Tabelle der eigenen Mandanten-DB, mit passender <code>id_azienda</code>.</li><li><code>_login_index</code> bildet jeden Benutzernamen auf seine <code>id_azienda</code> ab: keine Mehrdeutigkeit in der Fallback Chain.</li><li>der Benutzer waehlt den Mandanten durch Aenderung des Benutzernamens beim Login.</li></ul><p>Zu vermeidendes Anti-Pattern:</p><ul><li><strong>gleicher Benutzername auf zwei Mandanten</strong>: der Fallback Login probiert beide <code>_login_index</code> Kandidaten in aufsteigender Reihenfolge der <code>id_azienda</code>; der erste, der das Passwort validiert, gewinnt. Der Benutzer landet immer beim Mandanten mit der niedrigsten <code>id_azienda</code> und kann den anderen ohne Switcher-Widget nicht erreichen. Das Passwort bleibt cross-tenant geteilt und ist Brute-Force auf beiden DBs ausgesetzt.</li></ul><p>Ist die Person Superadmin, reicht ein einziger Benutzer auf der primaeren DB (<code>id_azienda</code> = ID der Mutterfirma): das Switcher-Widget deckt den Mandantenwechsel zur Laufzeit ab.</p><h2>Bekannte Einschraenkungen</h2><ul><li><strong>Einheitliches DBMS cross-Mandant</strong>: alle Mandanten MUESSEN dasselbe DBMS wie das Primaere verwenden (<code>AppSettings.dbms</code> / <code>meta-dbms</code>). Das tenant-aware Routing instradiert nur das <em>Catalog</em> (welche DB), nicht <em>welchen Provider</em>. Beispiel: MSSQL Backend + ein <code>Tenant2_Meta</code> mit MySQL Connection String → <code>SqlException</code> beim ersten Zugriff auf den Mandanten. Eine fail-soft Validierung (WARN Log) laeuft beim Start (<code>MultiTenantHelpers.ValidateTenantConnectionStrings</code>): fuer jede cs, die von <code>Aziende.{Connessione_DB_Dati,CONNESSIONE_DB_Meta}</code> referenziert wird, erkennt sie heuristisch das Ziel-DBMS (Keywords wie <code>Port=3306</code>, <code>Initial Catalog=</code>, <code>(HOST=...)</code>, usw.) und loggt eine Warnung, wenn es vom konfigurierten abweicht. Mismatch → Boot fahrt fort, aber der nicht-uebereinstimmende Mandant ist zur Laufzeit unbrauchbar.</li><li>der <code>__scheduler</code> Benutzer auf Mandanten-DBs hat <code>id_azienda=NULL</code> (nicht befuellt): angemessen fuer den Webservice-Flow (Login startet immer vom Primaeren), aber nicht via <code>_login_index</code> fuer umgekehrte Faelle aufloesbar.</li><li>jeder neue Mandant erfordert einen Eintrag in <code>appsettings.ConnectionStrings</code> + Redeploy/Config-Reload.</li><li>Scheduler-Jobs in Mandanten ohne <code>scheduler</code> Tabelle werden stillschweigend uebersprungen (Info-Log beim ersten Poll).</li></ul><h2>Relevante AppSettings</h2><p>Snippet (Multi-Tenant Keys):</p><ul><li><code>"AppSettings.multiConnectionEnabled": "true"</code></li><li><code>"AppSettings.enableCookieAuthentication": "true"</code></li><li><code>"ConnectionStrings.MetaDataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.DataSQLConnection": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant1_Data": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Meta": "..."</code></li><li><code>"ConnectionStrings.Tenant2_Data": "..."</code></li><li>(optional) <code>"AppSettings.email-host__a1": "smtp.tenant1.example"</code> — SMTP-Override fuer Mandant 1.</li></ul>",
|
|
26957
|
+
"codeSamples": []
|
|
26958
|
+
}
|
|
26959
|
+
],
|
|
26960
|
+
"images": []
|
|
26961
|
+
},
|
|
26566
26962
|
{
|
|
26567
26963
|
"slug": "troubleshooting",
|
|
26568
26964
|
"lang": "de-DE",
|
|
@@ -27100,7 +27496,7 @@ class FrameworkDocsComponent {
|
|
|
27100
27496
|
return work;
|
|
27101
27497
|
}
|
|
27102
27498
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: FrameworkDocsComponent, deps: [{ token: i1.Router }, { token: i1.ActivatedRoute }], target: i0.ɵɵFactoryTarget.Component });
|
|
27103
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: FrameworkDocsComponent, isStandalone: true, selector: "wuic-framework-docs", viewQueries: [{ propertyName: "contentScroller", first: true, predicate: ["contentScroller"], descendants: true }], ngImport: i0, template: "<div class=\"fw-docs\" [class.dark]=\"dark()\">\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <h2>{{ 'docs.title' | translate }}</h2>\n <p>{{ 'docs.subtitle' | translate }}</p>\n </div>\n\n <div class=\"search\">\n <input pInputText type=\"text\" [ngModel]=\"query()\" (ngModelChange)=\"query.set($event || '')\" [placeholder]=\"t('docs.search_placeholder','Search feature, tag, keyword')\" />\n <button pButton type=\"button\" icon=\"pi pi-sun\" *ngIf=\"dark()\" (click)=\"toggleTheme()\"></button>\n <button pButton type=\"button\" icon=\"pi pi-moon\" *ngIf=\"!dark()\" (click)=\"toggleTheme()\"></button>\n </div>\n\n <div class=\"nav-groups\">\n @for (group of filteredGroups(); track group.id) {\n <section>\n <h3>{{ group.title }}</h3>\n <ul>\n @for (item of group.items; track item.slug) {\n <li>\n <a [class.active]=\"item.slug === currentSlug()\" [href]=\"'#/docs/' + item.slug\" (click)=\"openPage(item.slug); $event.preventDefault()\">\n <i class=\"pi\" [ngClass]=\"item.icon || 'pi-file'\"></i>\n <span>{{ item.title }}</span>\n </a>\n </li>\n }\n </ul>\n </section>\n }\n </div>\n </aside>\n\n <main class=\"content\" #contentScroller *ngIf=\"currentPage() as page\">\n <nav class=\"breadcrumbs\">{{ breadcrumbs().join(' / ') }}</nav>\n\n <header>\n <h1>{{ page.title }}</h1>\n <p>{{ page.description }}</p>\n <div class=\"tags\">\n <p-tag *ngFor=\"let tag of page.tags\" [value]=\"tag\"></p-tag>\n <p-tag\n [value]=\"page.status\"\n [severity]=\"page.status === 'beta' ? 'warn' : 'success'\"\n [pTooltip]=\"page.status === 'beta' ? 'Feature in beta: API e comportamento possono cambiare senza preavviso' : ''\"\n tooltipPosition=\"top\"></p-tag>\n <p-tag *ngIf=\"page.tier === 'pro'\" value=\"Pro only\" styleClass=\"tier-tag tier-tag-pro\"></p-tag>\n <p-tag *ngIf=\"!page.tier || page.tier === 'dev-and-pro'\" value=\"Dev & Pro\" styleClass=\"tier-tag tier-tag-base\"></p-tag>\n </div>\n </header>\n\n <div class=\"body-grid\">\n <article>\n @for (section of page.sections; track section.id) {\n <section [attr.id]=\"section.id\" class=\"doc-section\">\n <h3>{{ section.title }}</h3>\n @for (part of sectionParts(section); track $index) {\n @if (part.kind === 'html') {\n <div class=\"section-html\" [innerHTML]=\"getHighlightedHtml(part.html)\"></div>\n } @else {\n @let sampleKey = getSampleKey(page.slug, section.id, part.sample.id);\n <div class=\"code-block\" [ngClass]=\"getSnippetLanguageClass(part.sample.language)\">\n <div class=\"code-header\">\n <div class=\"code-meta\">\n <span>{{ part.sample.title }}</span>\n <span class=\"snippet-lang\" [ngClass]=\"getSnippetLanguageClass(part.sample.language)\">\n <i class=\"pi\" [ngClass]=\"getSnippetLanguageIconClass(part.sample.language)\"></i>\n {{ getSnippetLanguageLabel(part.sample.language) }}\n </span>\n </div>\n <div class=\"code-actions\">\n @if (isSampleCollapsible(part.sample.code)) {\n <button\n pButton\n type=\"button\"\n size=\"small\"\n severity=\"secondary\"\n [icon]=\"isSampleExpanded(sampleKey) ? 'pi pi-chevron-up' : 'pi pi-chevron-down'\"\n [label]=\"isSampleExpanded(sampleKey) ? t('docs.collapse','Collapse') : (t('docs.expand','Expand') + ' (+' + getCollapsedHiddenLines(part.sample.code) + ')')\"\n (click)=\"toggleSampleExpanded(sampleKey)\">\n </button>\n }\n <button pButton type=\"button\" size=\"small\" icon=\"pi pi-copy\" [label]=\"t('docs.copy','Copy')\" (click)=\"copyCode(part.sample.code)\"></button>\n </div>\n </div>\n <pre><code [innerHTML]=\"getVisibleSampleHighlightedCode(part.sample.code, sampleKey, part.sample.language)\"></code></pre>\n </div>\n }\n }\n </section>\n }\n\n @if (page.slug === 'themes') {\n <section class=\"themes-catalog\">\n <h3>{{ 'docs.themes_catalog' | translate }}</h3>\n <div class=\"themes-catalog-grid\">\n @for (theme of allThemes; track theme.value) {\n <div class=\"themes-catalog-item\">\n <span class=\"theme-dot\" [style.backgroundColor]=\"theme.primaryHex\"></span>\n <span>{{ theme.label }}</span>\n </div>\n }\n </div>\n </section>\n }\n\n @if (page.images?.length) {\n <section class=\"screenshots\">\n <h3>{{ 'docs.feature_screenshots' | translate }}</h3>\n <div class=\"shots-grid\">\n @for (img of page.images; track img.id) {\n <figure>\n <p-image\n *ngIf=\"img.status === 'available'\"\n [preview]=\"true\"\n [src]=\"img.path\"\n [previewImageSrc]=\"img.path\"\n [alt]=\"img.caption\"\n />\n <div class=\"missing\" *ngIf=\"img.status !== 'available'\">{{ 'docs.snapshot_unavailable' | translate }}</div>\n <figcaption [innerHTML]=\"getHighlightedText(formatScreenshotCaption(img.caption))\"></figcaption>\n </figure>\n }\n </div>\n </section>\n }\n </article>\n\n <aside class=\"toc\">\n <h4>{{ 'docs.on_this_page' | translate }}</h4>\n <ul>\n @for (item of page.toc; track item.id) {\n <li><a [href]=\"'#' + item.id\" (click)=\"scrollToSection($event, item.id, item.title)\">{{ item.title }}</a></li>\n }\n </ul>\n </aside>\n </div>\n </main>\n</div>\n", styles: ["@charset \"UTF-8\";.fw-docs{--bg: linear-gradient(145deg, #f7f9ff 0%, #edf3ff 40%, #fefbf4 100%);--panel: rgba(255, 255, 255, .86);--text: #18304a;--muted: #4d6278;--accent: #0d6efd;--border: #dce7fb;height:calc(100vh - 70px);display:grid;grid-template-columns:320px 1fr;background:var(--bg);color:var(--text);overflow:hidden}.fw-docs.dark,:host-context(.theme-dark) .fw-docs,:host-context(.dark-theme) .fw-docs,:host-context([data-theme-mode=dark]) .fw-docs{--bg: linear-gradient(145deg, #121b2a 0%, #0f2233 55%, #222134 100%);--panel: rgba(22, 35, 55, .85);--text: #dbe8ff;--muted: #9eb4d2;--accent: #7eb2ff;--border: #294869}.sidebar{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:var(--panel);border-right:1px solid var(--border);padding:18px;position:sticky;top:0;height:calc(100vh - 70px);overflow:auto}.brand h1{margin:0;font-size:1.1rem}.brand p{margin:6px 0 14px;color:var(--muted);font-size:.88rem}.search{display:grid;grid-template-columns:1fr auto;gap:6px;margin-bottom:12px}.nav-groups h3{margin:14px 0 8px;font-size:.86rem;text-transform:uppercase;color:var(--muted);letter-spacing:.06em}.nav-groups ul{list-style:none;padding:0;margin:0}.nav-groups a{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:10px;color:inherit;text-decoration:none;cursor:pointer}.nav-groups a.active,.nav-groups a:hover{background:#0d6efd1f}.content{padding:26px 30px 36px;height:100%;overflow-y:scroll;scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--p-content-border-color, #94a3b8) 80%,transparent) transparent;scrollbar-gutter:stable}.content::-webkit-scrollbar,.sidebar::-webkit-scrollbar{width:10px;height:10px}.content::-webkit-scrollbar-track,.sidebar::-webkit-scrollbar-track{background:transparent}.content::-webkit-scrollbar-thumb,.sidebar::-webkit-scrollbar-thumb{background-color:color-mix(in srgb,var(--p-content-border-color, #94a3b8) 65%,transparent);border-radius:6px;border:2px solid transparent;background-clip:content-box}.content::-webkit-scrollbar-thumb:hover,.sidebar::-webkit-scrollbar-thumb:hover{background-color:var(--p-text-muted-color, #64748b)}.sidebar{scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--p-content-border-color, #94a3b8) 80%,transparent) transparent}.breadcrumbs{font-size:.85rem;color:var(--muted);margin-bottom:10px}header h2{margin:0;font-size:2rem}header p{margin:8px 0 12px;color:var(--muted)}.tags{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}::ng-deep .tier-tag.p-tag{font-weight:700;letter-spacing:.02em}::ng-deep .tier-tag-base.p-tag{background:color-mix(in srgb,#6366f1 15%,var(--panel, #fff));color:color-mix(in srgb,#4338ca 85%,var(--text, #0f172a));border:1px solid color-mix(in srgb,#6366f1 45%,transparent)}::ng-deep .tier-tag-pro.p-tag{background:color-mix(in srgb,#f59e0b 18%,var(--panel, #fff));color:color-mix(in srgb,#b45309 85%,var(--text, #0f172a));border:1px solid color-mix(in srgb,#f59e0b 55%,transparent)}.body-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,360px);gap:20px}article{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:20px}.doc-section{padding:6px 0 16px;border-bottom:1px dashed var(--border)}.doc-section:last-child{border-bottom:0}.doc-section h3{margin:8px 0}.section-html{color:var(--text)}:host ::ng-deep .section-html p{line-height:1.58}:host ::ng-deep .section-html table{width:100%;border-collapse:collapse;margin:16px 0 24px;border:1px solid var(--border);border-radius:8px;overflow:hidden;font-size:.92rem;box-shadow:0 1px 2px color-mix(in srgb,var(--text) 6%,transparent)}:host ::ng-deep .section-html thead{background:color-mix(in srgb,var(--accent) 14%,var(--panel))}:host ::ng-deep .section-html thead th{text-align:left;color:var(--text);font-weight:700;padding:12px 16px;border:1px solid var(--border);border-bottom:2px solid color-mix(in srgb,var(--accent) 35%,var(--border));text-transform:uppercase;font-size:.78rem;letter-spacing:.4px;white-space:nowrap}:host ::ng-deep .section-html tbody td{padding:11px 16px;border:1px solid color-mix(in srgb,var(--border) 80%,transparent);vertical-align:top;line-height:1.55;color:var(--text)}:host ::ng-deep .section-html tbody tr:nth-child(odd) td{background:color-mix(in srgb,var(--panel) 92%,transparent)}:host ::ng-deep .section-html tbody tr:nth-child(2n) td{background:color-mix(in srgb,var(--panel) 100%,transparent)}:host ::ng-deep .section-html tbody tr:hover td{background:color-mix(in srgb,var(--accent) 8%,var(--panel))}:host ::ng-deep .section-html table code{font-size:.85em;padding:1px 5px}:host ::ng-deep .section-html strong{color:var(--text);font-weight:600}:host ::ng-deep .section-html em{font-style:italic}:host ::ng-deep .section-html a.wuic-demo-link{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;margin:0 2px;background:color-mix(in srgb,var(--accent) 14%,var(--panel));border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));border-radius:6px;color:var(--accent, #4f46e5);font-size:.85rem;font-weight:600;text-decoration:none;line-height:1.2;transition:background .15s ease}:host ::ng-deep .section-html a.wuic-demo-link:before{content:\"\\25b6\";font-size:.7rem;color:var(--accent, #4f46e5)}:host ::ng-deep .section-html a.wuic-demo-link:hover{background:color-mix(in srgb,var(--accent) 22%,var(--panel));text-decoration:none}:host ::ng-deep .section-html :where(ul,ol){margin:12px 0 16px;padding-left:0}:host ::ng-deep .section-html ul{list-style:none}:host ::ng-deep .section-html ul>li{position:relative;margin:8px 0;padding:8px 12px 8px 34px;border:1px solid color-mix(in srgb,var(--border) 78%,transparent);border-radius:10px;background:color-mix(in srgb,var(--panel) 90%,transparent);line-height:1.52}:host ::ng-deep .section-html ul>li:nth-child(odd){background:color-mix(in srgb,var(--panel) 94%,transparent)}:host ::ng-deep .section-html ul>li:nth-child(2n){background:color-mix(in srgb,var(--accent) 5%,var(--panel));border-color:color-mix(in srgb,var(--accent) 18%,var(--border))}:host ::ng-deep .section-html ul>li:before{content:\"\";position:absolute;left:12px;top:50%;width:10px;height:10px;border-radius:999px;transform:translateY(-50%);background:radial-gradient(circle at 35% 35%,#fff 0 24%,color-mix(in srgb,var(--accent) 80%,#4aa3ff) 25% 100%);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 32%,transparent)}:host ::ng-deep .section-html ol{list-style:none;counter-reset:wuic-doc-step}:host ::ng-deep .section-html ol>li{position:relative;counter-increment:wuic-doc-step;margin:8px 0;padding:8px 12px 8px 42px;border-left:3px solid color-mix(in srgb,var(--accent) 55%,transparent);background:color-mix(in srgb,var(--accent) 7%,var(--panel));border-radius:10px;line-height:1.52}:host ::ng-deep .section-html ol>li:nth-child(odd){background:color-mix(in srgb,var(--accent) 7%,var(--panel))}:host ::ng-deep .section-html ol>li:nth-child(2n){background:color-mix(in srgb,var(--accent) 11%,var(--panel));border-left-color:color-mix(in srgb,var(--accent) 72%,transparent)}:host ::ng-deep .section-html ol>li:before{content:counter(wuic-doc-step);position:absolute;left:10px;top:50%;width:22px;height:22px;border-radius:999px;transform:translateY(-50%);display:grid;place-items:center;font-size:.78rem;font-weight:700;color:#fff;background:linear-gradient(160deg,color-mix(in srgb,var(--accent) 80%,#4aa3ff),color-mix(in srgb,var(--accent) 95%,#125dcc));box-shadow:0 2px 10px color-mix(in srgb,var(--accent) 35%,transparent)}:host ::ng-deep .section-html li>:where(ul,ol){margin-top:8px}.code-block{margin:10px 0;border:1px solid var(--border);border-radius:12px;overflow:hidden}.code-header{display:flex;justify-content:space-between;align-items:center;background:#0d6efd14;padding:8px 10px}.code-block pre{margin:0;padding:12px;overflow:auto;background:#1e1e1e}.code-block code{display:block;font-family:Consolas,Cascadia Code,Fira Code,Courier New,monospace;font-size:.88rem;line-height:1.55;color:#d4d4d4;white-space:pre}.code-actions{display:inline-flex;gap:8px;align-items:center}.code-meta{display:inline-flex;align-items:center;gap:10px}.snippet-lang{display:inline-flex;align-items:center;justify-content:center;min-width:44px;height:22px;padding:0 8px;border-radius:999px;font-size:.72rem;font-weight:700;letter-spacing:.04em;border:1px solid transparent;background:color-mix(in srgb,var(--text) 8%,transparent);color:var(--text)}.snippet-lang.lang-html{background:#e36c0924;border-color:#e36c0952;color:#b25d0e}.snippet-lang.lang-json{background:#00808024;border-color:#00808052;color:#0e6b6b}.snippet-lang.lang-ts{background:#3178c624;border-color:#3178c652;color:#1f5f9f}.snippet-lang.lang-cs{background:#68217a24;border-color:#68217a52;color:#6a2b82}.snippet-lang i{font-size:.72rem}:host ::ng-deep .code-block code .tok-comment{color:#6a9955}:host ::ng-deep .code-block code .tok-string{color:#ce9178}:host ::ng-deep .code-block code .tok-number{color:#b5cea8}:host ::ng-deep .code-block code .tok-keyword{color:#569cd6}:host ::ng-deep .code-block code .tok-type{color:#4ec9b0}:host ::ng-deep .code-block code .tok-fn{color:#dcdcaa}:host ::ng-deep .code-block code .tok-prop{color:#9cdcfe}:host ::ng-deep .code-block code .tok-op{color:#d4d4d4}:host ::ng-deep .code-block.lang-html code .tok-tag{color:#569cd6}:host ::ng-deep .code-block.lang-html code .tok-attr{color:#9cdcfe}:host ::ng-deep .code-block.lang-html code .tok-string{color:#ce9178}:host ::ng-deep .code-block.lang-json code .tok-key{color:#9cdcfe}:host ::ng-deep .code-block.lang-json code .tok-string{color:#ce9178}:host ::ng-deep .code-block.lang-json code .tok-keyword{color:#569cd6}:host ::ng-deep .code-block.lang-json code .tok-number{color:#b5cea8}:host ::ng-deep .code-block.lang-ts code .tok-keyword{color:#569cd6}:host ::ng-deep .code-block.lang-ts code .tok-type{color:#4ec9b0}:host ::ng-deep .code-block.lang-ts code .tok-fn{color:#dcdcaa}:host ::ng-deep .code-block.lang-ts code .tok-prop{color:#9cdcfe}:host ::ng-deep .code-block.lang-ts code .tok-string{color:#ce9178}:host ::ng-deep .code-block.lang-ts code .tok-comment{color:#6a9955}:host ::ng-deep .code-block.lang-ts code .tok-number{color:#b5cea8}:host ::ng-deep .code-block.lang-cs code .tok-keyword{color:#569cd6}:host ::ng-deep .code-block.lang-cs code .tok-fn{color:#dcdcaa}:host ::ng-deep .code-block.lang-cs code .tok-prop{color:#9cdcfe}:host ::ng-deep .code-block.lang-cs code .tok-string{color:#ce9178}:host ::ng-deep .code-block.lang-cs code .tok-comment{color:#6a9955}:host ::ng-deep .code-block.lang-cs code .tok-number{color:#b5cea8}.screenshots .shots-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px;align-items:start}.screenshots figure{margin:0;display:flex;flex-direction:column;gap:8px}.screenshots img{width:100%;border-radius:12px;border:1px solid var(--border)}:host ::ng-deep .screenshots .p-image,:host ::ng-deep .screenshots .p-image img{width:100%;display:block}:host ::ng-deep .screenshots .p-image{aspect-ratio:16/9;overflow:hidden;border-radius:12px;border:1px solid var(--border);background:color-mix(in srgb,var(--panel) 90%,transparent)}:host ::ng-deep .screenshots .p-image img{width:100%;height:100%;object-fit:contain;object-position:center;border:0}:host ::ng-deep .screenshots .p-image .p-image-preview-mask,:host ::ng-deep .screenshots .p-image .p-image-mask{inset:0;width:100%;height:100%}.missing{border:1px dashed var(--border);border-radius:12px;padding:36px 10px;text-align:center;color:var(--muted)}:host ::ng-deep .p-image-mask,:host ::ng-deep .p-image-preview-mask{background:none!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}:host ::ng-deep .p-image-toolbar{background:#0c1220!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important;filter:none!important;border:1px solid rgba(255,255,255,.14)!important;box-shadow:0 8px 24px #00000059!important}:host ::ng-deep .p-image-action{color:#eaf1ff!important;opacity:1!important;text-shadow:none!important}:host ::ng-deep .p-image-preview{background:#7eb2ff2e!important}::ng-deep .p-image-mask.p-component-overlay,::ng-deep .p-image-preview-mask.p-component-overlay{background:#0c121efa!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}:host ::ng-deep .screenshots .p-image .p-image-mask,:host ::ng-deep .screenshots .p-image .p-image-preview-indicator{background:#0c121e42!important}.themes-catalog{margin-top:14px;padding:6px 0 16px;border-top:1px dashed var(--border)}.themes-catalog-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px}.themes-catalog-item{display:inline-flex;align-items:center;gap:.55rem;min-height:2.25rem;padding:.45rem .65rem;border:1px solid var(--border);border-radius:10px;background:color-mix(in srgb,var(--panel) 92%,transparent)}.theme-dot{width:.8rem;height:.8rem;border-radius:999px;border:1px solid color-mix(in srgb,var(--text) 25%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,#ffffff 55%,transparent) inset;flex:0 0 auto}.toc{position:sticky;top:20px;max-height:calc(100vh - 120px);overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px}.toc h4{margin:0 0 8px}.toc ul{margin:0;padding-left:18px;list-style-position:outside}.toc a{display:block;color:var(--text);text-decoration:none;line-height:1.25;overflow-wrap:anywhere;word-break:break-word}.toc li{margin:0 0 6px}.toc li:last-child{margin-bottom:0}@media(max-width:1360px)and (min-width:1101px){.body-grid{grid-template-columns:minmax(0,1fr) minmax(280px,320px)}}@media(max-width:1100px){.fw-docs{grid-template-columns:1fr;height:auto;overflow:visible}.sidebar{height:auto;position:static;border-right:0;border-bottom:1px solid var(--border)}.content{height:auto;overflow:visible}.body-grid{grid-template-columns:1fr}.toc{position:static}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "directive", type: i4.ButtonDirective, selector: "[pButton]", inputs: ["ptButtonDirective", "pButtonPT", "pButtonUnstyled", "hostName", "text", "plain", "raised", "size", "outlined", "rounded", "iconPos", "loadingIcon", "fluid", "label", "icon", "loading", "buttonProps", "severity"] }, { kind: "ngmodule", type: InputTextModule }, { kind: "directive", type: i13.InputText, selector: "[pInputText]", inputs: ["hostName", "ptInputText", "pInputTextPT", "pInputTextUnstyled", "pSize", "variant", "fluid", "invalid"] }, { kind: "ngmodule", type: TagModule }, { kind: "component", type: i10.Tag, selector: "p-tag", inputs: ["styleClass", "severity", "value", "icon", "rounded"] }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i3.Tooltip, selector: "[pTooltip]", inputs: ["tooltipPosition", "tooltipEvent", "positionStyle", "tooltipStyleClass", "tooltipZIndex", "escape", "showDelay", "hideDelay", "life", "positionTop", "positionLeft", "autoHide", "fitContent", "hideOnEscape", "showOnEllipsis", "pTooltip", "tooltipDisabled", "tooltipOptions", "appendTo", "ptTooltip", "pTooltipPT", "pTooltipUnstyled"] }, { kind: "ngmodule", type: ImageModule }, { kind: "component", type: i1$3.Image, selector: "p-image", inputs: ["imageClass", "imageStyle", "styleClass", "src", "srcSet", "sizes", "previewImageSrc", "previewImageSrcSet", "previewImageSizes", "alt", "width", "height", "loading", "preview", "showTransitionOptions", "hideTransitionOptions", "modalEnterAnimation", "modalLeaveAnimation", "appendTo", "maskMotionOptions", "motionOptions"], outputs: ["onShow", "onHide", "onImageError"] }, { kind: "ngmodule", type: TranslateModule }, { kind: "pipe", type: i8.TranslatePipe, name: "translate" }] });
|
|
27499
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: FrameworkDocsComponent, isStandalone: true, selector: "wuic-framework-docs", viewQueries: [{ propertyName: "contentScroller", first: true, predicate: ["contentScroller"], descendants: true }], ngImport: i0, template: "<div class=\"fw-docs\" [class.dark]=\"dark()\">\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <h2>{{ 'docs.title' | translate }}</h2>\n <p>{{ 'docs.subtitle' | translate }}</p>\n </div>\n\n <div class=\"search\">\n <input pInputText type=\"text\" [ngModel]=\"query()\" (ngModelChange)=\"query.set($event || '')\" [placeholder]=\"t('docs.search_placeholder','Search feature, tag, keyword')\" />\n <button pButton type=\"button\" icon=\"pi pi-sun\" *ngIf=\"dark()\" (click)=\"toggleTheme()\"></button>\n <button pButton type=\"button\" icon=\"pi pi-moon\" *ngIf=\"!dark()\" (click)=\"toggleTheme()\"></button>\n </div>\n\n <div class=\"nav-groups\">\n @for (group of filteredGroups(); track group.id) {\n <section>\n <h3>{{ group.title }}</h3>\n <ul>\n @for (item of group.items; track item.slug) {\n <li>\n <a [class.active]=\"item.slug === currentSlug()\" [href]=\"'#/docs/' + item.slug\" (click)=\"openPage(item.slug); $event.preventDefault()\">\n <i class=\"pi\" [ngClass]=\"item.icon || 'pi-file'\"></i>\n <span>{{ item.title }}</span>\n </a>\n </li>\n }\n </ul>\n </section>\n }\n </div>\n </aside>\n\n <main class=\"content\" #contentScroller *ngIf=\"currentPage() as page\">\n <nav class=\"breadcrumbs\">{{ breadcrumbs().join(' / ') }}</nav>\n\n <header>\n <h1>{{ page.title }}</h1>\n <p>{{ page.description }}</p>\n <div class=\"tags\">\n <p-tag *ngFor=\"let tag of page.tags\" [value]=\"tag\"></p-tag>\n <p-tag\n [value]=\"page.status\"\n [severity]=\"page.status === 'beta' ? 'warn' : 'success'\"\n [pTooltip]=\"page.status === 'beta' ? 'Feature in beta: API e comportamento possono cambiare senza preavviso' : ''\"\n tooltipPosition=\"top\"></p-tag>\n <p-tag *ngIf=\"page.tier === 'pro'\" value=\"Pro only\" styleClass=\"tier-tag tier-tag-pro\"></p-tag>\n <p-tag *ngIf=\"!page.tier || page.tier === 'dev-and-pro'\" value=\"Dev & Pro\" styleClass=\"tier-tag tier-tag-base\"></p-tag>\n </div>\n </header>\n\n <div class=\"body-grid\">\n <article>\n @for (section of page.sections; track section.id) {\n <section [attr.id]=\"section.id\" class=\"doc-section\">\n <h3>{{ section.title }}</h3>\n @for (part of sectionParts(section); track $index) {\n @if (part.kind === 'html') {\n <div class=\"section-html\" [innerHTML]=\"getHighlightedHtml(part.html)\"></div>\n } @else {\n @let sampleKey = getSampleKey(page.slug, section.id, part.sample.id);\n <div class=\"code-block\" [ngClass]=\"getSnippetLanguageClass(part.sample.language)\">\n <div class=\"code-header\">\n <div class=\"code-meta\">\n <span>{{ part.sample.title }}</span>\n <span class=\"snippet-lang\" [ngClass]=\"getSnippetLanguageClass(part.sample.language)\">\n <i class=\"pi\" [ngClass]=\"getSnippetLanguageIconClass(part.sample.language)\"></i>\n {{ getSnippetLanguageLabel(part.sample.language) }}\n </span>\n </div>\n <div class=\"code-actions\">\n @if (isSampleCollapsible(part.sample.code)) {\n <button\n pButton\n type=\"button\"\n size=\"small\"\n severity=\"secondary\"\n [icon]=\"isSampleExpanded(sampleKey) ? 'pi pi-chevron-up' : 'pi pi-chevron-down'\"\n [label]=\"isSampleExpanded(sampleKey) ? t('docs.collapse','Collapse') : (t('docs.expand','Expand') + ' (+' + getCollapsedHiddenLines(part.sample.code) + ')')\"\n (click)=\"toggleSampleExpanded(sampleKey)\">\n </button>\n }\n <button pButton type=\"button\" size=\"small\" icon=\"pi pi-copy\" [label]=\"t('docs.copy','Copy')\" (click)=\"copyCode(part.sample.code)\"></button>\n </div>\n </div>\n <pre><code [innerHTML]=\"getVisibleSampleHighlightedCode(part.sample.code, sampleKey, part.sample.language)\"></code></pre>\n </div>\n }\n }\n </section>\n }\n\n @if (page.slug === 'themes') {\n <section class=\"themes-catalog\">\n <h3>{{ 'docs.themes_catalog' | translate }}</h3>\n <div class=\"themes-catalog-grid\">\n @for (theme of allThemes; track theme.value) {\n <div class=\"themes-catalog-item\">\n <span class=\"theme-dot\" [style.backgroundColor]=\"theme.primaryHex\"></span>\n <span>{{ theme.label }}</span>\n </div>\n }\n </div>\n </section>\n }\n\n @if (page.images?.length) {\n <section class=\"screenshots\">\n <h3>{{ 'docs.feature_screenshots' | translate }}</h3>\n <div class=\"shots-grid\">\n @for (img of page.images; track img.id) {\n <figure>\n <p-image\n *ngIf=\"img.status === 'available'\"\n [preview]=\"true\"\n [src]=\"img.path\"\n [previewImageSrc]=\"img.path\"\n [alt]=\"img.caption\"\n />\n <div class=\"missing\" *ngIf=\"img.status !== 'available'\">{{ 'docs.snapshot_unavailable' | translate }}</div>\n <figcaption [innerHTML]=\"getHighlightedText(formatScreenshotCaption(img.caption))\"></figcaption>\n </figure>\n }\n </div>\n </section>\n }\n </article>\n\n <aside class=\"toc\">\n <h4>{{ 'docs.on_this_page' | translate }}</h4>\n <ul>\n @for (item of page.toc; track item.id) {\n <li><a [href]=\"'#' + item.id\" (click)=\"scrollToSection($event, item.id, item.title)\">{{ item.title }}</a></li>\n }\n </ul>\n </aside>\n </div>\n </main>\n</div>\n", styles: ["@charset \"UTF-8\";.fw-docs{--bg: linear-gradient(145deg, #f7f9ff 0%, #edf3ff 40%, #fefbf4 100%);--panel: rgba(255, 255, 255, .86);--text: #18304a;--muted: #4d6278;--accent: #0d6efd;--border: #dce7fb;height:calc(100vh - 70px);display:grid;grid-template-columns:320px 1fr;background:var(--bg);color:var(--text);overflow:hidden}.fw-docs.dark,:host-context(.theme-dark) .fw-docs,:host-context(.dark-theme) .fw-docs,:host-context([data-theme-mode=dark]) .fw-docs{--bg: linear-gradient(145deg, #121b2a 0%, #0f2233 55%, #222134 100%);--panel: rgba(22, 35, 55, .85);--text: #dbe8ff;--muted: #9eb4d2;--accent: #7eb2ff;--border: #294869}.sidebar{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:var(--panel);border-right:1px solid var(--border);padding:18px;position:sticky;top:0;height:calc(100vh - 70px);overflow:auto}.brand h1{margin:0;font-size:1.1rem}.brand p{margin:6px 0 14px;color:var(--muted);font-size:.88rem}.search{display:grid;grid-template-columns:1fr auto;gap:6px;margin-bottom:12px}.nav-groups h3{margin:14px 0 8px;font-size:.86rem;text-transform:uppercase;color:var(--muted);letter-spacing:.06em}.nav-groups ul{list-style:none;padding:0;margin:0}.nav-groups a{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:10px;color:inherit;text-decoration:none;cursor:pointer}.nav-groups a.active,.nav-groups a:hover{background:#0d6efd1f}.content{padding:26px 30px 36px;height:100%;overflow-y:scroll;scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--p-content-border-color, #94a3b8) 80%,transparent) transparent;scrollbar-gutter:stable}.content::-webkit-scrollbar,.sidebar::-webkit-scrollbar{width:10px;height:10px}.content::-webkit-scrollbar-track,.sidebar::-webkit-scrollbar-track{background:transparent}.content::-webkit-scrollbar-thumb,.sidebar::-webkit-scrollbar-thumb{background-color:color-mix(in srgb,var(--p-content-border-color, #94a3b8) 65%,transparent);border-radius:6px;border:2px solid transparent;background-clip:content-box}.content::-webkit-scrollbar-thumb:hover,.sidebar::-webkit-scrollbar-thumb:hover{background-color:var(--p-text-muted-color, #64748b)}.sidebar{scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--p-content-border-color, #94a3b8) 80%,transparent) transparent}.breadcrumbs{font-size:.85rem;color:var(--muted);margin-bottom:10px}header h2{margin:0;font-size:2rem}header p{margin:8px 0 12px;color:var(--muted)}.tags{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}::ng-deep .tier-tag.p-tag{font-weight:700;letter-spacing:.02em}::ng-deep .tier-tag-base.p-tag{background:color-mix(in srgb,#6366f1 15%,var(--panel, #fff));color:color-mix(in srgb,#4338ca 85%,var(--text, #0f172a));border:1px solid color-mix(in srgb,#6366f1 45%,transparent)}::ng-deep .tier-tag-pro.p-tag{background:color-mix(in srgb,#f59e0b 18%,var(--panel, #fff));color:color-mix(in srgb,#b45309 85%,var(--text, #0f172a));border:1px solid color-mix(in srgb,#f59e0b 55%,transparent)}.body-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,360px);gap:20px}article{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:20px}.doc-section{padding:6px 0 16px;border-bottom:1px dashed var(--border)}.doc-section:last-child{border-bottom:0}.doc-section h3{margin:8px 0}.section-html{color:var(--text)}:host ::ng-deep .section-html p{line-height:1.58}:host ::ng-deep .section-html table{width:100%;border-collapse:collapse;margin:16px 0 24px;border:1px solid var(--border);border-radius:8px;overflow:hidden;font-size:.92rem;box-shadow:0 1px 2px color-mix(in srgb,var(--text) 6%,transparent)}:host ::ng-deep .section-html thead{background:color-mix(in srgb,var(--accent) 14%,var(--panel))}:host ::ng-deep .section-html thead th{text-align:left;color:var(--text);font-weight:700;padding:12px 16px;border:1px solid var(--border);border-bottom:2px solid color-mix(in srgb,var(--accent) 35%,var(--border));text-transform:uppercase;font-size:.78rem;letter-spacing:.4px;white-space:nowrap}:host ::ng-deep .section-html tbody td{padding:11px 16px;border:1px solid color-mix(in srgb,var(--border) 80%,transparent);vertical-align:top;line-height:1.55;color:var(--text)}:host ::ng-deep .section-html tbody tr:nth-child(odd) td{background:color-mix(in srgb,var(--panel) 92%,transparent)}:host ::ng-deep .section-html tbody tr:nth-child(2n) td{background:color-mix(in srgb,var(--panel) 100%,transparent)}:host ::ng-deep .section-html tbody tr:hover td{background:color-mix(in srgb,var(--accent) 8%,var(--panel))}:host ::ng-deep .section-html table code{font-size:.85em;padding:1px 5px}:host ::ng-deep .section-html strong{color:var(--text);font-weight:600}:host ::ng-deep .section-html em{font-style:italic}:host ::ng-deep .section-html a.wuic-demo-link{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;margin:0 2px;background:color-mix(in srgb,var(--accent) 14%,var(--panel));border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));border-radius:6px;color:var(--accent, #4f46e5);font-size:.85rem;font-weight:600;text-decoration:none;line-height:1.2;transition:background .15s ease}:host ::ng-deep .section-html a.wuic-demo-link:before{content:\"\\25b6\";font-size:.7rem;color:var(--accent, #4f46e5)}:host ::ng-deep .section-html a.wuic-demo-link:hover{background:color-mix(in srgb,var(--accent) 22%,var(--panel));text-decoration:none}:host ::ng-deep .section-html :where(ul,ol){margin:12px 0 16px;padding-left:0}:host ::ng-deep .section-html ul{list-style:none}:host ::ng-deep .section-html ul>li{position:relative;margin:8px 0;padding:8px 12px 8px 34px;border:1px solid color-mix(in srgb,var(--border) 78%,transparent);border-radius:10px;background:color-mix(in srgb,var(--panel) 90%,transparent);line-height:1.52}:host ::ng-deep .section-html ul>li:nth-child(odd){background:color-mix(in srgb,var(--panel) 94%,transparent)}:host ::ng-deep .section-html ul>li:nth-child(2n){background:color-mix(in srgb,var(--accent) 5%,var(--panel));border-color:color-mix(in srgb,var(--accent) 18%,var(--border))}:host ::ng-deep .section-html ul>li:before{content:\"\";position:absolute;left:12px;top:50%;width:10px;height:10px;border-radius:999px;transform:translateY(-50%);background:radial-gradient(circle at 35% 35%,#fff 0 24%,color-mix(in srgb,var(--accent) 80%,#4aa3ff) 25% 100%);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 32%,transparent)}:host ::ng-deep .section-html ol{list-style:none;counter-reset:wuic-doc-step}:host ::ng-deep .section-html ol>li{position:relative;counter-increment:wuic-doc-step;margin:8px 0;padding:8px 12px 8px 42px;border-left:3px solid color-mix(in srgb,var(--accent) 55%,transparent);background:color-mix(in srgb,var(--accent) 7%,var(--panel));border-radius:10px;line-height:1.52}:host ::ng-deep .section-html ol>li:nth-child(odd){background:color-mix(in srgb,var(--accent) 7%,var(--panel))}:host ::ng-deep .section-html ol>li:nth-child(2n){background:color-mix(in srgb,var(--accent) 11%,var(--panel));border-left-color:color-mix(in srgb,var(--accent) 72%,transparent)}:host ::ng-deep .section-html ol>li:before{content:counter(wuic-doc-step);position:absolute;left:10px;top:50%;width:22px;height:22px;border-radius:999px;transform:translateY(-50%);display:grid;place-items:center;font-size:.78rem;font-weight:700;color:#fff;background:linear-gradient(160deg,color-mix(in srgb,var(--accent) 80%,#4aa3ff),color-mix(in srgb,var(--accent) 95%,#125dcc));box-shadow:0 2px 10px color-mix(in srgb,var(--accent) 35%,transparent)}:host ::ng-deep .section-html li>:where(ul,ol){margin-top:8px}.code-block{margin:10px 0;border:1px solid var(--border);border-radius:12px;overflow:hidden}.code-header{display:flex;justify-content:space-between;align-items:center;background:#0d6efd14;padding:8px 10px}.code-block pre{margin:0;padding:12px;overflow:auto;background:#1e1e1e}.code-block code{display:block;font-family:Consolas,Cascadia Code,Fira Code,Courier New,monospace;font-size:.88rem;line-height:1.55;color:#d4d4d4;white-space:pre}.code-actions{display:inline-flex;gap:8px;align-items:center}.code-meta{display:inline-flex;align-items:center;gap:10px}.snippet-lang{display:inline-flex;align-items:center;justify-content:center;min-width:44px;height:22px;padding:0 8px;border-radius:999px;font-size:.72rem;font-weight:700;letter-spacing:.04em;border:1px solid transparent;background:color-mix(in srgb,var(--text) 8%,transparent);color:var(--text)}.snippet-lang.lang-html{background:#e36c0924;border-color:#e36c0952;color:#b25d0e}.snippet-lang.lang-json{background:#00808024;border-color:#00808052;color:#0e6b6b}.snippet-lang.lang-ts{background:#3178c624;border-color:#3178c652;color:#1f5f9f}.snippet-lang.lang-cs{background:#68217a24;border-color:#68217a52;color:#6a2b82}.snippet-lang i{font-size:.72rem}:host ::ng-deep .code-block code .tok-comment{color:#6a9955}:host ::ng-deep .code-block code .tok-string{color:#ce9178}:host ::ng-deep .code-block code .tok-number{color:#b5cea8}:host ::ng-deep .code-block code .tok-keyword{color:#569cd6}:host ::ng-deep .code-block code .tok-type{color:#4ec9b0}:host ::ng-deep .code-block code .tok-fn{color:#dcdcaa}:host ::ng-deep .code-block code .tok-prop{color:#9cdcfe}:host ::ng-deep .code-block code .tok-op{color:#d4d4d4}:host ::ng-deep .code-block.lang-html code .tok-tag{color:#569cd6}:host ::ng-deep .code-block.lang-html code .tok-attr{color:#9cdcfe}:host ::ng-deep .code-block.lang-html code .tok-string{color:#ce9178}:host ::ng-deep .code-block.lang-json code .tok-key{color:#9cdcfe}:host ::ng-deep .code-block.lang-json code .tok-string{color:#ce9178}:host ::ng-deep .code-block.lang-json code .tok-keyword{color:#569cd6}:host ::ng-deep .code-block.lang-json code .tok-number{color:#b5cea8}:host ::ng-deep .code-block.lang-ts code .tok-keyword{color:#569cd6}:host ::ng-deep .code-block.lang-ts code .tok-type{color:#4ec9b0}:host ::ng-deep .code-block.lang-ts code .tok-fn{color:#dcdcaa}:host ::ng-deep .code-block.lang-ts code .tok-prop{color:#9cdcfe}:host ::ng-deep .code-block.lang-ts code .tok-string{color:#ce9178}:host ::ng-deep .code-block.lang-ts code .tok-comment{color:#6a9955}:host ::ng-deep .code-block.lang-ts code .tok-number{color:#b5cea8}:host ::ng-deep .code-block.lang-cs code .tok-keyword{color:#569cd6}:host ::ng-deep .code-block.lang-cs code .tok-fn{color:#dcdcaa}:host ::ng-deep .code-block.lang-cs code .tok-prop{color:#9cdcfe}:host ::ng-deep .code-block.lang-cs code .tok-string{color:#ce9178}:host ::ng-deep .code-block.lang-cs code .tok-comment{color:#6a9955}:host ::ng-deep .code-block.lang-cs code .tok-number{color:#b5cea8}.screenshots .shots-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px;align-items:start}.screenshots figure{margin:0;display:flex;flex-direction:column;gap:8px}.screenshots img{width:100%;border-radius:12px;border:1px solid var(--border)}:host ::ng-deep .screenshots .p-image,:host ::ng-deep .screenshots .p-image img{width:100%;display:block}:host ::ng-deep .screenshots .p-image{aspect-ratio:16/9;overflow:hidden;border-radius:12px;border:1px solid var(--border);background:color-mix(in srgb,var(--panel) 90%,transparent)}:host ::ng-deep .screenshots .p-image img{width:100%;height:100%;object-fit:contain;object-position:center;border:0}:host ::ng-deep .screenshots .p-image .p-image-preview-mask,:host ::ng-deep .screenshots .p-image .p-image-mask{inset:0;width:100%;height:100%}.missing{border:1px dashed var(--border);border-radius:12px;padding:36px 10px;text-align:center;color:var(--muted)}:host ::ng-deep .p-image-mask,:host ::ng-deep .p-image-preview-mask{background:none!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}:host ::ng-deep .p-image-toolbar{background:#0c1220!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important;filter:none!important;border:1px solid rgba(255,255,255,.14)!important;box-shadow:0 8px 24px #00000059!important}:host ::ng-deep .p-image-action{color:#eaf1ff!important;opacity:1!important;text-shadow:none!important}:host ::ng-deep .p-image-preview{background:#7eb2ff2e!important}::ng-deep .p-image-mask.p-component-overlay,::ng-deep .p-image-preview-mask.p-component-overlay{background:#0c121efa!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}:host ::ng-deep .screenshots .p-image .p-image-mask,:host ::ng-deep .screenshots .p-image .p-image-preview-indicator{background:#0c121e42!important}.themes-catalog{margin-top:14px;padding:6px 0 16px;border-top:1px dashed var(--border)}.themes-catalog-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px}.themes-catalog-item{display:inline-flex;align-items:center;gap:.55rem;min-height:2.25rem;padding:.45rem .65rem;border:1px solid var(--border);border-radius:10px;background:color-mix(in srgb,var(--panel) 92%,transparent)}.theme-dot{width:.8rem;height:.8rem;border-radius:999px;border:1px solid color-mix(in srgb,var(--text) 25%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,#ffffff 55%,transparent) inset;flex:0 0 auto}.toc{position:sticky;top:20px;max-height:calc(100vh - 120px);overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px}.toc h4{margin:0 0 8px}.toc ul{margin:0;padding-left:18px;list-style-position:outside}.toc a{display:block;color:var(--text);text-decoration:none;line-height:1.25;overflow-wrap:anywhere;word-break:break-word}.toc li{margin:0 0 6px}.toc li:last-child{margin-bottom:0}@media(max-width:1360px)and (min-width:1101px){.body-grid{grid-template-columns:minmax(0,1fr) minmax(280px,320px)}}@media(max-width:1100px){.fw-docs{grid-template-columns:1fr;height:auto;overflow:visible}.sidebar{height:auto;position:static;border-right:0;border-bottom:1px solid var(--border)}.content{height:auto;overflow:visible}.body-grid{grid-template-columns:1fr}.toc{position:static}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$2.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonModule }, { kind: "directive", type: i4.ButtonDirective, selector: "[pButton]", inputs: ["ptButtonDirective", "pButtonPT", "pButtonUnstyled", "hostName", "text", "plain", "raised", "size", "outlined", "rounded", "iconPos", "loadingIcon", "fluid", "label", "icon", "loading", "buttonProps", "severity"] }, { kind: "ngmodule", type: InputTextModule }, { kind: "directive", type: i13.InputText, selector: "[pInputText]", inputs: ["hostName", "ptInputText", "pInputTextPT", "pInputTextUnstyled", "pSize", "variant", "fluid", "invalid"] }, { kind: "ngmodule", type: TagModule }, { kind: "component", type: i10.Tag, selector: "p-tag", inputs: ["styleClass", "severity", "value", "icon", "rounded"] }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i6.Tooltip, selector: "[pTooltip]", inputs: ["tooltipPosition", "tooltipEvent", "positionStyle", "tooltipStyleClass", "tooltipZIndex", "escape", "showDelay", "hideDelay", "life", "positionTop", "positionLeft", "autoHide", "fitContent", "hideOnEscape", "showOnEllipsis", "pTooltip", "tooltipDisabled", "tooltipOptions", "appendTo", "ptTooltip", "pTooltipPT", "pTooltipUnstyled"] }, { kind: "ngmodule", type: ImageModule }, { kind: "component", type: i1$3.Image, selector: "p-image", inputs: ["imageClass", "imageStyle", "styleClass", "src", "srcSet", "sizes", "previewImageSrc", "previewImageSrcSet", "previewImageSizes", "alt", "width", "height", "loading", "preview", "showTransitionOptions", "hideTransitionOptions", "modalEnterAnimation", "modalLeaveAnimation", "appendTo", "maskMotionOptions", "motionOptions"], outputs: ["onShow", "onHide", "onImageError"] }, { kind: "ngmodule", type: TranslateModule }, { kind: "pipe", type: i8.TranslatePipe, name: "translate" }] });
|
|
27104
27500
|
}
|
|
27105
27501
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: FrameworkDocsComponent, decorators: [{
|
|
27106
27502
|
type: Component,
|
|
@@ -27111,4 +27507,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
|
|
|
27111
27507
|
}] } });
|
|
27112
27508
|
|
|
27113
27509
|
export { FrameworkDocsComponent };
|
|
27114
|
-
//# sourceMappingURL=wuic-framework-lib-framework-docs.component-
|
|
27510
|
+
//# sourceMappingURL=wuic-framework-lib-framework-docs.component-m0IlS0Sl.mjs.map
|