@qwickapps/server 1.2.0 → 1.3.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/README.md +238 -0
- package/dist/core/control-panel.d.ts +7 -2
- package/dist/core/control-panel.d.ts.map +1 -1
- package/dist/core/control-panel.js +92 -54
- package/dist/core/control-panel.js.map +1 -1
- package/dist/core/gateway.d.ts +159 -79
- package/dist/core/gateway.d.ts.map +1 -1
- package/dist/core/gateway.js +679 -319
- package/dist/core/gateway.js.map +1 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/plugin-registry.d.ts +271 -0
- package/dist/core/plugin-registry.d.ts.map +1 -0
- package/dist/core/plugin-registry.js +326 -0
- package/dist/core/plugin-registry.js.map +1 -0
- package/dist/core/types.d.ts +16 -33
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/auth/adapters/auth0-adapter.d.ts +14 -0
- package/dist/plugins/auth/adapters/auth0-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/auth0-adapter.js +179 -0
- package/dist/plugins/auth/adapters/auth0-adapter.js.map +1 -0
- package/dist/plugins/auth/adapters/basic-adapter.d.ts +13 -0
- package/dist/plugins/auth/adapters/basic-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/basic-adapter.js +51 -0
- package/dist/plugins/auth/adapters/basic-adapter.js.map +1 -0
- package/dist/plugins/auth/adapters/index.d.ts +9 -0
- package/dist/plugins/auth/adapters/index.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/index.js +9 -0
- package/dist/plugins/auth/adapters/index.js.map +1 -0
- package/dist/plugins/auth/adapters/supabase-adapter.d.ts +13 -0
- package/dist/plugins/auth/adapters/supabase-adapter.d.ts.map +1 -0
- package/dist/plugins/auth/adapters/supabase-adapter.js +109 -0
- package/dist/plugins/auth/adapters/supabase-adapter.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.d.ts +40 -0
- package/dist/plugins/auth/auth-plugin.d.ts.map +1 -0
- package/dist/plugins/auth/auth-plugin.js +255 -0
- package/dist/plugins/auth/auth-plugin.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.test.d.ts +9 -0
- package/dist/plugins/auth/auth-plugin.test.d.ts.map +1 -0
- package/dist/plugins/auth/auth-plugin.test.js +147 -0
- package/dist/plugins/auth/auth-plugin.test.js.map +1 -0
- package/dist/plugins/auth/index.d.ts +12 -0
- package/dist/plugins/auth/index.d.ts.map +1 -0
- package/dist/plugins/auth/index.js +13 -0
- package/dist/plugins/auth/index.js.map +1 -0
- package/dist/plugins/auth/types.d.ts +148 -0
- package/dist/plugins/auth/types.d.ts.map +1 -0
- package/dist/plugins/auth/types.js +14 -0
- package/dist/plugins/auth/types.js.map +1 -0
- package/dist/plugins/bans/bans-plugin.d.ts +59 -0
- package/dist/plugins/bans/bans-plugin.d.ts.map +1 -0
- package/dist/plugins/bans/bans-plugin.js +428 -0
- package/dist/plugins/bans/bans-plugin.js.map +1 -0
- package/dist/plugins/bans/index.d.ts +9 -0
- package/dist/plugins/bans/index.d.ts.map +1 -0
- package/dist/plugins/bans/index.js +10 -0
- package/dist/plugins/bans/index.js.map +1 -0
- package/dist/plugins/bans/stores/index.d.ts +7 -0
- package/dist/plugins/bans/stores/index.d.ts.map +1 -0
- package/dist/plugins/bans/stores/index.js +7 -0
- package/dist/plugins/bans/stores/index.js.map +1 -0
- package/dist/plugins/bans/stores/postgres-store.d.ts +29 -0
- package/dist/plugins/bans/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/bans/stores/postgres-store.js +132 -0
- package/dist/plugins/bans/stores/postgres-store.js.map +1 -0
- package/dist/plugins/bans/types.d.ts +128 -0
- package/dist/plugins/bans/types.d.ts.map +1 -0
- package/dist/plugins/bans/types.js +11 -0
- package/dist/plugins/bans/types.js.map +1 -0
- package/dist/plugins/cache-plugin.d.ts +14 -3
- package/dist/plugins/cache-plugin.d.ts.map +1 -1
- package/dist/plugins/cache-plugin.js +27 -7
- package/dist/plugins/cache-plugin.js.map +1 -1
- package/dist/plugins/cache-plugin.test.js +96 -32
- package/dist/plugins/cache-plugin.test.js.map +1 -1
- package/dist/plugins/config-plugin.d.ts +3 -2
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/plugins/config-plugin.js +17 -10
- package/dist/plugins/config-plugin.js.map +1 -1
- package/dist/plugins/diagnostics-plugin.d.ts +2 -2
- package/dist/plugins/diagnostics-plugin.d.ts.map +1 -1
- package/dist/plugins/diagnostics-plugin.js +17 -10
- package/dist/plugins/diagnostics-plugin.js.map +1 -1
- package/dist/plugins/entitlements/entitlements-plugin.d.ts +95 -0
- package/dist/plugins/entitlements/entitlements-plugin.d.ts.map +1 -0
- package/dist/plugins/entitlements/entitlements-plugin.js +707 -0
- package/dist/plugins/entitlements/entitlements-plugin.js.map +1 -0
- package/dist/plugins/entitlements/index.d.ts +12 -0
- package/dist/plugins/entitlements/index.d.ts.map +1 -0
- package/dist/plugins/entitlements/index.js +16 -0
- package/dist/plugins/entitlements/index.js.map +1 -0
- package/dist/plugins/entitlements/sources/index.d.ts +9 -0
- package/dist/plugins/entitlements/sources/index.d.ts.map +1 -0
- package/dist/plugins/entitlements/sources/index.js +9 -0
- package/dist/plugins/entitlements/sources/index.js.map +1 -0
- package/dist/plugins/entitlements/sources/postgres-source.d.ts +29 -0
- package/dist/plugins/entitlements/sources/postgres-source.d.ts.map +1 -0
- package/dist/plugins/entitlements/sources/postgres-source.js +169 -0
- package/dist/plugins/entitlements/sources/postgres-source.js.map +1 -0
- package/dist/plugins/entitlements/types.d.ts +232 -0
- package/dist/plugins/entitlements/types.d.ts.map +1 -0
- package/dist/plugins/entitlements/types.js +11 -0
- package/dist/plugins/entitlements/types.js.map +1 -0
- package/dist/plugins/frontend-app-plugin.d.ts +9 -3
- package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
- package/dist/plugins/frontend-app-plugin.js +14 -9
- package/dist/plugins/frontend-app-plugin.js.map +1 -1
- package/dist/plugins/health-plugin.d.ts +5 -2
- package/dist/plugins/health-plugin.d.ts.map +1 -1
- package/dist/plugins/health-plugin.js +20 -5
- package/dist/plugins/health-plugin.js.map +1 -1
- package/dist/plugins/index.d.ts +8 -2
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +8 -2
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/logs-plugin.d.ts +3 -2
- package/dist/plugins/logs-plugin.d.ts.map +1 -1
- package/dist/plugins/logs-plugin.js +21 -12
- package/dist/plugins/logs-plugin.js.map +1 -1
- package/dist/plugins/postgres-plugin.d.ts +3 -3
- package/dist/plugins/postgres-plugin.d.ts.map +1 -1
- package/dist/plugins/postgres-plugin.js +9 -7
- package/dist/plugins/postgres-plugin.js.map +1 -1
- package/dist/plugins/postgres-plugin.test.js +47 -29
- package/dist/plugins/postgres-plugin.test.js.map +1 -1
- package/dist/plugins/users/index.d.ts +12 -0
- package/dist/plugins/users/index.d.ts.map +1 -0
- package/dist/plugins/users/index.js +13 -0
- package/dist/plugins/users/index.js.map +1 -0
- package/dist/plugins/users/stores/index.d.ts +7 -0
- package/dist/plugins/users/stores/index.d.ts.map +1 -0
- package/dist/plugins/users/stores/index.js +7 -0
- package/dist/plugins/users/stores/index.js.map +1 -0
- package/dist/plugins/users/stores/postgres-store.d.ts +28 -0
- package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/users/stores/postgres-store.js +157 -0
- package/dist/plugins/users/stores/postgres-store.js.map +1 -0
- package/dist/plugins/users/types.d.ts +189 -0
- package/dist/plugins/users/types.d.ts.map +1 -0
- package/dist/plugins/users/types.js +12 -0
- package/dist/plugins/users/types.js.map +1 -0
- package/dist/plugins/users/users-plugin.d.ts +39 -0
- package/dist/plugins/users/users-plugin.d.ts.map +1 -0
- package/dist/plugins/users/users-plugin.js +242 -0
- package/dist/plugins/users/users-plugin.js.map +1 -0
- package/dist-ui/assets/index-Bsp2ntcw.js +465 -0
- package/dist-ui/assets/index-Bsp2ntcw.js.map +1 -0
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +232 -0
- package/dist-ui-lib/components/ControlPanelApp.d.ts +61 -0
- package/dist-ui-lib/components/index.d.ts +18 -0
- package/dist-ui-lib/config/AppConfig.d.ts +7 -0
- package/dist-ui-lib/dashboard/DashboardWidgetRegistry.d.ts +62 -0
- package/dist-ui-lib/dashboard/DashboardWidgetRenderer.d.ts +8 -0
- package/dist-ui-lib/dashboard/PluginWidgetRenderer.d.ts +19 -0
- package/dist-ui-lib/dashboard/WidgetComponentRegistry.d.ts +44 -0
- package/dist-ui-lib/dashboard/builtInWidgets.d.ts +19 -0
- package/dist-ui-lib/dashboard/index.d.ts +13 -0
- package/dist-ui-lib/dashboard/widgets/ServiceHealthWidget.d.ts +12 -0
- package/dist-ui-lib/dashboard/widgets/index.d.ts +6 -0
- package/dist-ui-lib/index.js +6441 -0
- package/dist-ui-lib/index.js.map +1 -0
- package/dist-ui-lib/pages/ConfigPage.d.ts +1 -0
- package/dist-ui-lib/pages/DashboardPage.d.ts +1 -0
- package/dist-ui-lib/pages/DiagnosticsPage.d.ts +1 -0
- package/dist-ui-lib/pages/EntitlementsPage.d.ts +17 -0
- package/dist-ui-lib/pages/LogsPage.d.ts +1 -0
- package/dist-ui-lib/pages/NotFoundPage.d.ts +1 -0
- package/dist-ui-lib/pages/PluginPage.d.ts +15 -0
- package/dist-ui-lib/pages/SystemPage.d.ts +1 -0
- package/dist-ui-lib/pages/UsersPage.d.ts +22 -0
- package/package.json +18 -6
- package/src/core/control-panel.ts +114 -61
- package/src/core/gateway.ts +863 -403
- package/src/core/index.ts +21 -2
- package/src/core/plugin-registry.ts +653 -0
- package/src/core/types.ts +31 -37
- package/src/index.ts +118 -19
- package/src/plugins/auth/adapters/auth0-adapter.ts +214 -0
- package/src/plugins/auth/adapters/basic-adapter.ts +61 -0
- package/src/plugins/auth/adapters/index.ts +9 -0
- package/src/plugins/auth/adapters/supabase-adapter.ts +141 -0
- package/src/plugins/auth/auth-plugin.test.ts +176 -0
- package/src/plugins/auth/auth-plugin.ts +303 -0
- package/src/plugins/auth/index.ts +33 -0
- package/src/plugins/auth/types.ts +165 -0
- package/src/plugins/bans/bans-plugin.ts +485 -0
- package/src/plugins/bans/index.ts +31 -0
- package/src/plugins/bans/stores/index.ts +7 -0
- package/src/plugins/bans/stores/postgres-store.ts +195 -0
- package/src/plugins/bans/types.ts +141 -0
- package/src/plugins/cache-plugin.test.ts +105 -32
- package/src/plugins/cache-plugin.ts +40 -9
- package/src/plugins/config-plugin.ts +23 -12
- package/src/plugins/diagnostics-plugin.ts +22 -12
- package/src/plugins/entitlements/entitlements-plugin.ts +820 -0
- package/src/plugins/entitlements/index.ts +51 -0
- package/src/plugins/entitlements/sources/index.ts +9 -0
- package/src/plugins/entitlements/sources/postgres-source.ts +253 -0
- package/src/plugins/entitlements/types.ts +256 -0
- package/src/plugins/frontend-app-plugin.ts +24 -12
- package/src/plugins/health-plugin.ts +27 -7
- package/src/plugins/index.ts +106 -4
- package/src/plugins/logs-plugin.ts +28 -14
- package/src/plugins/postgres-plugin.test.ts +49 -29
- package/src/plugins/postgres-plugin.ts +11 -9
- package/src/plugins/users/index.ts +35 -0
- package/src/plugins/users/stores/index.ts +7 -0
- package/src/plugins/users/stores/postgres-store.ts +225 -0
- package/src/plugins/users/types.ts +209 -0
- package/src/plugins/users/users-plugin.ts +281 -0
- package/ui/src/App.tsx +185 -31
- package/ui/src/api/controlPanelApi.ts +354 -1
- package/ui/src/components/ControlPanelApp.tsx +209 -0
- package/ui/src/components/index.ts +62 -0
- package/ui/src/dashboard/DashboardWidgetRegistry.tsx +129 -0
- package/ui/src/dashboard/DashboardWidgetRenderer.tsx +34 -0
- package/ui/src/dashboard/PluginWidgetRenderer.tsx +115 -0
- package/ui/src/dashboard/WidgetComponentRegistry.tsx +116 -0
- package/ui/src/dashboard/builtInWidgets.tsx +29 -0
- package/ui/src/dashboard/index.ts +35 -0
- package/ui/src/dashboard/widgets/ServiceHealthWidget.tsx +140 -0
- package/ui/src/dashboard/widgets/index.ts +7 -0
- package/ui/src/pages/DashboardPage.tsx +28 -149
- package/ui/src/pages/EntitlementsPage.tsx +557 -0
- package/ui/src/pages/LogsPage.tsx +174 -8
- package/ui/src/pages/PluginPage.tsx +148 -0
- package/ui/src/pages/SystemPage.tsx +445 -0
- package/ui/src/pages/UsersPage.tsx +837 -0
- package/ui/tsconfig.lib.json +11 -0
- package/ui/vite.lib.config.ts +51 -0
- package/dist-ui/assets/index-CW1BviRn.js +0 -465
- package/dist-ui/assets/index-CW1BviRn.js.map +0 -1
- package/ui/src/pages/HealthPage.tsx +0 -204
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlements Plugin
|
|
3
|
+
*
|
|
4
|
+
* User entitlement management for @qwickapps/server.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2025 QwickApps.com. All rights reserved.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Main plugin
|
|
10
|
+
export { createEntitlementsPlugin } from './entitlements-plugin.js';
|
|
11
|
+
|
|
12
|
+
// Helper functions
|
|
13
|
+
export {
|
|
14
|
+
getEntitlementSource,
|
|
15
|
+
isSourceReadonly,
|
|
16
|
+
getEntitlements,
|
|
17
|
+
refreshEntitlements,
|
|
18
|
+
hasEntitlement,
|
|
19
|
+
hasAnyEntitlement,
|
|
20
|
+
hasAllEntitlements,
|
|
21
|
+
grantEntitlement,
|
|
22
|
+
revokeEntitlement,
|
|
23
|
+
setEntitlements,
|
|
24
|
+
getAvailableEntitlements,
|
|
25
|
+
getEntitlementStats,
|
|
26
|
+
invalidateEntitlementCache,
|
|
27
|
+
storeExternalIdMapping,
|
|
28
|
+
invalidateByExternalId,
|
|
29
|
+
// Middleware
|
|
30
|
+
requireEntitlement,
|
|
31
|
+
requireAnyEntitlement,
|
|
32
|
+
requireAllEntitlements,
|
|
33
|
+
} from './entitlements-plugin.js';
|
|
34
|
+
|
|
35
|
+
// Sources
|
|
36
|
+
export { postgresEntitlementSource } from './sources/index.js';
|
|
37
|
+
|
|
38
|
+
// Types
|
|
39
|
+
export type {
|
|
40
|
+
EntitlementSource,
|
|
41
|
+
EntitlementResult,
|
|
42
|
+
EntitlementDefinition,
|
|
43
|
+
EntitlementsPluginConfig,
|
|
44
|
+
EntitlementCallbacks,
|
|
45
|
+
EntitlementsCacheConfig,
|
|
46
|
+
EntitlementsApiConfig,
|
|
47
|
+
PostgresEntitlementSourceConfig,
|
|
48
|
+
UserEntitlement,
|
|
49
|
+
CachedEntitlements,
|
|
50
|
+
EntitlementStats,
|
|
51
|
+
} from './types.js';
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL Entitlement Source
|
|
3
|
+
*
|
|
4
|
+
* Entitlement storage implementation using PostgreSQL.
|
|
5
|
+
* Stores user entitlements and entitlement definitions.
|
|
6
|
+
*
|
|
7
|
+
* Copyright (c) 2025 QwickApps.com. All rights reserved.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
EntitlementSource,
|
|
12
|
+
EntitlementDefinition,
|
|
13
|
+
EntitlementStats,
|
|
14
|
+
PostgresEntitlementSourceConfig,
|
|
15
|
+
} from '../types.js';
|
|
16
|
+
|
|
17
|
+
// Pool interface (from pg package)
|
|
18
|
+
interface PgPool {
|
|
19
|
+
query(text: string, values?: unknown[]): Promise<{ rows: unknown[]; rowCount: number | null }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a PostgreSQL entitlement source
|
|
24
|
+
*
|
|
25
|
+
* @param config Configuration including a pg Pool instance or a function that returns one
|
|
26
|
+
* @returns EntitlementSource implementation
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { Pool } from 'pg';
|
|
31
|
+
* import { postgresEntitlementSource } from '@qwickapps/server';
|
|
32
|
+
*
|
|
33
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
34
|
+
* const source = postgresEntitlementSource({ pool });
|
|
35
|
+
*
|
|
36
|
+
* // Or with lazy initialization:
|
|
37
|
+
* const source = postgresEntitlementSource({ pool: () => getPostgres().getPool() });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function postgresEntitlementSource(config: PostgresEntitlementSourceConfig): EntitlementSource {
|
|
41
|
+
const {
|
|
42
|
+
pool: poolOrFn,
|
|
43
|
+
tableName = 'user_entitlements',
|
|
44
|
+
definitionsTable = 'entitlement_definitions',
|
|
45
|
+
schema = 'public',
|
|
46
|
+
autoCreateTables = true,
|
|
47
|
+
} = config;
|
|
48
|
+
|
|
49
|
+
// Helper to get pool (supports lazy initialization via function)
|
|
50
|
+
const getPool = (): PgPool => {
|
|
51
|
+
const pool = typeof poolOrFn === 'function' ? poolOrFn() : poolOrFn;
|
|
52
|
+
return pool as PgPool;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const entitlementsTable = `"${schema}"."${tableName}"`;
|
|
56
|
+
const defsTable = `"${schema}"."${definitionsTable}"`;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
name: 'postgres',
|
|
60
|
+
description: 'PostgreSQL local entitlements',
|
|
61
|
+
readonly: false,
|
|
62
|
+
|
|
63
|
+
async initialize(): Promise<void> {
|
|
64
|
+
if (!autoCreateTables) return;
|
|
65
|
+
|
|
66
|
+
// Create entitlement definitions table (catalog of available entitlements)
|
|
67
|
+
await getPool().query(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS ${defsTable} (
|
|
69
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
70
|
+
name VARCHAR(255) NOT NULL UNIQUE,
|
|
71
|
+
category VARCHAR(100),
|
|
72
|
+
description TEXT,
|
|
73
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
74
|
+
);
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
// Create user entitlements table (many-to-many style)
|
|
78
|
+
await getPool().query(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS ${entitlementsTable} (
|
|
80
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
81
|
+
user_id UUID,
|
|
82
|
+
email VARCHAR(255) NOT NULL,
|
|
83
|
+
entitlement VARCHAR(255) NOT NULL,
|
|
84
|
+
granted_at TIMESTAMPTZ DEFAULT NOW(),
|
|
85
|
+
granted_by VARCHAR(255),
|
|
86
|
+
expires_at TIMESTAMPTZ,
|
|
87
|
+
metadata JSONB DEFAULT '{}',
|
|
88
|
+
UNIQUE(email, entitlement)
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_email ON ${entitlementsTable}(LOWER(email));
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_user_id ON ${entitlementsTable}(user_id) WHERE user_id IS NOT NULL;
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_entitlement ON ${entitlementsTable}(entitlement);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_expires_at ON ${entitlementsTable}(expires_at) WHERE expires_at IS NOT NULL;
|
|
95
|
+
`);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async getEntitlements(identifier: string): Promise<string[]> {
|
|
99
|
+
const email = identifier.toLowerCase();
|
|
100
|
+
|
|
101
|
+
const result = await getPool().query(
|
|
102
|
+
`SELECT entitlement FROM ${entitlementsTable}
|
|
103
|
+
WHERE LOWER(email) = $1
|
|
104
|
+
AND (expires_at IS NULL OR expires_at > NOW())
|
|
105
|
+
ORDER BY entitlement`,
|
|
106
|
+
[email]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return result.rows.map((row) => (row as { entitlement: string }).entitlement);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async getAllAvailable(): Promise<EntitlementDefinition[]> {
|
|
113
|
+
const result = await getPool().query(
|
|
114
|
+
`SELECT id, name, category, description FROM ${defsTable}
|
|
115
|
+
ORDER BY category NULLS LAST, name`
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return result.rows as EntitlementDefinition[];
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async getUsersWithEntitlement(
|
|
122
|
+
entitlement: string,
|
|
123
|
+
options: { limit?: number; offset?: number } = {}
|
|
124
|
+
): Promise<{ emails: string[]; total: number }> {
|
|
125
|
+
const { limit = 50, offset = 0 } = options;
|
|
126
|
+
|
|
127
|
+
// Get total count
|
|
128
|
+
const countResult = await getPool().query(
|
|
129
|
+
`SELECT COUNT(DISTINCT email) FROM ${entitlementsTable}
|
|
130
|
+
WHERE entitlement = $1
|
|
131
|
+
AND (expires_at IS NULL OR expires_at > NOW())`,
|
|
132
|
+
[entitlement]
|
|
133
|
+
);
|
|
134
|
+
const total = parseInt((countResult.rows[0] as { count: string }).count, 10);
|
|
135
|
+
|
|
136
|
+
// Get emails
|
|
137
|
+
const result = await getPool().query(
|
|
138
|
+
`SELECT DISTINCT email FROM ${entitlementsTable}
|
|
139
|
+
WHERE entitlement = $1
|
|
140
|
+
AND (expires_at IS NULL OR expires_at > NOW())
|
|
141
|
+
ORDER BY email
|
|
142
|
+
LIMIT $2 OFFSET $3`,
|
|
143
|
+
[entitlement, limit, offset]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
emails: result.rows.map((row) => (row as { email: string }).email),
|
|
148
|
+
total,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async addEntitlement(identifier: string, entitlement: string, grantedBy?: string): Promise<void> {
|
|
153
|
+
const email = identifier.toLowerCase();
|
|
154
|
+
|
|
155
|
+
// Use ON CONFLICT to handle duplicates (update granted_at if re-granting)
|
|
156
|
+
await getPool().query(
|
|
157
|
+
`INSERT INTO ${entitlementsTable} (email, entitlement, granted_by)
|
|
158
|
+
VALUES ($1, $2, $3)
|
|
159
|
+
ON CONFLICT (email, entitlement) DO UPDATE SET
|
|
160
|
+
granted_at = NOW(),
|
|
161
|
+
granted_by = EXCLUDED.granted_by,
|
|
162
|
+
expires_at = NULL`,
|
|
163
|
+
[email, entitlement, grantedBy || 'system']
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Auto-create definition if it doesn't exist
|
|
167
|
+
await getPool().query(
|
|
168
|
+
`INSERT INTO ${defsTable} (name)
|
|
169
|
+
VALUES ($1)
|
|
170
|
+
ON CONFLICT (name) DO NOTHING`,
|
|
171
|
+
[entitlement]
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async removeEntitlement(identifier: string, entitlement: string): Promise<void> {
|
|
176
|
+
const email = identifier.toLowerCase();
|
|
177
|
+
|
|
178
|
+
await getPool().query(
|
|
179
|
+
`DELETE FROM ${entitlementsTable}
|
|
180
|
+
WHERE LOWER(email) = $1 AND entitlement = $2`,
|
|
181
|
+
[email, entitlement]
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async setEntitlements(identifier: string, entitlements: string[]): Promise<void> {
|
|
186
|
+
const email = identifier.toLowerCase();
|
|
187
|
+
const pool = getPool();
|
|
188
|
+
|
|
189
|
+
// Start a transaction
|
|
190
|
+
await pool.query('BEGIN');
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// Remove all existing entitlements for this email
|
|
194
|
+
await pool.query(
|
|
195
|
+
`DELETE FROM ${entitlementsTable} WHERE LOWER(email) = $1`,
|
|
196
|
+
[email]
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Insert new entitlements
|
|
200
|
+
if (entitlements.length > 0) {
|
|
201
|
+
const values = entitlements
|
|
202
|
+
.map((_, i) => `($1, $${i + 2}, 'system')`)
|
|
203
|
+
.join(', ');
|
|
204
|
+
|
|
205
|
+
await pool.query(
|
|
206
|
+
`INSERT INTO ${entitlementsTable} (email, entitlement, granted_by)
|
|
207
|
+
VALUES ${values}`,
|
|
208
|
+
[email, ...entitlements]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// Auto-create definitions for any new entitlements
|
|
212
|
+
for (const ent of entitlements) {
|
|
213
|
+
await pool.query(
|
|
214
|
+
`INSERT INTO ${defsTable} (name)
|
|
215
|
+
VALUES ($1)
|
|
216
|
+
ON CONFLICT (name) DO NOTHING`,
|
|
217
|
+
[ent]
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await pool.query('COMMIT');
|
|
223
|
+
} catch (error) {
|
|
224
|
+
await pool.query('ROLLBACK');
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async getStats(): Promise<EntitlementStats> {
|
|
230
|
+
const result = await getPool().query(
|
|
231
|
+
`SELECT
|
|
232
|
+
COUNT(DISTINCT email) as users_with_entitlements,
|
|
233
|
+
COUNT(DISTINCT entitlement) as total_entitlements
|
|
234
|
+
FROM ${entitlementsTable}
|
|
235
|
+
WHERE expires_at IS NULL OR expires_at > NOW()`
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const row = result.rows[0] as {
|
|
239
|
+
users_with_entitlements: string;
|
|
240
|
+
total_entitlements: string;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
usersWithEntitlements: parseInt(row.users_with_entitlements, 10) || 0,
|
|
245
|
+
totalEntitlements: parseInt(row.total_entitlements, 10) || 0,
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async shutdown(): Promise<void> {
|
|
250
|
+
// Pool is managed externally, nothing to do here
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entitlements Plugin Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for entitlement management.
|
|
5
|
+
* Entitlements are string-based tags (e.g., 'pro', 'enterprise', 'feature:analytics').
|
|
6
|
+
* Sources can be local (PostgreSQL) or remote (Keap, Stripe, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) 2025 QwickApps.com. All rights reserved.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Entitlement definition - describes an available entitlement
|
|
13
|
+
*/
|
|
14
|
+
export interface EntitlementDefinition {
|
|
15
|
+
/** Unique ID (UUID for Postgres, tag ID for external sources) */
|
|
16
|
+
id: string;
|
|
17
|
+
/** Display name */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Category for grouping (optional) */
|
|
20
|
+
category?: string;
|
|
21
|
+
/** Description (optional) */
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Result from entitlement lookup
|
|
27
|
+
*/
|
|
28
|
+
export interface EntitlementResult {
|
|
29
|
+
/** Identifier used for lookup (email) */
|
|
30
|
+
identifier: string;
|
|
31
|
+
/** Array of entitlement strings (tag names) */
|
|
32
|
+
entitlements: string[];
|
|
33
|
+
/** Source that provided the entitlements */
|
|
34
|
+
source: 'cache' | string;
|
|
35
|
+
/** When the data was cached (ISO string) */
|
|
36
|
+
cachedAt?: string;
|
|
37
|
+
/** When the cache expires (ISO string) */
|
|
38
|
+
expiresAt?: string;
|
|
39
|
+
/** Per-source breakdown (when multiple sources) */
|
|
40
|
+
bySource?: Record<string, string[]>;
|
|
41
|
+
/** Additional metadata from source */
|
|
42
|
+
metadata?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* User entitlement record (for writable sources)
|
|
47
|
+
*/
|
|
48
|
+
export interface UserEntitlement {
|
|
49
|
+
/** Record ID */
|
|
50
|
+
id: string;
|
|
51
|
+
/** User ID (optional, for linking to users table) */
|
|
52
|
+
user_id?: string;
|
|
53
|
+
/** User email (always present) */
|
|
54
|
+
email: string;
|
|
55
|
+
/** Entitlement name/tag */
|
|
56
|
+
entitlement: string;
|
|
57
|
+
/** When granted */
|
|
58
|
+
granted_at: Date;
|
|
59
|
+
/** Who granted it */
|
|
60
|
+
granted_by?: string;
|
|
61
|
+
/** When it expires (null = permanent) */
|
|
62
|
+
expires_at?: Date;
|
|
63
|
+
/** Additional metadata */
|
|
64
|
+
metadata?: Record<string, unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* EntitlementSource interface - adapter pattern for pluggable sources
|
|
69
|
+
*
|
|
70
|
+
* Identifier is typically email, but sources may support other identifiers
|
|
71
|
+
* (e.g., Keap contact ID). The plugin normalizes to email for caching.
|
|
72
|
+
*/
|
|
73
|
+
export interface EntitlementSource {
|
|
74
|
+
/** Unique source name (e.g., 'postgres', 'keap') */
|
|
75
|
+
name: string;
|
|
76
|
+
|
|
77
|
+
/** Human-readable description */
|
|
78
|
+
description?: string;
|
|
79
|
+
|
|
80
|
+
/** Whether this source is read-only (no add/remove operations) */
|
|
81
|
+
readonly?: boolean;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Initialize the source (create tables, establish connections, etc.)
|
|
85
|
+
*/
|
|
86
|
+
initialize(): Promise<void>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get entitlements for an identifier
|
|
90
|
+
* @param identifier Email or other identifier
|
|
91
|
+
* @returns Array of entitlement strings (tag names)
|
|
92
|
+
*/
|
|
93
|
+
getEntitlements(identifier: string): Promise<string[]>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get all available entitlements that can be assigned
|
|
97
|
+
* Optional - returns empty array if not implemented
|
|
98
|
+
*/
|
|
99
|
+
getAllAvailable?(): Promise<EntitlementDefinition[]>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Search for users with a specific entitlement
|
|
103
|
+
* Optional - for sources that support reverse lookup
|
|
104
|
+
*/
|
|
105
|
+
getUsersWithEntitlement?(entitlement: string, options?: {
|
|
106
|
+
limit?: number;
|
|
107
|
+
offset?: number;
|
|
108
|
+
}): Promise<{ emails: string[]; total: number }>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Add an entitlement to a user
|
|
112
|
+
* @throws Error if source is read-only
|
|
113
|
+
*/
|
|
114
|
+
addEntitlement?(identifier: string, entitlement: string, grantedBy?: string): Promise<void>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Remove an entitlement from a user
|
|
118
|
+
* @throws Error if source is read-only
|
|
119
|
+
*/
|
|
120
|
+
removeEntitlement?(identifier: string, entitlement: string): Promise<void>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Bulk set entitlements for a user (replaces all existing)
|
|
124
|
+
* Optional - for sources that support batch operations
|
|
125
|
+
*/
|
|
126
|
+
setEntitlements?(identifier: string, entitlements: string[]): Promise<void>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Shutdown the source (close connections, cleanup)
|
|
130
|
+
*/
|
|
131
|
+
shutdown(): Promise<void>;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if the source is healthy (optional)
|
|
135
|
+
* Used for health checks without making expensive API calls.
|
|
136
|
+
* If not implemented, health check will assume healthy if initialized.
|
|
137
|
+
*/
|
|
138
|
+
isHealthy?(): Promise<boolean>;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get statistics about entitlements (optional)
|
|
142
|
+
* Used for dashboard widgets showing user counts with entitlements.
|
|
143
|
+
*/
|
|
144
|
+
getStats?(): Promise<EntitlementStats>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Statistics about entitlements from a source
|
|
149
|
+
*/
|
|
150
|
+
export interface EntitlementStats {
|
|
151
|
+
/** Total users with at least one entitlement */
|
|
152
|
+
usersWithEntitlements: number;
|
|
153
|
+
/** Total number of unique entitlements/tags */
|
|
154
|
+
totalEntitlements?: number;
|
|
155
|
+
/** Additional source-specific stats */
|
|
156
|
+
[key: string]: unknown;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Entitlement callbacks
|
|
161
|
+
*/
|
|
162
|
+
export interface EntitlementCallbacks {
|
|
163
|
+
/** Called when entitlements are fetched */
|
|
164
|
+
onFetch?: (identifier: string, entitlements: string[], source: string) => Promise<void>;
|
|
165
|
+
/** Called when entitlements change */
|
|
166
|
+
onChange?: (identifier: string, added: string[], removed: string[]) => Promise<void>;
|
|
167
|
+
/** Called when an entitlement is granted */
|
|
168
|
+
onGrant?: (identifier: string, entitlement: string, grantedBy?: string) => Promise<void>;
|
|
169
|
+
/** Called when an entitlement is revoked */
|
|
170
|
+
onRevoke?: (identifier: string, entitlement: string) => Promise<void>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Cache configuration for entitlements
|
|
175
|
+
*/
|
|
176
|
+
export interface EntitlementsCacheConfig {
|
|
177
|
+
/** Enable caching (default: true) */
|
|
178
|
+
enabled?: boolean;
|
|
179
|
+
/** Cache instance name from cache plugin (default: 'default') */
|
|
180
|
+
instanceName?: string;
|
|
181
|
+
/** Cache key prefix (default: 'entitlements:') */
|
|
182
|
+
keyPrefix?: string;
|
|
183
|
+
/** TTL in seconds for cached entitlements (default: 300) */
|
|
184
|
+
ttl?: number;
|
|
185
|
+
/** TTL for identifier mappings, e.g., contactId -> email (default: ttl * 2) */
|
|
186
|
+
mappingTtl?: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* API configuration for entitlements
|
|
191
|
+
*/
|
|
192
|
+
export interface EntitlementsApiConfig {
|
|
193
|
+
/** API route prefix (default: '/entitlements'). Note: routes are mounted under /api by control panel */
|
|
194
|
+
prefix?: string;
|
|
195
|
+
/** Enable API endpoints (default: true) */
|
|
196
|
+
enabled?: boolean;
|
|
197
|
+
/** Enable write endpoints (grant/revoke) - only works with writable source */
|
|
198
|
+
enableWrite?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Entitlements plugin configuration
|
|
203
|
+
*/
|
|
204
|
+
export interface EntitlementsPluginConfig {
|
|
205
|
+
/** Primary entitlement source */
|
|
206
|
+
source: EntitlementSource;
|
|
207
|
+
|
|
208
|
+
/** Additional sources to query (results are merged) */
|
|
209
|
+
additionalSources?: EntitlementSource[];
|
|
210
|
+
|
|
211
|
+
/** Cache configuration */
|
|
212
|
+
cache?: EntitlementsCacheConfig;
|
|
213
|
+
|
|
214
|
+
/** Callbacks for entitlement events */
|
|
215
|
+
callbacks?: EntitlementCallbacks;
|
|
216
|
+
|
|
217
|
+
/** API configuration */
|
|
218
|
+
api?: EntitlementsApiConfig;
|
|
219
|
+
|
|
220
|
+
/** Enable debug logging */
|
|
221
|
+
debug?: boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* PostgreSQL entitlement source configuration
|
|
226
|
+
*/
|
|
227
|
+
export interface PostgresEntitlementSourceConfig {
|
|
228
|
+
/** PostgreSQL pool instance or a function that returns one (for lazy initialization) */
|
|
229
|
+
pool: unknown | (() => unknown);
|
|
230
|
+
/** User entitlements table name (default: 'user_entitlements') */
|
|
231
|
+
tableName?: string;
|
|
232
|
+
/** Entitlement definitions table (default: 'entitlement_definitions') */
|
|
233
|
+
definitionsTable?: string;
|
|
234
|
+
/** Schema name (default: 'public') */
|
|
235
|
+
schema?: string;
|
|
236
|
+
/** Auto-create tables on init (default: true) */
|
|
237
|
+
autoCreateTables?: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Cached entitlements structure (stored in Redis)
|
|
242
|
+
*/
|
|
243
|
+
export interface CachedEntitlements {
|
|
244
|
+
/** Email (normalized lowercase) */
|
|
245
|
+
email: string;
|
|
246
|
+
/** Combined entitlements from all sources */
|
|
247
|
+
entitlements: string[];
|
|
248
|
+
/** Per-source breakdown */
|
|
249
|
+
bySource: Record<string, string[]>;
|
|
250
|
+
/** When cached (ISO string) */
|
|
251
|
+
cachedAt: string;
|
|
252
|
+
/** When expires (ISO string) */
|
|
253
|
+
expiresAt: string;
|
|
254
|
+
/** Cache version for invalidation */
|
|
255
|
+
version: number;
|
|
256
|
+
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import express from 'express';
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
import { resolve } from 'node:path';
|
|
16
|
-
import type {
|
|
16
|
+
import type { Plugin, PluginConfig, PluginRegistry } from '../core/plugin-registry.js';
|
|
17
|
+
import type { FrontendAppConfig } from '../core/types.js';
|
|
17
18
|
import { createRouteGuard } from '../core/guards.js';
|
|
18
19
|
|
|
19
20
|
export interface FrontendAppPluginConfig {
|
|
@@ -27,25 +28,32 @@ export interface FrontendAppPluginConfig {
|
|
|
27
28
|
heading?: string;
|
|
28
29
|
description?: string;
|
|
29
30
|
links?: Array<{ label: string; url: string }>;
|
|
31
|
+
/** URL path to the logo icon (SVG, PNG, etc.) */
|
|
32
|
+
logoIconUrl?: string;
|
|
30
33
|
branding?: {
|
|
31
|
-
logo?: string;
|
|
32
34
|
primaryColor?: string;
|
|
33
35
|
};
|
|
34
36
|
};
|
|
35
37
|
/** Route guard configuration */
|
|
36
38
|
guard?: FrontendAppConfig['mount']['guard'];
|
|
39
|
+
/** Product name for default landing page */
|
|
40
|
+
productName?: string;
|
|
41
|
+
/** Mount path for control panel link */
|
|
42
|
+
mountPath?: string;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/**
|
|
40
46
|
* Create a frontend app plugin that handles the root path
|
|
41
47
|
*/
|
|
42
|
-
export function createFrontendAppPlugin(config: FrontendAppPluginConfig):
|
|
48
|
+
export function createFrontendAppPlugin(config: FrontendAppPluginConfig): Plugin {
|
|
43
49
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
id: 'frontend-app',
|
|
51
|
+
name: 'Frontend App Plugin',
|
|
52
|
+
version: '1.0.0',
|
|
46
53
|
|
|
47
|
-
async
|
|
48
|
-
const
|
|
54
|
+
async onStart(_pluginConfig: PluginConfig, registry: PluginRegistry): Promise<void> {
|
|
55
|
+
const logger = registry.getLogger('frontend-app');
|
|
56
|
+
const app = registry.getApp();
|
|
49
57
|
|
|
50
58
|
// Apply guard if configured
|
|
51
59
|
if (config.guard && config.guard.type !== 'none') {
|
|
@@ -95,16 +103,20 @@ export function createFrontendAppPlugin(config: FrontendAppPluginConfig): Contro
|
|
|
95
103
|
logger.info(`Frontend app: Serving default welcome page`);
|
|
96
104
|
app.get('/', (_req, res) => {
|
|
97
105
|
const html = generateLandingPageHtml({
|
|
98
|
-
title:
|
|
99
|
-
heading: `Welcome to ${
|
|
106
|
+
title: config.productName || 'QwickApps Server',
|
|
107
|
+
heading: `Welcome to ${config.productName || 'QwickApps Server'}`,
|
|
100
108
|
description: 'Your application is running.',
|
|
101
109
|
links: [
|
|
102
|
-
{ label: 'Control Panel', url:
|
|
110
|
+
{ label: 'Control Panel', url: config.mountPath || '/cpanel' },
|
|
103
111
|
],
|
|
104
112
|
});
|
|
105
113
|
res.type('html').send(html);
|
|
106
114
|
});
|
|
107
115
|
},
|
|
116
|
+
|
|
117
|
+
async onStop(): Promise<void> {
|
|
118
|
+
// Nothing to cleanup
|
|
119
|
+
},
|
|
108
120
|
};
|
|
109
121
|
}
|
|
110
122
|
|
|
@@ -143,7 +155,7 @@ function generateLandingPageHtml(config: NonNullable<FrontendAppPluginConfig['la
|
|
|
143
155
|
max-width: 600px;
|
|
144
156
|
padding: 2rem;
|
|
145
157
|
}
|
|
146
|
-
${config.
|
|
158
|
+
${config.logoIconUrl ? `
|
|
147
159
|
.logo {
|
|
148
160
|
width: 80px;
|
|
149
161
|
height: 80px;
|
|
@@ -198,7 +210,7 @@ function generateLandingPageHtml(config: NonNullable<FrontendAppPluginConfig['la
|
|
|
198
210
|
</head>
|
|
199
211
|
<body>
|
|
200
212
|
<div class="container">
|
|
201
|
-
${config.
|
|
213
|
+
${config.logoIconUrl ? `<img src="${config.logoIconUrl}" alt="Logo" class="logo">` : ''}
|
|
202
214
|
<h1>${config.heading || config.title}</h1>
|
|
203
215
|
${config.description ? `<p>${config.description}</p>` : ''}
|
|
204
216
|
${linksHtml ? `<div class="links">${linksHtml}</div>` : ''}
|