diff --git a/changelog.md b/changelog.md index 5e5cc95..9f4c049 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-12-01 - 1.7.0 - feat(admin) +Add global admin functionality: backend admin APIs, model fields and UI integration + +- Backend: Add AppManager admin endpoints (getGlobalAppStats, create/update/delete/global apps, regenerate credentials) and checkGlobalAdmin handler; enforce admin checks via verifyGlobalAdmin +- Data models: Add createdAt and createdByUserId to global app data; add optional isGlobalAdmin flag to user data (IUser) +- Typed requests: Add new request definitions in loint-reception.admin.ts and export it from request index +- UI: Expose Global Admin entry in account navigation (isGlobalAdmin reactive state), add /admin subroute and AdminView export +- Account state: Fetch whoIs() on load to populate user information for admin checks +- App seeding: Seed global apps with createdAt and createdByUserId metadata +- Docs: Story index updated to include ADM-008 Manage Global Apps and adjust priority summary + ## 2025-12-01 - 1.6.0 - feat(apps) Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation diff --git a/stories/README.md b/stories/README.md index 90c5626..2aab3df 100644 --- a/stories/README.md +++ b/stories/README.md @@ -9,7 +9,7 @@ stories/ ├── end-user/ # Stories for regular users (8) ├── organization-owner/ # Stories for organization admins (11) ├── developer/ # Stories for API/SDK consumers (8) -└── admin/ # Stories for platform administrators (7) +└── admin/ # Stories for platform administrators (8) ``` ## Story Index @@ -63,13 +63,14 @@ stories/ | ADM-005 | [Security Monitoring Dashboard](admin/ADM-005-security-dashboard.md) | Medium | New | | ADM-006 | [Impersonate Users for Support](admin/ADM-006-user-impersonation.md) | Low | New | | ADM-007 | [Manage JWT Blocklist](admin/ADM-007-blocklist-management.md) | Medium | Enhance | +| ADM-008 | [Manage Global Apps](admin/ADM-008-global-app-management.md) | High | In Development | ## Priority Summary | Priority | Count | Stories | |----------|-------|---------| | Critical | 3 | EU-002, ORG-002, ADM-001 | -| High | 11 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003 | +| High | 12 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003, ADM-008 | | Medium | 14 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, ORG-010, ORG-011, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 | | Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 | diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 8e7a843..4602983 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.6.0', + version: '1.7.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts/reception/classes.appmanager.ts b/ts/reception/classes.appmanager.ts index 319e4e4..d6ae410 100644 --- a/ts/reception/classes.appmanager.ts +++ b/ts/reception/classes.appmanager.ts @@ -15,7 +15,7 @@ export class AppManager { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedrouter); - // Handler: Get all global apps + // Handler: Get all global apps (for org owners) this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGlobalApps', @@ -26,6 +26,7 @@ export class AppManager { // Get all active global apps const globalApps = await this.CApp.getInstances({ type: 'global', + 'data.isActive': true, }); const appObjects = await Promise.all( @@ -38,6 +39,199 @@ export class AppManager { } ) ); + + // Handler: Check if user is global admin + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'checkGlobalAdmin', + async (requestArg) => { + const user = await this.receptionRef.userManager.getUserByJwt(requestArg.jwt); + return { + isGlobalAdmin: user?.data?.isGlobalAdmin ?? false, + }; + } + ) + ); + + // Handler: Get global apps with stats (admin only) + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getGlobalAppStats', + async (requestArg) => { + await this.verifyGlobalAdmin(requestArg.jwt); + + // Get all global apps (including inactive) + const globalApps = await this.CApp.getInstances({ + type: 'global', + }); + + const appsWithStats = await Promise.all( + globalApps.map(async (app) => { + const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({ + 'data.appId': app.id, + 'data.status': 'active', + }); + return { + app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp, + connectionCount: connections.length, + }; + }) + ); + + return { apps: appsWithStats }; + } + ) + ); + + // Handler: Create global app (admin only) + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createGlobalApp', + async (requestArg) => { + const jwtData = await this.verifyGlobalAdmin(requestArg.jwt); + + // Generate OAuth credentials + const clientId = `app-${plugins.smartunique.shortId(12)}`; + const clientSecret = plugins.smartunique.shortId(32); + const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret); + + const app = new App(); + app.id = `app-${plugins.smartunique.shortId(8)}`; + app.type = 'global'; + app.data = { + name: requestArg.name, + description: requestArg.description, + logoUrl: requestArg.logoUrl, + appUrl: requestArg.appUrl, + category: requestArg.category, + isActive: true, + createdAt: Date.now(), + createdByUserId: jwtData.data.userId, + oauthCredentials: { + clientId, + clientSecretHash, + redirectUris: requestArg.redirectUris, + allowedScopes: requestArg.allowedScopes, + grantTypes: ['authorization_code', 'refresh_token'], + }, + }; + await app.save(); + + return { + app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp, + clientSecret, // Only shown once + }; + } + ) + ); + + // Handler: Update global app (admin only) + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateGlobalApp', + async (requestArg) => { + await this.verifyGlobalAdmin(requestArg.jwt); + + const app = await this.CApp.getInstance({ id: requestArg.appId }); + if (!app) { + throw new Error('App not found'); + } + + if (!app.isGlobalApp()) { + throw new Error('Can only update global apps'); + } + + // Update allowed fields - cast data to global app type after type guard + const appData = app.data as plugins.idpInterfaces.data.IGlobalApp['data']; + if (requestArg.updates.name !== undefined) appData.name = requestArg.updates.name; + if (requestArg.updates.description !== undefined) appData.description = requestArg.updates.description; + if (requestArg.updates.logoUrl !== undefined) appData.logoUrl = requestArg.updates.logoUrl; + if (requestArg.updates.appUrl !== undefined) appData.appUrl = requestArg.updates.appUrl; + if (requestArg.updates.category !== undefined) appData.category = requestArg.updates.category; + if (requestArg.updates.isActive !== undefined) appData.isActive = requestArg.updates.isActive; + if (requestArg.updates.redirectUris !== undefined) appData.oauthCredentials.redirectUris = requestArg.updates.redirectUris; + if (requestArg.updates.allowedScopes !== undefined) appData.oauthCredentials.allowedScopes = requestArg.updates.allowedScopes; + + await app.save(); + + return { + app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp, + }; + } + ) + ); + + // Handler: Delete global app (admin only) + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteGlobalApp', + async (requestArg) => { + await this.verifyGlobalAdmin(requestArg.jwt); + + const app = await this.CApp.getInstance({ id: requestArg.appId }); + if (!app) { + throw new Error('App not found'); + } + + // Get and disconnect all connections + const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({ + 'data.appId': requestArg.appId, + }); + + for (const connection of connections) { + await connection.delete(); + } + + await app.delete(); + + return { + success: true, + disconnectedOrganizations: connections.length, + }; + } + ) + ); + + // Handler: Regenerate OAuth credentials (admin only) + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'regenerateAppCredentials', + async (requestArg) => { + await this.verifyGlobalAdmin(requestArg.jwt); + + const app = await this.CApp.getInstance({ id: requestArg.appId }); + if (!app) { + throw new Error('App not found'); + } + + // Generate new credentials + const clientId = `app-${plugins.smartunique.shortId(12)}`; + const clientSecret = plugins.smartunique.shortId(32); + const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret); + + app.data.oauthCredentials.clientId = clientId; + app.data.oauthCredentials.clientSecretHash = clientSecretHash; + await app.save(); + + return { + clientId, + clientSecret, // Only shown once + }; + } + ) + ); + } + + /** + * Verify that the user is a global admin + */ + private async verifyGlobalAdmin(jwt: string) { + const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwt); + const user = await this.receptionRef.userManager.getUserByJwt(jwt); + if (!user?.data?.isGlobalAdmin) { + throw new Error('Access denied: Global admin privileges required'); + } + return jwtData; } /** @@ -80,6 +274,8 @@ export class AppManager { }, isActive: true, category: 'Development', + createdAt: Date.now(), + createdByUserId: 'system', }, }, { @@ -99,6 +295,8 @@ export class AppManager { }, isActive: true, category: 'Productivity', + createdAt: Date.now(), + createdByUserId: 'system', }, }, ]; diff --git a/ts/reception/classes.usermanager.ts b/ts/reception/classes.usermanager.ts index 0ae5943..7494237 100644 --- a/ts/reception/classes.usermanager.ts +++ b/ts/reception/classes.usermanager.ts @@ -51,6 +51,7 @@ export class UserManager { connectedOrgs: user.data.connectedOrgs, status: null, password: null, + isGlobalAdmin: user.data.isGlobalAdmin, } as plugins.idpInterfaces.data.IUser['data'] } } diff --git a/ts_interfaces/data/loint-reception.app.ts b/ts_interfaces/data/loint-reception.app.ts index 4a21baa..579a898 100644 --- a/ts_interfaces/data/loint-reception.app.ts +++ b/ts_interfaces/data/loint-reception.app.ts @@ -27,6 +27,8 @@ export interface IGlobalApp { oauthCredentials: IOAuthCredentials; isActive: boolean; category: string; + createdAt: number; + createdByUserId: string; }; } diff --git a/ts_interfaces/data/loint-reception.user.ts b/ts_interfaces/data/loint-reception.user.ts index 80339e3..6e69067 100644 --- a/ts_interfaces/data/loint-reception.user.ts +++ b/ts_interfaces/data/loint-reception.user.ts @@ -26,5 +26,11 @@ export interface IUser { * speeds up lookup */ connectedOrgs: string[]; + /** + * Platform-level admin flag + * Users with this flag can access the global admin panel + * to manage global apps, view platform stats, etc. + */ + isGlobalAdmin?: boolean; }; } diff --git a/ts_interfaces/request/index.ts b/ts_interfaces/request/index.ts index b18854e..d93c809 100644 --- a/ts_interfaces/request/index.ts +++ b/ts_interfaces/request/index.ts @@ -1,3 +1,4 @@ +export * from './loint-reception.admin.js'; export * from './loint-reception.apitoken.js'; export * from './loint-reception.app.js'; export * from './loint-reception.authorization.js'; diff --git a/ts_interfaces/request/loint-reception.admin.ts b/ts_interfaces/request/loint-reception.admin.ts new file mode 100644 index 0000000..71c4846 --- /dev/null +++ b/ts_interfaces/request/loint-reception.admin.ts @@ -0,0 +1,130 @@ +import * as plugins from '../loint-reception.plugins.js'; +import * as data from '../data/index.js'; + +/** + * Check if the current user is a global admin + */ +export interface IReq_CheckGlobalAdmin + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_CheckGlobalAdmin + > { + method: 'checkGlobalAdmin'; + request: { + jwt: string; + }; + response: { + isGlobalAdmin: boolean; + }; +} + +/** + * Get all global apps with statistics (admin only) + */ +export interface IReq_GetGlobalAppStats + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_GetGlobalAppStats + > { + method: 'getGlobalAppStats'; + request: { + jwt: string; + }; + response: { + apps: Array<{ + app: data.IGlobalApp; + connectionCount: number; + }>; + }; +} + +/** + * Create a new global app (admin only) + */ +export interface IReq_CreateGlobalApp + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_CreateGlobalApp + > { + method: 'createGlobalApp'; + request: { + jwt: string; + name: string; + description: string; + logoUrl: string; + appUrl: string; + category: string; + redirectUris: string[]; + allowedScopes: string[]; + }; + response: { + app: data.IGlobalApp; + clientSecret: string; // Only shown once on creation + }; +} + +/** + * Update an existing global app (admin only) + */ +export interface IReq_UpdateGlobalApp + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_UpdateGlobalApp + > { + method: 'updateGlobalApp'; + request: { + jwt: string; + appId: string; + updates: { + name?: string; + description?: string; + logoUrl?: string; + appUrl?: string; + category?: string; + isActive?: boolean; + redirectUris?: string[]; + allowedScopes?: string[]; + }; + }; + response: { + app: data.IGlobalApp; + }; +} + +/** + * Delete a global app (admin only) + */ +export interface IReq_DeleteGlobalApp + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_DeleteGlobalApp + > { + method: 'deleteGlobalApp'; + request: { + jwt: string; + appId: string; + }; + response: { + success: boolean; + disconnectedOrganizations: number; + }; +} + +/** + * Regenerate OAuth credentials for a global app (admin only) + */ +export interface IReq_RegenerateAppCredentials + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_RegenerateAppCredentials + > { + method: 'regenerateAppCredentials'; + request: { + jwt: string; + appId: string; + }; + response: { + clientId: string; + clientSecret: string; // Only shown once + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 8e7a843..4602983 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.6.0', + version: '1.7.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts_web/elements/account/content.ts b/ts_web/elements/account/content.ts index 57726f4..f416590 100644 --- a/ts_web/elements/account/content.ts +++ b/ts_web/elements/account/content.ts @@ -149,6 +149,16 @@ export class IdpAccountContent extends DeesElement { await this.domtools.convenience.smartdelay.delayFor(300); }); + this.subrouter.on('/admin', async () => { + viewcontainer.classList.add('changing'); + await this.domtools.convenience.smartdelay.delayFor(300); + console.log('We are viewing the admin page'); + await cleanupViews(); + viewcontainer.append(new views.AdminView()); + viewcontainer.classList.remove('changing'); + await this.domtools.convenience.smartdelay.delayFor(300); + }); + this.subrouter._handleRouteState(); this.registerGarbageFunction(async () => { diff --git a/ts_web/elements/account/navigation.ts b/ts_web/elements/account/navigation.ts index 1f971c7..6e35bf8 100644 --- a/ts_web/elements/account/navigation.ts +++ b/ts_web/elements/account/navigation.ts @@ -6,6 +6,7 @@ import { cssManager, unsafeCSS, css, + state, type TemplateResult, } from '@design.estate/dees-element'; @@ -24,6 +25,9 @@ declare global { @customElement('lele-accountnavigation') export class LeleAccountNavigation extends DeesElement { + @state() + accessor isGlobalAdmin: boolean = false; + constructor() { super(); } @@ -252,12 +256,34 @@ export class LeleAccountNavigation extends DeesElement { Billing + + ${this.renderAdminLink()}
v${commitinfo.version}
`; } + private renderAdminLink(): TemplateResult | null { + if (!this.isGlobalAdmin) { + return null; + } + return html` +
+ + + `; + } + public firstUpdated() { const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown'); const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => { @@ -286,5 +312,12 @@ export class LeleAccountNavigation extends DeesElement { .subscribe((selectedOrgArg) => { deesInputDropdown.selectedOption = selectedOrgArg; }); + + // Check if user is global admin + states.accountState + .select((stateArg) => stateArg.user) + .subscribe((user) => { + this.isGlobalAdmin = user?.data?.isGlobalAdmin ?? false; + }); } } diff --git a/ts_web/elements/account/views/adminview.ts b/ts_web/elements/account/views/adminview.ts new file mode 100644 index 0000000..a8b73d1 --- /dev/null +++ b/ts_web/elements/account/views/adminview.ts @@ -0,0 +1,759 @@ +import * as plugins from '../../../plugins.js'; +import { + customElement, + DeesElement, + property, + html, + cssManager, + css, + state, + type TemplateResult, +} from '@design.estate/dees-element'; + +import { IdpState } from '../../../states/idp.state.js'; +import { accountDesignTokens } from '../sharedstyles.js'; + +declare global { + interface HTMLElementTagNameMap { + 'lele-accountview-admin': AdminView; + } +} + +interface IAppWithStats { + app: plugins.idpInterfaces.data.IGlobalApp; + connectionCount: number; +} + +@customElement('lele-accountview-admin') +export class AdminView extends DeesElement { + @state() + accessor apps: IAppWithStats[] = []; + + @state() + accessor loading: boolean = true; + + @state() + accessor showCreateDialog: boolean = false; + + @state() + accessor editingApp: plugins.idpInterfaces.data.IGlobalApp | null = null; + + @state() + accessor newClientSecret: string | null = null; + + public static styles = [ + cssManager.defaultStyles, + accountDesignTokens, + css` + :host { + display: block; + min-height: 100%; + background: var(--background); + color: var(--foreground); + } + + .container { + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32px; + } + + h1 { + font-size: 32px; + font-weight: 600; + margin: 0; + letter-spacing: -0.02em; + } + + .subtitle { + color: #71717a; + margin-top: 8px; + font-size: 14px; + } + + .stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 32px; + } + + .stat-card { + background: #18181b; + border: 1px solid #27272a; + border-radius: 12px; + padding: 20px; + } + + .stat-value { + font-size: 28px; + font-weight: 600; + margin-bottom: 4px; + } + + .stat-label { + font-size: 13px; + color: #71717a; + } + + .apps-section { + background: #18181b; + border: 1px solid #27272a; + border-radius: 12px; + overflow: hidden; + } + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #27272a; + } + + .section-title { + font-size: 18px; + font-weight: 600; + } + + .app-list { + padding: 0; + } + + .app-item { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + border-bottom: 1px solid #27272a; + } + + .app-item:last-child { + border-bottom: none; + } + + .app-logo { + width: 48px; + height: 48px; + border-radius: 12px; + background: #27272a; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + } + + .app-logo img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .app-logo dees-icon { + font-size: 24px; + opacity: 0.7; + } + + .app-info { + flex: 1; + min-width: 0; + } + + .app-name { + font-size: 15px; + font-weight: 600; + margin-bottom: 4px; + } + + .app-details { + font-size: 13px; + color: #71717a; + display: flex; + gap: 16px; + } + + .app-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + } + + .app-status.active { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; + } + + .app-status.inactive { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + } + + .app-actions { + display: flex; + gap: 8px; + } + + .action-btn { + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + border: 1px solid #27272a; + background: transparent; + color: #fafafa; + cursor: pointer; + transition: all 0.15s ease; + } + + .action-btn:hover { + background: #27272a; + } + + .action-btn.danger:hover { + background: rgba(239, 68, 68, 0.1); + border-color: #ef4444; + color: #ef4444; + } + + .empty-state { + text-align: center; + padding: 48px; + color: #71717a; + } + + .empty-state dees-icon { + font-size: 48px; + opacity: 0.5; + margin-bottom: 16px; + } + + .loading { + display: flex; + align-items: center; + justify-content: center; + padding: 48px; + color: #71717a; + } + + /* Dialog styles */ + .dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .dialog { + background: #18181b; + border: 1px solid #27272a; + border-radius: 16px; + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + } + + .dialog-header { + padding: 20px 24px; + border-bottom: 1px solid #27272a; + } + + .dialog-title { + font-size: 18px; + font-weight: 600; + margin: 0; + } + + .dialog-body { + padding: 24px; + } + + .dialog-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid #27272a; + } + + .form-group { + margin-bottom: 20px; + } + + .form-label { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 8px; + color: #a1a1aa; + } + + .form-input { + width: 100%; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid #27272a; + background: #0a0a0a; + color: #fafafa; + font-size: 14px; + box-sizing: border-box; + } + + .form-input:focus { + outline: none; + border-color: #3b82f6; + } + + .form-textarea { + min-height: 80px; + resize: vertical; + } + + .secret-display { + background: #0a0a0a; + border: 1px solid #27272a; + border-radius: 8px; + padding: 16px; + margin-top: 16px; + } + + .secret-label { + font-size: 12px; + color: #71717a; + margin-bottom: 8px; + } + + .secret-value { + font-family: 'Geist Mono', monospace; + font-size: 13px; + word-break: break-all; + color: #22c55e; + } + + .secret-warning { + font-size: 12px; + color: #f59e0b; + margin-top: 12px; + display: flex; + align-items: center; + gap: 6px; + } + + `, + ]; + + public render(): TemplateResult { + return html` +
+
+
+

Global Admin

+

Manage platform-wide settings and global apps

+
+
+ +
+
+
${this.apps.length}
+
Total Global Apps
+
+
+
${this.apps.filter(a => a.app.data.isActive).length}
+
Active Apps
+
+
+
${this.apps.reduce((sum, a) => sum + a.connectionCount, 0)}
+
Total Connections
+
+
+ +
+
+ Global Apps + this.showCreateDialog = true} + > + + Create App + +
+ + ${this.loading ? this.renderLoading() : this.renderAppList()} +
+
+ + ${this.showCreateDialog ? this.renderCreateDialog() : null} + ${this.editingApp ? this.renderEditDialog() : null} + ${this.newClientSecret ? this.renderSecretDialog() : null} + `; + } + + private renderLoading(): TemplateResult { + return html` +
+ Loading apps... +
+ `; + } + + private renderAppList(): TemplateResult { + if (this.apps.length === 0) { + return html` +
+ +

No Global Apps

+

Create your first global app to get started.

+
+ `; + } + + return html` +
+ ${this.apps.map(({ app, connectionCount }) => html` +
+ +
+
${app.data.name}
+
+ ${app.data.category} + ${connectionCount} connections + ${app.data.appUrl} +
+
+ + ${app.data.isActive ? 'Active' : 'Inactive'} + +
+ + + +
+
+ `)} +
+ `; + } + + private renderCreateDialog(): TemplateResult { + return html` +
{ + if ((e.target as HTMLElement).classList.contains('dialog-overlay')) { + this.showCreateDialog = false; + } + }}> +
+
+

Create Global App

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ `; + } + + private renderEditDialog(): TemplateResult { + const app = this.editingApp!; + return html` +
{ + if ((e.target as HTMLElement).classList.contains('dialog-overlay')) { + this.editingApp = null; + } + }}> +
+
+

Edit ${app.data.name}

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ `; + } + + private renderSecretDialog(): TemplateResult { + return html` +
{ + if ((e.target as HTMLElement).classList.contains('dialog-overlay')) { + this.newClientSecret = null; + } + }}> +
+
+

Client Secret Generated

+
+
+

Your new client secret has been generated. Copy it now - you won't be able to see it again.

+
+
Client Secret
+
${this.newClientSecret}
+
+
+ + This secret will only be shown once. Store it securely. +
+
+ +
+
+ `; + } + + public async firstUpdated() { + await this.loadApps(); + } + + private async loadApps() { + this.loading = true; + + try { + const idpState = await IdpState.getSingletonInstance(); + const jwt = await idpState.idpClient.getJwt(); + + const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'getGlobalAppStats' + ); + + const response = await typedRequest.fire({ jwt }); + this.apps = response?.apps ?? []; + } catch (error) { + console.error('Error loading apps:', error); + } finally { + this.loading = false; + } + } + + private async createApp() { + const nameInput = this.shadowRoot!.querySelector('#app-name') as HTMLInputElement; + const descInput = this.shadowRoot!.querySelector('#app-description') as HTMLTextAreaElement; + const urlInput = this.shadowRoot!.querySelector('#app-url') as HTMLInputElement; + const logoInput = this.shadowRoot!.querySelector('#app-logo') as HTMLInputElement; + const categoryInput = this.shadowRoot!.querySelector('#app-category') as HTMLInputElement; + const redirectsInput = this.shadowRoot!.querySelector('#app-redirects') as HTMLInputElement; + const scopesInput = this.shadowRoot!.querySelector('#app-scopes') as HTMLInputElement; + + try { + const idpState = await IdpState.getSingletonInstance(); + const jwt = await idpState.idpClient.getJwt(); + + const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'createGlobalApp' + ); + + const response = await typedRequest.fire({ + jwt, + name: nameInput.value, + description: descInput.value, + appUrl: urlInput.value, + logoUrl: logoInput.value, + category: categoryInput.value, + redirectUris: redirectsInput.value.split(',').map(s => s.trim()).filter(Boolean), + allowedScopes: scopesInput.value.split(',').map(s => s.trim()).filter(Boolean), + }); + + this.showCreateDialog = false; + this.newClientSecret = response.clientSecret; + await this.loadApps(); + } catch (error) { + console.error('Error creating app:', error); + alert('Failed to create app'); + } + } + + private async updateApp() { + const app = this.editingApp!; + const nameInput = this.shadowRoot!.querySelector('#edit-name') as HTMLInputElement; + const descInput = this.shadowRoot!.querySelector('#edit-description') as HTMLTextAreaElement; + const urlInput = this.shadowRoot!.querySelector('#edit-url') as HTMLInputElement; + const logoInput = this.shadowRoot!.querySelector('#edit-logo') as HTMLInputElement; + const categoryInput = this.shadowRoot!.querySelector('#edit-category') as HTMLInputElement; + const activeCheckbox = this.shadowRoot!.querySelector('#edit-active') as any; + + try { + const idpState = await IdpState.getSingletonInstance(); + const jwt = await idpState.idpClient.getJwt(); + + const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'updateGlobalApp' + ); + + await typedRequest.fire({ + jwt, + appId: app.id, + updates: { + name: nameInput.value, + description: descInput.value, + appUrl: urlInput.value, + logoUrl: logoInput.value, + category: categoryInput.value, + isActive: activeCheckbox.value, + }, + }); + + this.editingApp = null; + await this.loadApps(); + } catch (error) { + console.error('Error updating app:', error); + alert('Failed to update app'); + } + } + + private async regenerateCredentials(appId: string) { + if (!confirm('Are you sure you want to regenerate credentials? The current credentials will stop working.')) { + return; + } + + try { + const idpState = await IdpState.getSingletonInstance(); + const jwt = await idpState.idpClient.getJwt(); + + const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'regenerateAppCredentials' + ); + + const response = await typedRequest.fire({ jwt, appId }); + this.newClientSecret = response.clientSecret; + } catch (error) { + console.error('Error regenerating credentials:', error); + alert('Failed to regenerate credentials'); + } + } + + private async deleteApp(appId: string) { + if (!confirm('Are you sure you want to delete this app? All organizations will be disconnected.')) { + return; + } + + try { + const idpState = await IdpState.getSingletonInstance(); + const jwt = await idpState.idpClient.getJwt(); + + const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'deleteGlobalApp' + ); + + const response = await typedRequest.fire({ jwt, appId }); + + if (response.disconnectedOrganizations > 0) { + alert(`App deleted. ${response.disconnectedOrganizations} organizations were disconnected.`); + } + + await this.loadApps(); + } catch (error) { + console.error('Error deleting app:', error); + alert('Failed to delete app'); + } + } +} diff --git a/ts_web/elements/account/views/index.ts b/ts_web/elements/account/views/index.ts index 2c16ebb..9998e49 100644 --- a/ts_web/elements/account/views/index.ts +++ b/ts_web/elements/account/views/index.ts @@ -1,3 +1,4 @@ +export * from './adminview.js'; export * from './appsview.js'; export * from './baseview.js'; export * from './orgsetup.js'; diff --git a/ts_web/states/accountstate.ts b/ts_web/states/accountstate.ts index 660aceb..a6d1481 100644 --- a/ts_web/states/accountstate.ts +++ b/ts_web/states/accountstate.ts @@ -46,6 +46,11 @@ export const getOrganizationsAction = accountState.createAction( const response = await idpState.idpClient.getRolesAndOrganizations(); currentState.organizations = response.organizations; currentState.roles = response.roles; + // Also fetch user data for admin checks + const whoIsResponse = await idpState.idpClient.whoIs().catch(() => null); + if (whoIsResponse?.user) { + currentState.user = whoIsResponse.user; + } return currentState; } );