@ti-engine/web-framework 1.20.0 → 1.21.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/CHANGELOG.md +43 -0
- package/bin/localization/web-server-labels.json +161 -1
- package/bin/static/fragments/components/component-sidebar.html +25 -0
- package/bin/static/fragments/frame-about.html +96 -0
- package/bin/static/fragments/frame-profile.html +103 -3
- package/bin/static/scripts/ti-framework.css +139 -0
- package/bin/static/scripts/ti-framework.js +316 -0
- package/bin/web-app-manager.js +235 -2
- package/components/application-info.js +190 -0
- package/components/definitions.types.js +65 -0
- package/package.json +5 -1
- package/types/bin/web-app-manager.d.ts +95 -1
- package/types/bin/web-server.d.ts +1 -0
- package/types/components/application-info.d.ts +53 -0
- package/types/components/definitions.types.d.ts +166 -0
|
@@ -1301,6 +1301,320 @@ document.addEventListener( "htmx:responseError", ( event ) => {
|
|
|
1301
1301
|
}
|
|
1302
1302
|
} );
|
|
1303
1303
|
|
|
1304
|
+
/* ============================================================================================================
|
|
1305
|
+
Read-only "facts" screens — Profile and About.
|
|
1306
|
+
|
|
1307
|
+
Both render the same descriptor shape: an identity header plus an ordered list of titled sections, each
|
|
1308
|
+
holding label/value items. The server decides the content (see TiWebAppManager#getProfileInfo /
|
|
1309
|
+
#getApplicationInfo); everything below only shapes it for the template, so the fragments stay free of
|
|
1310
|
+
formatting logic — which is also what keeps them within Alpine's CSP expression subset.
|
|
1311
|
+
============================================================================================================ */
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Rendered in place of an item value the server left empty. Keeping it here rather than in each fragment means a
|
|
1315
|
+
* section never has to be conditionally assembled just to avoid a blank cell.
|
|
1316
|
+
*
|
|
1317
|
+
* @type {string}
|
|
1318
|
+
*/
|
|
1319
|
+
const INFO_VALUE_PLACEHOLDER = "—";
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* URL schemes an info item may link to. Descriptor content is server-authored, but an `href` becomes a live
|
|
1323
|
+
* navigation target, so the set is an allowlist rather than a `javascript:` denylist.
|
|
1324
|
+
*
|
|
1325
|
+
* @type {string[]}
|
|
1326
|
+
*/
|
|
1327
|
+
const INFO_LINK_SCHEMES = [ "http:", "https:", "mailto:" ];
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Returns the href for an info item, or an empty string when it has none or names a scheme that is not in
|
|
1331
|
+
* {@link INFO_LINK_SCHEMES}. A rejected href degrades the item to plain text rather than dropping it.
|
|
1332
|
+
*
|
|
1333
|
+
* @method
|
|
1334
|
+
* @param {Object} item
|
|
1335
|
+
* @returns {string}
|
|
1336
|
+
* @private
|
|
1337
|
+
*/
|
|
1338
|
+
const sanitizeInfoHref = ( item ) => {
|
|
1339
|
+
const href = String( ( item && item.href ) || "" ).trim();
|
|
1340
|
+
if ( !href ) {
|
|
1341
|
+
return "";
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
return INFO_LINK_SCHEMES.includes( new URL( href, window.location.origin ).protocol ) ? href : "";
|
|
1345
|
+
} catch {
|
|
1346
|
+
return "";
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Normalizes one label/value item: fills in the placeholder for an absent value, collapses the presentational
|
|
1352
|
+
* flags into a single class string, and vets any link target.
|
|
1353
|
+
*
|
|
1354
|
+
* @method
|
|
1355
|
+
* @param {Object} item
|
|
1356
|
+
* @returns {Object}
|
|
1357
|
+
* @private
|
|
1358
|
+
*/
|
|
1359
|
+
const normalizeInfoItem = ( item ) => {
|
|
1360
|
+
const source = ( item && typeof item === "object" ) ? item : {};
|
|
1361
|
+
const value = String( source.value === undefined || source.value === null ? "" : source.value ).trim();
|
|
1362
|
+
const classes = [];
|
|
1363
|
+
if ( source.mono ) classes.push( "mono" );
|
|
1364
|
+
if ( source.muted || !value ) classes.push( "muted" );
|
|
1365
|
+
|
|
1366
|
+
return {
|
|
1367
|
+
label: String( source.label || "" ),
|
|
1368
|
+
value: value || INFO_VALUE_PLACEHOLDER,
|
|
1369
|
+
valueClass: classes.join( " " ),
|
|
1370
|
+
wide: source.wide === true,
|
|
1371
|
+
href: value ? sanitizeInfoHref( source ) : ""
|
|
1372
|
+
};
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Normalizes a descriptor's section list so the template can iterate it without guarding for missing keys.
|
|
1377
|
+
* A section carrying no items at all is dropped — an empty panel is noise, not information.
|
|
1378
|
+
*
|
|
1379
|
+
* @method
|
|
1380
|
+
* @param {Array} sections
|
|
1381
|
+
* @returns {Array}
|
|
1382
|
+
* @private
|
|
1383
|
+
*/
|
|
1384
|
+
const normalizeInfoSections = ( sections ) => {
|
|
1385
|
+
return ( Array.isArray( sections ) ? sections : [] )
|
|
1386
|
+
.filter( ( section ) => section && Array.isArray( section.items ) && section.items.length > 0 )
|
|
1387
|
+
.map( ( section ) => ( {
|
|
1388
|
+
title: String( section.title || "" ),
|
|
1389
|
+
description: String( section.description || "" ),
|
|
1390
|
+
icon: String( section.icon || "info-circle" ),
|
|
1391
|
+
wide: section.wide === true,
|
|
1392
|
+
items: section.items.map( normalizeInfoItem )
|
|
1393
|
+
} ) );
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Normalizes an identity tag into a ready-to-bind pill.
|
|
1398
|
+
*
|
|
1399
|
+
* @method
|
|
1400
|
+
* @param {Object} tag
|
|
1401
|
+
* @returns {Object}
|
|
1402
|
+
* @private
|
|
1403
|
+
*/
|
|
1404
|
+
const normalizeInfoTag = ( tag ) => {
|
|
1405
|
+
const source = ( tag && typeof tag === "object" ) ? tag : {};
|
|
1406
|
+
const classes = [ source.mono ? "ti-tag mono" : "ti-status-pill" ];
|
|
1407
|
+
if ( source.tone ) classes.push( String( source.tone ) );
|
|
1408
|
+
return {
|
|
1409
|
+
text: String( source.text || "" ),
|
|
1410
|
+
dot: source.dot === true && !source.mono,
|
|
1411
|
+
pillClass: classes.join( " " )
|
|
1412
|
+
};
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Runs `load` once the application store has finished initializing — the descriptor endpoints are session-scoped,
|
|
1417
|
+
* so requesting them before `/app/config` has resolved would race the session bootstrap.
|
|
1418
|
+
*
|
|
1419
|
+
* @method
|
|
1420
|
+
* @param {Object} component The Alpine component (for `$watch`).
|
|
1421
|
+
* @param {Function} load
|
|
1422
|
+
* @private
|
|
1423
|
+
*/
|
|
1424
|
+
const loadWhenInitialized = ( component, load ) => {
|
|
1425
|
+
const tiApplication = Alpine.store( "tiApplication" );
|
|
1426
|
+
if ( tiApplication.isInitialized ) {
|
|
1427
|
+
load();
|
|
1428
|
+
} else {
|
|
1429
|
+
component.$watch( () => tiApplication.isInitialized, ( isInitialized ) => {
|
|
1430
|
+
if ( isInitialized ) {
|
|
1431
|
+
load();
|
|
1432
|
+
}
|
|
1433
|
+
} );
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Returns a configuration object for the Profile screen "frame-profile.html".
|
|
1439
|
+
* <br/>
|
|
1440
|
+
* The descriptor comes from `GET /app/profile` (JSON), which the application's web app manager builds — this
|
|
1441
|
+
* component only shapes it for the template and never decides what a profile contains.
|
|
1442
|
+
*
|
|
1443
|
+
* @method
|
|
1444
|
+
* @returns {Object}
|
|
1445
|
+
* @public
|
|
1446
|
+
*/
|
|
1447
|
+
const configureScreenProfile = () => {
|
|
1448
|
+
const tiToolbox = Alpine.store( "tiToolbox" );
|
|
1449
|
+
const tiApplication = Alpine.store( "tiApplication" );
|
|
1450
|
+
|
|
1451
|
+
return {
|
|
1452
|
+
|
|
1453
|
+
profile: null,
|
|
1454
|
+
busy: true,
|
|
1455
|
+
|
|
1456
|
+
init() {
|
|
1457
|
+
loadWhenInitialized( this, () => this.load() );
|
|
1458
|
+
},
|
|
1459
|
+
|
|
1460
|
+
load() {
|
|
1461
|
+
this.busy = true;
|
|
1462
|
+
tiApplication.sendRequest( "/app/profile" ).then( ( result ) => {
|
|
1463
|
+
const data = ( result && result.data && typeof result.data === "object" ) ? result.data : {};
|
|
1464
|
+
const identity = ( data.identity && typeof data.identity === "object" ) ? data.identity : {};
|
|
1465
|
+
this.profile = {
|
|
1466
|
+
identity: {
|
|
1467
|
+
name: String( identity.name || "" ),
|
|
1468
|
+
subtitle: String( identity.subtitle || "" ),
|
|
1469
|
+
caption: String( identity.caption || "" ),
|
|
1470
|
+
avatarSeed: String( identity.avatarSeed || identity.name || "" ),
|
|
1471
|
+
badge: identity.badge ? { text: String( identity.badge.text || "" ), tone: String( identity.badge.tone || "" ) } : null,
|
|
1472
|
+
tags: ( Array.isArray( identity.tags ) ? identity.tags : [] ).map( normalizeInfoTag )
|
|
1473
|
+
},
|
|
1474
|
+
sections: normalizeInfoSections( data.sections )
|
|
1475
|
+
};
|
|
1476
|
+
this.busy = false;
|
|
1477
|
+
} ).catch( ( error ) => {
|
|
1478
|
+
this.busy = false;
|
|
1479
|
+
if ( error && ( error.name === "AbortError" || error.isAborted ) ) return;
|
|
1480
|
+
tiApplication.notify( tiApplication.formatException( error ) );
|
|
1481
|
+
} );
|
|
1482
|
+
},
|
|
1483
|
+
|
|
1484
|
+
avatarStyle() {
|
|
1485
|
+
const identity = this.profile ? this.profile.identity : {};
|
|
1486
|
+
return tiToolbox.generateAvatarStyle( identity.avatarSeed, identity.name );
|
|
1487
|
+
},
|
|
1488
|
+
|
|
1489
|
+
avatarInitial() {
|
|
1490
|
+
const identity = this.profile ? this.profile.identity : {};
|
|
1491
|
+
return String( identity.name || "?" ).charAt( 0 ).toUpperCase();
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
};
|
|
1495
|
+
};
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Returns a configuration object for the About screen "frame-about.html".
|
|
1499
|
+
* <br/>
|
|
1500
|
+
* The server descriptor carries the application's identity as flat fields; the release, component and runtime
|
|
1501
|
+
* sections are assembled here rather than server-side, because their labels are fixed framework chrome and the
|
|
1502
|
+
* client's `getLabel` takes a fallback — which is what keeps the screen readable inside a consuming application
|
|
1503
|
+
* that loads only its own label catalogue.
|
|
1504
|
+
*
|
|
1505
|
+
* @method
|
|
1506
|
+
* @returns {Object}
|
|
1507
|
+
* @public
|
|
1508
|
+
*/
|
|
1509
|
+
const configureScreenAbout = () => {
|
|
1510
|
+
const tiToolbox = Alpine.store( "tiToolbox" );
|
|
1511
|
+
const tiApplication = Alpine.store( "tiApplication" );
|
|
1512
|
+
|
|
1513
|
+
// Runtime keys the framework itself supplies; anything else an application adds falls back to its own key.
|
|
1514
|
+
const RUNTIME_LABELS = {
|
|
1515
|
+
node: [ "interface.about.runtime-node", "Node.js" ],
|
|
1516
|
+
platform: [ "interface.about.runtime-platform", "Platform" ],
|
|
1517
|
+
application: [ "interface.about.runtime-application", "Application ID" ]
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
const label = ( key, fallback ) => tiApplication.getLabel( key, fallback );
|
|
1521
|
+
|
|
1522
|
+
return {
|
|
1523
|
+
|
|
1524
|
+
application: null,
|
|
1525
|
+
sections: [],
|
|
1526
|
+
busy: true,
|
|
1527
|
+
|
|
1528
|
+
init() {
|
|
1529
|
+
loadWhenInitialized( this, () => this.load() );
|
|
1530
|
+
},
|
|
1531
|
+
|
|
1532
|
+
load() {
|
|
1533
|
+
this.busy = true;
|
|
1534
|
+
tiApplication.sendRequest( "/app/about" ).then( ( result ) => {
|
|
1535
|
+
const data = ( result && result.data && typeof result.data === "object" ) ? result.data : {};
|
|
1536
|
+
this.application = {
|
|
1537
|
+
name: String( data.name || "" ),
|
|
1538
|
+
version: String( data.version || "" ),
|
|
1539
|
+
releaseDate: String( data.releaseDate || "" ),
|
|
1540
|
+
description: String( data.description || "" )
|
|
1541
|
+
};
|
|
1542
|
+
this.sections = normalizeInfoSections( [
|
|
1543
|
+
this._releaseSection( data ),
|
|
1544
|
+
this._componentsSection( data ),
|
|
1545
|
+
this._runtimeSection( data )
|
|
1546
|
+
].concat( Array.isArray( data.sections ) ? data.sections : [] ) );
|
|
1547
|
+
this.busy = false;
|
|
1548
|
+
} ).catch( ( error ) => {
|
|
1549
|
+
this.busy = false;
|
|
1550
|
+
if ( error && ( error.name === "AbortError" || error.isAborted ) ) return;
|
|
1551
|
+
tiApplication.notify( tiApplication.formatException( error ) );
|
|
1552
|
+
} );
|
|
1553
|
+
},
|
|
1554
|
+
|
|
1555
|
+
applicationInitial() {
|
|
1556
|
+
return String( ( this.application && this.application.name ) || "?" ).charAt( 0 ).toUpperCase();
|
|
1557
|
+
},
|
|
1558
|
+
|
|
1559
|
+
versionTag() {
|
|
1560
|
+
return "v" + ( ( this.application && this.application.version ) || "" );
|
|
1561
|
+
},
|
|
1562
|
+
|
|
1563
|
+
releaseDateText() {
|
|
1564
|
+
const released = label( "interface.about.released", "Released" );
|
|
1565
|
+
return released + " " + tiToolbox.formatDate( ( this.application && this.application.releaseDate ) || "" );
|
|
1566
|
+
},
|
|
1567
|
+
|
|
1568
|
+
_releaseSection( data ) {
|
|
1569
|
+
return {
|
|
1570
|
+
title: label( "interface.about.section-release", "Release" ),
|
|
1571
|
+
icon: "info-circle",
|
|
1572
|
+
items: [
|
|
1573
|
+
{ label: label( "interface.about.field-version", "Version" ), value: data.version, mono: true },
|
|
1574
|
+
{ label: label( "interface.about.field-release-date", "Release date" ), value: tiToolbox.formatDate( data.releaseDate ) },
|
|
1575
|
+
{ label: label( "interface.about.field-license", "License" ), value: data.license },
|
|
1576
|
+
{ label: label( "interface.about.field-author", "Author" ), value: data.author },
|
|
1577
|
+
{ label: label( "interface.about.field-package", "Package" ), value: data.packageName, mono: true, wide: true },
|
|
1578
|
+
{ label: label( "interface.about.field-homepage", "Homepage" ), value: data.homepage, href: data.homepage, wide: true }
|
|
1579
|
+
]
|
|
1580
|
+
};
|
|
1581
|
+
},
|
|
1582
|
+
|
|
1583
|
+
_componentsSection( data ) {
|
|
1584
|
+
const components = Array.isArray( data.components ) ? data.components : [];
|
|
1585
|
+
return {
|
|
1586
|
+
title: label( "interface.about.section-components", "Framework components" ),
|
|
1587
|
+
description: label( "interface.about.section-components-desc", "The ti-engine packages this application is built on." ),
|
|
1588
|
+
icon: "folder",
|
|
1589
|
+
items: components.map( ( component ) => ( {
|
|
1590
|
+
label: String( component.name || "" ),
|
|
1591
|
+
value: String( component.version || "" ),
|
|
1592
|
+
mono: true
|
|
1593
|
+
} ) )
|
|
1594
|
+
};
|
|
1595
|
+
},
|
|
1596
|
+
|
|
1597
|
+
_runtimeSection( data ) {
|
|
1598
|
+
// Present only for an admin session — the server withholds `runtime` from everyone else.
|
|
1599
|
+
const runtime = ( data.runtime && typeof data.runtime === "object" ) ? data.runtime : {};
|
|
1600
|
+
return {
|
|
1601
|
+
title: label( "interface.about.section-runtime", "Runtime" ),
|
|
1602
|
+
description: label( "interface.about.section-runtime-desc", "Visible to administrators only." ),
|
|
1603
|
+
icon: "settings",
|
|
1604
|
+
items: Object.keys( runtime ).map( ( key ) => {
|
|
1605
|
+
const known = RUNTIME_LABELS[ key ];
|
|
1606
|
+
return {
|
|
1607
|
+
label: known ? label( known[ 0 ], known[ 1 ] ) : key,
|
|
1608
|
+
value: String( runtime[ key ] || "" ),
|
|
1609
|
+
mono: true
|
|
1610
|
+
};
|
|
1611
|
+
} )
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
};
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1304
1618
|
/**
|
|
1305
1619
|
* Returns a configuration object for the login screen test user pill panel.
|
|
1306
1620
|
* <br/>
|
|
@@ -1424,4 +1738,6 @@ document.addEventListener( "alpine:init", () => {
|
|
|
1424
1738
|
Alpine.data( "tiComponentNotificationBar", configureComponentNotificationBar );
|
|
1425
1739
|
Alpine.data( "tiComponentTooltip", configureComponentTooltip );
|
|
1426
1740
|
Alpine.data( "tiLoginTestUserPanel", configureLoginTestUserPanel );
|
|
1741
|
+
Alpine.data( "tiScreenProfile", configureScreenProfile );
|
|
1742
|
+
Alpine.data( "tiScreenAbout", configureScreenAbout );
|
|
1427
1743
|
} );
|
package/bin/web-app-manager.js
CHANGED
|
@@ -14,8 +14,9 @@ const fs = require( "node:fs" );
|
|
|
14
14
|
const configRegistry = require( "#config-registry" );
|
|
15
15
|
const configService = require( "#config-service" );
|
|
16
16
|
const authorization = require( "#authorization" );
|
|
17
|
+
const applicationInfo = require( "#application-info" );
|
|
17
18
|
|
|
18
|
-
/** @import { TiSession } from "#definitions" */
|
|
19
|
+
/** @import { TiApplicationInfo, TiInfoSection, TiProfileInfo, TiSession } from "#definitions" */
|
|
19
20
|
|
|
20
21
|
const RE_NONCE_ATTR = /\{ti-nonce-placeholder}/g;
|
|
21
22
|
const RE_CSRF_ATTR = /\{ti-csrf-placeholder}/g;
|
|
@@ -124,6 +125,7 @@ class TiWebAppManager {
|
|
|
124
125
|
#staticFileCache = {};
|
|
125
126
|
#staticFileCacheEnabled;
|
|
126
127
|
#enabledAuthMethods = [];
|
|
128
|
+
#baseApplicationInfo = null;
|
|
127
129
|
|
|
128
130
|
/**
|
|
129
131
|
* @constructor
|
|
@@ -165,6 +167,10 @@ class TiWebAppManager {
|
|
|
165
167
|
title: "Profile",
|
|
166
168
|
path: "fragments/frame-profile.html"
|
|
167
169
|
};
|
|
170
|
+
this.#fragments[ 'about' ] = {
|
|
171
|
+
title: "About",
|
|
172
|
+
path: "fragments/frame-about.html"
|
|
173
|
+
};
|
|
168
174
|
this.#fragments[ 'not-found' ] = {
|
|
169
175
|
title: "Not Found",
|
|
170
176
|
path: "fragments/frame-not-found.html"
|
|
@@ -393,13 +399,20 @@ class TiWebAppManager {
|
|
|
393
399
|
* @public
|
|
394
400
|
*/
|
|
395
401
|
processDataRequest( session, view, options = {} ) {
|
|
402
|
+
if ( view === "profile" ) {
|
|
403
|
+
return this.getProfileInfo( session );
|
|
404
|
+
}
|
|
405
|
+
if ( view === "about" ) {
|
|
406
|
+
return this.getApplicationInfo( session );
|
|
407
|
+
}
|
|
396
408
|
return new Promise( ( resolve, reject ) => {
|
|
397
409
|
if ( view === "config" ) {
|
|
398
410
|
resolve( {
|
|
399
411
|
labels: localization.getAllLabels( session?.language ),
|
|
400
412
|
auth: {
|
|
401
413
|
isAuthenticated: Boolean( session && session.user )
|
|
402
|
-
}
|
|
414
|
+
},
|
|
415
|
+
componentsConfig: this.buildComponentsConfig( session )
|
|
403
416
|
} );
|
|
404
417
|
} else {
|
|
405
418
|
reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { view: view } ) );
|
|
@@ -407,6 +420,198 @@ class TiWebAppManager {
|
|
|
407
420
|
} );
|
|
408
421
|
}
|
|
409
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Returns the configuration for the shared UI components the application shell renders — currently the sidebar
|
|
425
|
+
* user flyout menu. Shipped as part of the `config` data payload and merged into the `tiComponentsConfig`
|
|
426
|
+
* Alpine store on the client.
|
|
427
|
+
* <br/>
|
|
428
|
+
* The default menu links the two screens the framework itself provides (Profile and About) plus sign-out, so a
|
|
429
|
+
* consuming application gets a working user menu without configuring one. Override to replace it; a subclass
|
|
430
|
+
* that supplies its own `componentsConfig` naturally supersedes this.
|
|
431
|
+
*
|
|
432
|
+
* @method
|
|
433
|
+
* @param {TiSession} session
|
|
434
|
+
* @returns {Object}
|
|
435
|
+
* @virtual
|
|
436
|
+
* @public
|
|
437
|
+
*/
|
|
438
|
+
buildComponentsConfig( session ) {
|
|
439
|
+
const language = session && session.language;
|
|
440
|
+
return {
|
|
441
|
+
userProfileMenu: {
|
|
442
|
+
menuTitle: localization.getLabel( "interface.topbar.user-profile", language, "Your profile" ),
|
|
443
|
+
placement: "right-end",
|
|
444
|
+
offset: 0,
|
|
445
|
+
buttonConfigs: [ {
|
|
446
|
+
title: localization.getLabel( "interface.user-menu.profile", language, "Your profile" ),
|
|
447
|
+
icon: "user-profile",
|
|
448
|
+
action: { href: "/app/profile", target: "#ti-content", swap: "innerHTML" }
|
|
449
|
+
}, {
|
|
450
|
+
title: localization.getLabel( "interface.user-menu.about", language, "About" ),
|
|
451
|
+
icon: "info-circle",
|
|
452
|
+
action: { href: "/app/about", target: "#ti-content", swap: "innerHTML" }
|
|
453
|
+
}, {
|
|
454
|
+
title: localization.getLabel( "interface.user-menu.logout", language, "Logout" ),
|
|
455
|
+
icon: "logout",
|
|
456
|
+
action: { href: "/logout", method: "post", target: "body", swap: "outerHTML" }
|
|
457
|
+
} ]
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Returns the descriptor rendered by the "Profile" screen — the identity header plus an ordered list of titled
|
|
464
|
+
* label/value sections. Every string in it is display-ready: the server resolves labels and formats values,
|
|
465
|
+
* because this is where the session language and the label catalogue are (see {@link resolveLabel}).
|
|
466
|
+
* <br/>
|
|
467
|
+
* The default implementation reports what the framework itself knows about the session user — name, username,
|
|
468
|
+
* e-mail, language and roles. Override in subclasses to show application-owned data instead; the screen, its
|
|
469
|
+
* Alpine component and its styling are inherited unchanged, so an override only decides the content.
|
|
470
|
+
* <br/>
|
|
471
|
+
* NOTE: The descriptor is always about the SESSION user. There is deliberately no "whose profile" parameter —
|
|
472
|
+
* viewing another person's record belongs to an application screen that carries its own scoping rules.
|
|
473
|
+
*
|
|
474
|
+
* @method
|
|
475
|
+
* @param {TiSession} session
|
|
476
|
+
* @returns {Promise<TiProfileInfo>}
|
|
477
|
+
* @exception {TiException.E_SEC_UNAUTHORIZED_ACCESS} (401) When the session carries no user.
|
|
478
|
+
* @virtual
|
|
479
|
+
* @public
|
|
480
|
+
*/
|
|
481
|
+
getProfileInfo( session ) {
|
|
482
|
+
return new Promise( ( resolve, reject ) => {
|
|
483
|
+
const user = session && session.user;
|
|
484
|
+
if ( !user ) {
|
|
485
|
+
return reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
|
|
486
|
+
}
|
|
487
|
+
resolve( {
|
|
488
|
+
identity: this.buildSessionIdentity( session ),
|
|
489
|
+
sections: this.buildAccountSections( session )
|
|
490
|
+
} );
|
|
491
|
+
} );
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Returns the descriptor rendered by the "About" screen — the application's own identity (name, version,
|
|
496
|
+
* release date, description, license, homepage) plus the ti-engine component versions it runs on, and any
|
|
497
|
+
* extra sections the application contributes.
|
|
498
|
+
* <br/>
|
|
499
|
+
* The baseline is resolved once from the consuming application's `package.json`, overridable through
|
|
500
|
+
* `TI_WEB_APP_NAME` / `TI_WEB_APP_VERSION` / `TI_WEB_APP_RELEASE_DATE` (see `#application-info`), and cached —
|
|
501
|
+
* the manifest cannot change while the process runs.
|
|
502
|
+
* <br/>
|
|
503
|
+
* NOTE: Runtime facts (node version, platform, instance identity) are attached only for an `admin` session.
|
|
504
|
+
* They are operational detail that helps support and means nothing to an ordinary user, so they are not handed
|
|
505
|
+
* to every signed-in visitor. Override in subclasses to append application-specific sections; call `super` and
|
|
506
|
+
* extend the result rather than rebuilding it.
|
|
507
|
+
*
|
|
508
|
+
* @method
|
|
509
|
+
* @param {TiSession} session
|
|
510
|
+
* @returns {Promise<TiApplicationInfo>}
|
|
511
|
+
* @virtual
|
|
512
|
+
* @public
|
|
513
|
+
*/
|
|
514
|
+
getApplicationInfo( session ) {
|
|
515
|
+
return new Promise( ( resolve ) => {
|
|
516
|
+
if ( !this.#baseApplicationInfo ) {
|
|
517
|
+
this.#baseApplicationInfo = applicationInfo.buildApplicationInfo( {
|
|
518
|
+
manifest: applicationInfo.readApplicationManifest(),
|
|
519
|
+
env: process.env,
|
|
520
|
+
components: TiWebAppManager.resolveFrameworkComponents()
|
|
521
|
+
} );
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const info = structuredClone( this.#baseApplicationInfo );
|
|
525
|
+
if ( authorization.hasAnyRole( session, [ authorization.ADMIN_ROLE ] ) ) {
|
|
526
|
+
info.runtime = {
|
|
527
|
+
node: process.version,
|
|
528
|
+
platform: `${ process.platform } · ${ process.arch }`,
|
|
529
|
+
application: this.#webAppIdentifier
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
resolve( info );
|
|
533
|
+
} );
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Builds the identity header of the Profile screen from the session user. Kept separate from
|
|
538
|
+
* {@link TiWebAppManager#getProfileInfo} so a subclass that replaces the sections can still reuse — or fall
|
|
539
|
+
* back to — the framework's identity block when the application has no richer identity to show.
|
|
540
|
+
*
|
|
541
|
+
* @method
|
|
542
|
+
* @param {TiSession} session
|
|
543
|
+
* @returns {Object}
|
|
544
|
+
* @public
|
|
545
|
+
*/
|
|
546
|
+
buildSessionIdentity( session ) {
|
|
547
|
+
const user = ( session && session.user ) || {};
|
|
548
|
+
return {
|
|
549
|
+
name: String( user.name || user.username || user.userID || "" ),
|
|
550
|
+
subtitle: "",
|
|
551
|
+
caption: String( user.email || "" ),
|
|
552
|
+
avatarSeed: String( user.userID || user.username || "" ),
|
|
553
|
+
// No pills by default: the framework knows roles only as opaque codes, and a stack of pills reading
|
|
554
|
+
// "1" / "2" beside the name is noise. The Access section lists them, and an application that has
|
|
555
|
+
// meaningful status to show (employment state, an ID, a badge) supplies its own.
|
|
556
|
+
tags: []
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Builds the framework's account-level Profile sections from the session user. A subclass showing richer
|
|
562
|
+
* application data can append these so the account facts remain visible alongside it.
|
|
563
|
+
*
|
|
564
|
+
* @method
|
|
565
|
+
* @param {TiSession} session
|
|
566
|
+
* @returns {TiInfoSection[]}
|
|
567
|
+
* @public
|
|
568
|
+
*/
|
|
569
|
+
buildAccountSections( session ) {
|
|
570
|
+
const user = ( session && session.user ) || {};
|
|
571
|
+
const language = session && session.language;
|
|
572
|
+
const roles = Array.isArray( user.roles ) ? user.roles : [];
|
|
573
|
+
|
|
574
|
+
return [ {
|
|
575
|
+
title: localization.getLabel( "interface.profile.section-account", language, "Account" ),
|
|
576
|
+
icon: "user",
|
|
577
|
+
items: [
|
|
578
|
+
{ label: localization.getLabel( "interface.profile.field-name", language, "Full name" ), value: String( user.name || "" ) },
|
|
579
|
+
{ label: localization.getLabel( "interface.profile.field-username", language, "Username" ), value: String( user.username || "" ) },
|
|
580
|
+
{ label: localization.getLabel( "interface.profile.field-email", language, "E-mail" ), value: String( user.email || "" ), wide: true },
|
|
581
|
+
{ label: localization.getLabel( "interface.profile.field-user-id", language, "User ID" ), value: String( user.userID || "" ), mono: true },
|
|
582
|
+
{ label: localization.getLabel( "interface.profile.field-language", language, "Language" ), value: String( user.language || language || "" ) }
|
|
583
|
+
]
|
|
584
|
+
}, {
|
|
585
|
+
title: localization.getLabel( "interface.profile.section-access", language, "Access" ),
|
|
586
|
+
icon: "check-circle",
|
|
587
|
+
items: [
|
|
588
|
+
{ label: localization.getLabel( "interface.profile.field-roles", language, "Roles" ), value: roles.join( " · " ), wide: true }
|
|
589
|
+
]
|
|
590
|
+
} ];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Resolves the versions of the ti-engine packages the running application is built on, for the About screen.
|
|
595
|
+
* <br/>
|
|
596
|
+
* NOTE: `@ti-engine/core` does not expose `./package.json` through its exports map, so its manifest is located
|
|
597
|
+
* by walking up from a module it *does* export. A package that cannot be resolved is simply omitted — an
|
|
598
|
+
* informational screen must never be the reason a request fails.
|
|
599
|
+
*
|
|
600
|
+
* @method
|
|
601
|
+
* @static
|
|
602
|
+
* @returns {Array<{name: string, version: string}>}
|
|
603
|
+
* @public
|
|
604
|
+
*/
|
|
605
|
+
static resolveFrameworkComponents() {
|
|
606
|
+
const manifests = [
|
|
607
|
+
applicationInfo.readApplicationManifest( path.join( __dirname, ".." ) ),
|
|
608
|
+
TiWebAppManager.#readManifestForModule( "@ti-engine/core/tools" )
|
|
609
|
+
];
|
|
610
|
+
return manifests
|
|
611
|
+
.filter( ( manifest ) => manifest && manifest.name && manifest.version )
|
|
612
|
+
.map( ( manifest ) => ( { name: manifest.name, version: manifest.version } ) );
|
|
613
|
+
}
|
|
614
|
+
|
|
410
615
|
/**
|
|
411
616
|
* Used to process an application service request.
|
|
412
617
|
*
|
|
@@ -452,6 +657,34 @@ class TiWebAppManager {
|
|
|
452
657
|
|
|
453
658
|
/* Private interface */
|
|
454
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Locates the `package.json` owning a resolvable module specifier by walking up from the resolved file.
|
|
662
|
+
*
|
|
663
|
+
* @method
|
|
664
|
+
* @static
|
|
665
|
+
* @param {string} moduleSpecifier
|
|
666
|
+
* @returns {Object} The manifest, or an empty object when it cannot be located.
|
|
667
|
+
*/
|
|
668
|
+
static #readManifestForModule( moduleSpecifier ) {
|
|
669
|
+
try {
|
|
670
|
+
let directory = path.dirname( require.resolve( moduleSpecifier ) );
|
|
671
|
+
for ( let depth = 0; depth < 8; depth++ ) {
|
|
672
|
+
const manifest = applicationInfo.readApplicationManifest( directory );
|
|
673
|
+
if ( manifest && manifest.name ) {
|
|
674
|
+
return manifest;
|
|
675
|
+
}
|
|
676
|
+
const parent = path.dirname( directory );
|
|
677
|
+
if ( parent === directory ) {
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
directory = parent;
|
|
681
|
+
}
|
|
682
|
+
} catch {
|
|
683
|
+
// An unresolvable package simply does not appear in the component list.
|
|
684
|
+
}
|
|
685
|
+
return {};
|
|
686
|
+
}
|
|
687
|
+
|
|
455
688
|
/**
|
|
456
689
|
* Returns the HTML fragment for the requested route.
|
|
457
690
|
*
|