diff --git a/changelog.md b/changelog.md index cfd8e21..5e5cc95 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 2025-12-01 - 1.6.0 - feat(apps) +Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation + +- Introduce App and AppConnection SmartData models (ts/reception/classes.app.ts, ts/reception/classes.appconnection.ts) +- Add AppManager and AppConnectionManager with typed handlers for getGlobalApps, getAppConnections and toggleAppConnection (ts/reception/classes.appmanager.ts, ts/reception/classes.appconnectionmanager.ts) +- Add request and data interfaces for apps and app connections (ts_interfaces/data/loint-reception.app.ts, ts_interfaces/data/loint-reception.appconnection.ts, ts_interfaces/request/loint-reception.app.ts) +- Seed default global apps and support OAuth credential shape (IOAuthCredentials) in app data +- Wire App managers into Reception (ts/reception/classes.reception.ts) and Reception startup +- Update idp client types to use legacy app shape where required (IAppLegacy) and adapt typed requests (ts_idpclient/*) +- Expose web UI routes and navigation for organization Apps view and export the AppsView (ts_web/elements/account/*, ts_web/elements/account/views/index.ts) +- Add registration of new stories for Apps feature (stories/*: ORG-009, ORG-010, ORG-011, DEV-008) and update story index +- Adjust typed request shapes for login/transfer flows to accept IAppLegacy where transfer/app data is exchanged + ## 2025-12-01 - 1.5.0 - feat(account) Refactor account UI styles into reusable design tokens, apply updated styles across views and fix login submit behavior diff --git a/stories/README.md b/stories/README.md index 48c694a..90c5626 100644 --- a/stories/README.md +++ b/stories/README.md @@ -7,8 +7,8 @@ This directory contains user stories for the idp.global Identity Provider platfo ``` stories/ ├── end-user/ # Stories for regular users (8) -├── organization-owner/ # Stories for organization admins (8) -├── developer/ # Stories for API/SDK consumers (7) +├── organization-owner/ # Stories for organization admins (11) +├── developer/ # Stories for API/SDK consumers (8) └── admin/ # Stories for platform administrators (7) ``` @@ -37,6 +37,9 @@ stories/ | ORG-006 | [Configure SSO for Organization](organization-owner/ORG-006-sso-config.md) | High | New | | ORG-007 | [View Organization Audit Logs](organization-owner/ORG-007-audit-logs.md) | Medium | New | | ORG-008 | [Manage Subscription and Billing](organization-owner/ORG-008-subscription-management.md) | Medium | Enhance | +| ORG-009 | [Connect Global Apps](organization-owner/ORG-009-global-apps.md) | High | New | +| ORG-010 | [Browse and Install Partner Apps](organization-owner/ORG-010-app-store.md) | Medium | New | +| ORG-011 | [Create Custom OIDC Apps](organization-owner/ORG-011-custom-oidc-apps.md) | Medium | New | ### Developer (DEV) | ID | Title | Priority | Source | @@ -48,6 +51,7 @@ stories/ | DEV-005 | [Register OAuth Client App](developer/DEV-005-oauth-client.md) | Medium | New | | DEV-006 | [Understand API Rate Limits](developer/DEV-006-rate-limiting.md) | Low | New | | DEV-007 | [Validate JWTs in My Application](developer/DEV-007-jwt-validation.md) | Medium | Enhance | +| DEV-008 | [Submit App to AppStore](developer/DEV-008-submit-partner-app.md) | Low | New | ### Platform Admin (ADM) | ID | Title | Priority | Source | @@ -65,9 +69,9 @@ stories/ | Priority | Count | Stories | |----------|-------|---------| | Critical | 3 | EU-002, ORG-002, ADM-001 | -| High | 10 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003 | -| Medium | 12 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 | -| Low | 5 | EU-007, EU-008, DEV-006, ADM-006 | +| High | 11 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003 | +| 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 | ## Source Legend diff --git a/stories/admin/ADM-008-global-app-management.md b/stories/admin/ADM-008-global-app-management.md new file mode 100644 index 0000000..830ab81 --- /dev/null +++ b/stories/admin/ADM-008-global-app-management.md @@ -0,0 +1,130 @@ +# Manage Global Apps + +**ID:** ADM-008 +**Priority:** High +**Status:** In Development +**Phase:** 1 + +## User Story +As a global administrator, I want to create, configure, and manage first-party global apps (foss.global, task.vc, etc.) so that organization owners can connect to these integrated services. + +## Acceptance Criteria +- [ ] Only users with `isGlobalAdmin: true` can access the admin page +- [ ] View list of all global apps with their status +- [ ] Create new global apps with OAuth credentials +- [ ] Edit existing global app details (name, description, logo, URLs) +- [ ] Activate/deactivate global apps (inactive apps hidden from org owners) +- [ ] View connection statistics per app (how many orgs connected) +- [ ] Regenerate OAuth client credentials for an app +- [ ] Delete global apps (with confirmation and impact warning) +- [ ] Admin page accessible at `/admin` route + +## Technical Notes +- Global admin flag stored on user: `isGlobalAdmin: boolean` +- Separate from organization roles (platform-level permission) +- OAuth credentials generated server-side, secrets never exposed in full +- App deletion should warn about existing connections +- Audit logging for all admin actions + +## Data Model + +```typescript +interface IUser { + id: string; + data: { + // ... existing fields ... + isGlobalAdmin?: boolean; // Platform-level admin flag + }; +} + +interface IGlobalApp { + id: string; + type: 'global'; + data: { + name: string; + description: string; + logoUrl: string; + appUrl: string; + oauthCredentials: IOAuthCredentials; + isActive: boolean; + category: string; + createdAt: number; + createdByUserId: string; + }; +} +``` + +## Request Interfaces + +```typescript +interface IReq_CreateGlobalApp { + method: 'createGlobalApp'; + request: { + jwt: string; + name: string; + description: string; + logoUrl: string; + appUrl: string; + category: string; + redirectUris: string[]; + allowedScopes: string[]; + }; + response: { + app: IGlobalApp; + clientSecret: string; // Only shown once on creation + }; +} + +interface IReq_UpdateGlobalApp { + method: 'updateGlobalApp'; + request: { + jwt: string; + appId: string; + updates: Partial; + }; + response: { + app: IGlobalApp; + }; +} + +interface IReq_DeleteGlobalApp { + method: 'deleteGlobalApp'; + request: { + jwt: string; + appId: string; + }; + response: { + success: boolean; + disconnectedOrganizations: number; + }; +} + +interface IReq_GetGlobalAppStats { + method: 'getGlobalAppStats'; + request: { + jwt: string; + }; + response: { + apps: Array<{ + app: IGlobalApp; + connectionCount: number; + }>; + }; +} +``` + +## UI Components +- **GlobalAdminView** (`/admin`) - Main admin dashboard +- **Global Apps Tab** - List of global apps with CRUD operations +- **Create/Edit App Dialog** - Form for app configuration +- Navigation shows "Admin" link only for global admins + +## Security Considerations +- Server-side validation of `isGlobalAdmin` flag on all admin endpoints +- JWT must be validated and user's admin status checked +- Rate limiting on credential regeneration +- Audit trail for all changes + +## Related Stories +- ORG-009: Connect Global Apps (organization perspective) +- ADM-003: Platform-wide Audit Logging diff --git a/stories/developer/DEV-004-app-id-setup.md b/stories/developer/DEV-004-app-id-setup.md index 21f4575..53b5984 100644 --- a/stories/developer/DEV-004-app-id-setup.md +++ b/stories/developer/DEV-004-app-id-setup.md @@ -19,10 +19,26 @@ As a developer, I want to properly register my application with a unique App ID ## Technical Notes - Current client has `id: ''` placeholder (TODO in code) -- Need Application model in database -- App credentials similar to OAuth client credentials +- App ID is now part of the unified Apps model (`IApp` discriminated union) +- Three app types exist: Global Apps, Partner Apps, Custom OIDC Apps +- For custom applications, use the Custom OIDC Apps flow (ORG-011) +- App credentials stored as `IOAuthCredentials` with hashed client secret - Validate redirect URIs to prevent open redirector attacks -- App ID should be included in JWT claims +- App ID/Client ID is included in JWT claims + +## Apps Architecture + +The Apps system supports three types: +1. **Global Apps** (ORG-009) - First-party platform apps (foss.global, task.vc) +2. **Partner Apps** (ORG-010, DEV-008) - AppStore model for third-party apps +3. **Custom OIDC Apps** (ORG-011) - Organization-created OAuth/OIDC clients + +## Related Stories +- ORG-009: Connect Global Apps +- ORG-010: Browse and Install Partner Apps +- ORG-011: Create Custom OIDC Apps +- DEV-005: Register OAuth Client App +- DEV-008: Submit App to AppStore ## Related TODOs - `ts_idpclient/classes.idpclient.ts:30` - `id: '', // TODO` diff --git a/stories/developer/DEV-005-oauth-client.md b/stories/developer/DEV-005-oauth-client.md index e8554d3..f5a6259 100644 --- a/stories/developer/DEV-005-oauth-client.md +++ b/stories/developer/DEV-005-oauth-client.md @@ -18,11 +18,34 @@ As a developer, I want to register my application as an OAuth client so that use - [ ] Client credentials flow for server-to-server ## Technical Notes -- OAuth keywords in package.json suggest this is planned -- Implement OAuth 2.0 authorization server endpoints +- OAuth/OIDC client registration is now part of the Apps system +- **For organization owners**: Use Custom OIDC Apps (ORG-011) to create OAuth clients +- **For third-party developers**: Submit to AppStore (DEV-008) for public apps +- Standard OAuth 2.0 / OpenID Connect flows supported - Scopes: openid, profile, email, organizations -- Consider OpenID Connect for identity layer - PKCE is required for mobile and SPA security +## Implementation Path + +This story's functionality is now implemented through: +1. **Custom OIDC Apps** (ORG-011) - Create org-specific OAuth clients via the Apps UI +2. **Partner Apps** (DEV-008) - Submit public apps to the AppStore + +Both use the same underlying `IOAuthCredentials` model: +```typescript +interface IOAuthCredentials { + clientId: string; + clientSecretHash: string; + redirectUris: string[]; + allowedScopes: string[]; + grantTypes: ('authorization_code' | 'client_credentials' | 'refresh_token')[]; +} +``` + +## Related Stories +- ORG-011: Create Custom OIDC Apps (primary implementation) +- DEV-004: Proper App ID Initialization +- DEV-008: Submit App to AppStore + ## Related TODOs - New feature - OAuth server implementation diff --git a/stories/developer/DEV-008-submit-partner-app.md b/stories/developer/DEV-008-submit-partner-app.md new file mode 100644 index 0000000..45bce99 --- /dev/null +++ b/stories/developer/DEV-008-submit-partner-app.md @@ -0,0 +1,70 @@ +# Submit App to AppStore + +**ID:** DEV-008 +**Priority:** Low +**Status:** Planned +**Phase:** 4 + +## User Story +As a developer, I want to submit my application to the AppStore so that other organizations can discover and install my app. + +## Acceptance Criteria +- [ ] Submit a new app to the AppStore +- [ ] Provide app name, description, and logo +- [ ] Add screenshots for the store listing +- [ ] Select app category and tags +- [ ] Set pricing model (free, paid, freemium) +- [ ] Configure OAuth credentials (redirect URIs, scopes) +- [ ] Submit for review +- [ ] View submission status (draft, pending_review, approved, rejected, suspended) +- [ ] Receive notification on approval/rejection +- [ ] Edit app listing after approval +- [ ] View app analytics (install count, usage) + +## Technical Notes +- Submitter organization becomes `ownerOrganizationId` +- Apps start in `draft` status, move to `pending_review` on submit +- Platform admins review and approve/reject apps +- Approved apps become visible in the AppStore +- App updates may require re-approval + +## Approval Workflow + +``` +draft → pending_review → approved → published + ↘ rejected + +approved ↔ suspended (admin action) +``` + +## Data Model + +```typescript +interface IPartnerApp { + id: string; + type: 'partner'; + data: { + ownerOrganizationId: string; + appStoreMetadata: { + shortDescription: string; + longDescription: string; + screenshots: string[]; + category: string; + tags: string[]; + pricing: { model: 'free' | 'paid' | 'freemium' }; + }; + approvalStatus: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'suspended'; + isPublished: boolean; + installCount: number; + // ... other fields + }; +} +``` + +## UI Components +- **AppSubmissionView** (`/account/org/:orgName/apps/submit`) - Submit new partner app form + +## Related Stories +- ORG-010: Browse and Install Partner Apps +- ORG-011: Create Custom OIDC Apps +- ADM-008: Review Partner App Submissions (new admin story) diff --git a/stories/organization-owner/ORG-009-global-apps.md b/stories/organization-owner/ORG-009-global-apps.md new file mode 100644 index 0000000..4a0f405 --- /dev/null +++ b/stories/organization-owner/ORG-009-global-apps.md @@ -0,0 +1,65 @@ +# Connect Global Apps + +**ID:** ORG-009 +**Priority:** High +**Status:** In Development +**Phase:** 1 + +## User Story +As an organization owner, I want to connect and disconnect first-party apps (foss.global, task.vc, etc.) for my organization so that my team members can use these integrated services. + +## Acceptance Criteria +- [ ] View list of available global apps (foss.global, task.vc) +- [ ] See connection status for each global app +- [ ] Connect a global app to the organization +- [ ] Disconnect a global app from the organization +- [ ] View which user connected the app and when +- [ ] See what scopes/permissions each app requires +- [ ] Toggle does not require page reload + +## Technical Notes +- Global apps are pre-registered by the platform administrators +- Uses `IAppConnection` to track org-to-app relationships +- Connection creates OAuth authorization for the app +- Apps access org data via granted scopes +- No credentials shown to org owners (managed by platform) + +## Data Model + +```typescript +interface IGlobalApp { + id: string; + type: 'global'; + data: { + name: string; + description: string; + logoUrl: string; + appUrl: string; + oauthCredentials: IOAuthCredentials; + isActive: boolean; + category: string; + }; +} + +interface IAppConnection { + id: string; + data: { + organizationId: string; + appId: string; + appType: 'global' | 'partner' | 'custom_oidc'; + status: 'active' | 'disconnected'; + connectedAt: number; + connectedByUserId: string; + grantedScopes: string[]; + }; +} +``` + +## UI Components +- **AppsView** (`/account/org/:orgName/apps`) - Main tabbed interface +- **Global Apps Tab** - List of global apps with toggle switches + +## Related Stories +- ORG-010: Browse and Install Partner Apps (AppStore) +- ORG-011: Create Custom OIDC Apps +- DEV-004: Proper App ID Initialization diff --git a/stories/organization-owner/ORG-010-app-store.md b/stories/organization-owner/ORG-010-app-store.md new file mode 100644 index 0000000..26ab322 --- /dev/null +++ b/stories/organization-owner/ORG-010-app-store.md @@ -0,0 +1,63 @@ +# Browse and Install Partner Apps + +**ID:** ORG-010 +**Priority:** Medium +**Status:** Planned +**Phase:** 3 + +## User Story +As an organization owner, I want to browse and install partner apps from the AppStore so that my organization can benefit from third-party integrations. + +## Acceptance Criteria +- [ ] Browse available partner apps in the AppStore +- [ ] Search apps by name or description +- [ ] Filter apps by category +- [ ] View curated sections (Featured, Popular, New) +- [ ] View app details (description, screenshots, pricing) +- [ ] See app install count and ratings +- [ ] Install/connect a partner app to the organization +- [ ] Uninstall/disconnect a partner app +- [ ] View installed apps list + +## Technical Notes +- Partner apps are submitted by other organizations (DEV-008) +- Apps must be approved by platform admins before appearing in store +- Uses `IPartnerApp` with `appStoreMetadata` +- Connection uses same `IAppConnection` as global apps + +## Data Model + +```typescript +interface IPartnerApp { + id: string; + type: 'partner'; + data: { + name: string; + description: string; + logoUrl: string; + appUrl: string; + ownerOrganizationId: string; + oauthCredentials: IOAuthCredentials; + appStoreMetadata: { + shortDescription: string; + longDescription: string; + screenshots: string[]; + category: string; + tags: string[]; + pricing: { model: 'free' | 'paid' | 'freemium' }; + }; + approvalStatus: TAppApprovalStatus; + isPublished: boolean; + installCount: number; + }; +} +``` + +## UI Components +- **AppsView** - App Store tab with search and categories +- **AppStoreDetailView** (`/account/org/:orgName/apps/store/:appId`) - Full app details page + +## Related Stories +- ORG-009: Connect Global Apps +- ORG-011: Create Custom OIDC Apps +- DEV-008: Submit App to AppStore diff --git a/stories/organization-owner/ORG-011-custom-oidc-apps.md b/stories/organization-owner/ORG-011-custom-oidc-apps.md new file mode 100644 index 0000000..6fa7795 --- /dev/null +++ b/stories/organization-owner/ORG-011-custom-oidc-apps.md @@ -0,0 +1,70 @@ +# Create Custom OIDC Apps + +**ID:** ORG-011 +**Priority:** Medium +**Status:** Planned +**Phase:** 2 + +## User Story +As an organization owner, I want to create custom OAuth/OIDC client applications so that I can integrate my own internal tools and services with the identity provider. + +## Acceptance Criteria +- [ ] Create a new custom OIDC application +- [ ] Configure application name and description +- [ ] Upload application logo +- [ ] Set application URL +- [ ] Configure redirect URIs +- [ ] Select allowed OAuth scopes +- [ ] Choose grant types (authorization_code, client_credentials, refresh_token) +- [ ] View client ID and client secret +- [ ] Regenerate client secret if compromised +- [ ] Edit existing applications +- [ ] Delete applications +- [ ] Configure token lifetimes + +## Technical Notes +- Custom OIDC apps are organization-scoped +- Client secret is hashed in database, shown only once at creation +- Redirect URIs validated to prevent open redirect attacks +- Standard OAuth 2.0 / OpenID Connect flows supported +- PKCE support for public clients + +## Data Model + +```typescript +interface ICustomOidcApp { + id: string; + type: 'custom_oidc'; + data: { + name: string; + description: string; + logoUrl: string; + appUrl: string; + ownerOrganizationId: string; + oauthCredentials: IOAuthCredentials; + oidcSettings: { + accessTokenLifetime: number; // seconds + refreshTokenLifetime: number; // seconds + }; + }; +} + +interface IOAuthCredentials { + clientId: string; + clientSecretHash: string; + redirectUris: string[]; + allowedScopes: string[]; + grantTypes: ('authorization_code' | 'client_credentials' | 'refresh_token')[]; +} +``` + +## UI Components +- **AppsView** - Custom OIDC tab with app list +- **OidcAppFormView** (`/account/org/:orgName/apps/custom/new`) - Create new app form +- **OidcAppFormView** (`/account/org/:orgName/apps/custom/:appId`) - Edit existing app + +## Related Stories +- ORG-009: Connect Global Apps +- ORG-010: Browse and Install Partner Apps +- DEV-004: Proper App ID Initialization +- DEV-005: Register OAuth Client App diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index a0ee570..8e7a843 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.5.0', + version: '1.6.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts/reception/classes.app.ts b/ts/reception/classes.app.ts new file mode 100644 index 0000000..4c7b9ba --- /dev/null +++ b/ts/reception/classes.app.ts @@ -0,0 +1,40 @@ +import * as plugins from '../plugins.js'; +import type { AppManager } from './classes.appmanager.js'; + +@plugins.smartdata.Manager() +export class App extends plugins.smartdata.SmartDataDbDoc< + App, + plugins.idpInterfaces.data.IAppDocument, + AppManager +> { + // INSTANCE + @plugins.smartdata.unI() + id: plugins.idpInterfaces.data.IAppDocument['id']; + + @plugins.smartdata.svDb() + type: plugins.idpInterfaces.data.IAppDocument['type']; + + @plugins.smartdata.svDb() + data: plugins.idpInterfaces.data.IAppDocument['data']; + + /** + * Check if the app is a global app + */ + public isGlobalApp(): this is App & { type: 'global' } { + return this.type === 'global'; + } + + /** + * Check if the app is a partner app + */ + public isPartnerApp(): this is App & { type: 'partner' } { + return this.type === 'partner'; + } + + /** + * Check if the app is a custom OIDC app + */ + public isCustomOidcApp(): this is App & { type: 'custom_oidc' } { + return this.type === 'custom_oidc'; + } +} diff --git a/ts/reception/classes.appconnection.ts b/ts/reception/classes.appconnection.ts new file mode 100644 index 0000000..b9bf348 --- /dev/null +++ b/ts/reception/classes.appconnection.ts @@ -0,0 +1,41 @@ +import * as plugins from '../plugins.js'; +import type { AppConnectionManager } from './classes.appconnectionmanager.js'; + +@plugins.smartdata.Manager() +export class AppConnection extends plugins.smartdata.SmartDataDbDoc< + AppConnection, + plugins.idpInterfaces.data.IAppConnection, + AppConnectionManager +> { + // INSTANCE + @plugins.smartdata.unI() + id: plugins.idpInterfaces.data.IAppConnection['id']; + + @plugins.smartdata.svDb() + data: plugins.idpInterfaces.data.IAppConnection['data']; + + /** + * Check if the connection is active + */ + public isActive(): boolean { + return this.data.status === 'active'; + } + + /** + * Disconnect the app + */ + public async disconnect(): Promise { + this.data.status = 'disconnected'; + await this.save(); + } + + /** + * Reconnect the app + */ + public async reconnect(userId: string): Promise { + this.data.status = 'active'; + this.data.connectedAt = Date.now(); + this.data.connectedByUserId = userId; + await this.save(); + } +} diff --git a/ts/reception/classes.appconnectionmanager.ts b/ts/reception/classes.appconnectionmanager.ts new file mode 100644 index 0000000..4b51a2f --- /dev/null +++ b/ts/reception/classes.appconnectionmanager.ts @@ -0,0 +1,187 @@ +import * as plugins from '../plugins.js'; +import type { Reception } from './classes.reception.js'; +import { AppConnection } from './classes.appconnection.js'; + +export class AppConnectionManager { + public receptionRef: Reception; + public get db() { + return this.receptionRef.db.smartdataDb; + } + public typedrouter = new plugins.typedrequest.TypedRouter(); + + public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection); + + constructor(receptionRefArg: Reception) { + this.receptionRef = receptionRefArg; + this.receptionRef.typedrouter.addTypedRouter(this.typedrouter); + + // Handler: Get app connections for an organization + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getAppConnections', + async (requestArg) => { + // Verify JWT and get user + const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); + const user = await this.receptionRef.userManager.CUser.getInstance({ + id: jwtData.data.userId, + }); + + // Check user has access to the organization + const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({ + id: requestArg.organizationId, + }); + + if (!organization) { + throw new plugins.typedrequest.TypedResponseError('Organization not found'); + } + + const role = await this.receptionRef.roleManager.CRole.getInstance({ + data: { + organizationId: organization.id, + userId: user.id, + }, + }); + + if (!role) { + throw new plugins.typedrequest.TypedResponseError( + 'User not authorized for this organization' + ); + } + + // Get all connections for this organization + const connections = await this.CAppConnection.getInstances({ + 'data.organizationId': requestArg.organizationId, + }); + + const connectionObjects = await Promise.all( + connections.map(async (conn) => await conn.createSavableObject()) + ); + + return { + connections: connectionObjects, + }; + } + ) + ); + + // Handler: Toggle app connection (connect/disconnect) + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'toggleAppConnection', + async (requestArg) => { + // Verify JWT and get user + const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); + const user = await this.receptionRef.userManager.CUser.getInstance({ + id: jwtData.data.userId, + }); + + // Check user has admin access to the organization + const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({ + id: requestArg.organizationId, + }); + + if (!organization) { + throw new plugins.typedrequest.TypedResponseError('Organization not found'); + } + + const isAdmin = await organization.checkIfUserIsAdmin(user); + if (!isAdmin) { + throw new plugins.typedrequest.TypedResponseError( + 'Only organization admins can manage app connections' + ); + } + + // Get the app + const app = await this.receptionRef.appManager.getAppById(requestArg.appId); + if (!app) { + throw new plugins.typedrequest.TypedResponseError('App not found'); + } + + // Find existing connection + let connection = await this.CAppConnection.getInstance({ + 'data.organizationId': requestArg.organizationId, + 'data.appId': requestArg.appId, + }); + + if (requestArg.action === 'connect') { + if (connection && connection.isActive()) { + // Already connected + return { + success: true, + connection: await connection.createSavableObject(), + }; + } + + if (connection) { + // Reconnect existing connection + await connection.reconnect(user.id); + } else { + // Create new connection + connection = new AppConnection(); + connection.id = plugins.smartunique.shortId(); + connection.data = { + organizationId: requestArg.organizationId, + appId: requestArg.appId, + appType: app.type, + status: 'active', + connectedAt: Date.now(), + connectedByUserId: user.id, + grantedScopes: app.data.oauthCredentials?.allowedScopes || [], + }; + await connection.save(); + } + + return { + success: true, + connection: await connection.createSavableObject(), + }; + } else { + // Disconnect + if (!connection) { + return { + success: true, + }; + } + + await connection.disconnect(); + + return { + success: true, + connection: await connection.createSavableObject(), + }; + } + } + ) + ); + } + + /** + * Get all connections for an organization + */ + public async getConnectionsForOrganization(organizationId: string): Promise { + return await this.CAppConnection.getInstances({ + 'data.organizationId': organizationId, + }); + } + + /** + * Get connection for a specific app and organization + */ + public async getConnection( + organizationId: string, + appId: string + ): Promise { + return await this.CAppConnection.getInstance({ + 'data.organizationId': organizationId, + 'data.appId': appId, + }); + } + + /** + * Check if an app is connected to an organization + */ + public async isAppConnected(organizationId: string, appId: string): Promise { + const connection = await this.getConnection(organizationId, appId); + return connection?.isActive() || false; + } +} diff --git a/ts/reception/classes.appmanager.ts b/ts/reception/classes.appmanager.ts new file mode 100644 index 0000000..319e4e4 --- /dev/null +++ b/ts/reception/classes.appmanager.ts @@ -0,0 +1,117 @@ +import * as plugins from '../plugins.js'; +import type { Reception } from './classes.reception.js'; +import { App } from './classes.app.js'; + +export class AppManager { + public receptionRef: Reception; + public get db() { + return this.receptionRef.db.smartdataDb; + } + public typedrouter = new plugins.typedrequest.TypedRouter(); + + public CApp = plugins.smartdata.setDefaultManagerForDoc(this, App); + + constructor(receptionRefArg: Reception) { + this.receptionRef = receptionRefArg; + this.receptionRef.typedrouter.addTypedRouter(this.typedrouter); + + // Handler: Get all global apps + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getGlobalApps', + async (requestArg) => { + // Verify JWT + await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt); + + // Get all active global apps + const globalApps = await this.CApp.getInstances({ + type: 'global', + }); + + const appObjects = await Promise.all( + globalApps.map(async (app) => await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp) + ); + + return { + apps: appObjects, + }; + } + ) + ); + } + + /** + * Get all global apps + */ + public async getGlobalApps(): Promise { + return await this.CApp.getInstances({ + type: 'global', + }); + } + + /** + * Get app by ID + */ + public async getAppById(appId: string): Promise { + return await this.CApp.getInstance({ + id: appId, + }); + } + + /** + * Seed initial global apps (for development/testing) + */ + public async seedGlobalApps() { + const defaultGlobalApps: Partial[] = [ + { + id: 'app-foss-global', + type: 'global', + data: { + name: 'foss.global', + description: 'Open Source Package Registry and Collaboration Platform', + logoUrl: 'https://foss.global/assets/logo.png', + appUrl: 'https://foss.global', + oauthCredentials: { + clientId: 'foss-global-client', + clientSecretHash: '', // Will be set when OAuth is configured + redirectUris: ['https://foss.global/auth/callback'], + allowedScopes: ['openid', 'profile', 'email', 'organizations'], + grantTypes: ['authorization_code', 'refresh_token'], + }, + isActive: true, + category: 'Development', + }, + }, + { + id: 'app-task-vc', + type: 'global', + data: { + name: 'task.vc', + description: 'Task Management and Project Collaboration', + logoUrl: 'https://task.vc/assets/logo.png', + appUrl: 'https://task.vc', + oauthCredentials: { + clientId: 'task-vc-client', + clientSecretHash: '', + redirectUris: ['https://task.vc/auth/callback'], + allowedScopes: ['openid', 'profile', 'email', 'organizations'], + grantTypes: ['authorization_code', 'refresh_token'], + }, + isActive: true, + category: 'Productivity', + }, + }, + ]; + + for (const appData of defaultGlobalApps) { + const existing = await this.CApp.getInstance({ id: appData.id }); + if (!existing) { + const app = new App(); + app.id = appData.id!; + app.type = appData.type!; + app.data = appData.data as any; + await app.save(); + } + } + } +} diff --git a/ts/reception/classes.reception.ts b/ts/reception/classes.reception.ts index f11c038..5cb56ca 100644 --- a/ts/reception/classes.reception.ts +++ b/ts/reception/classes.reception.ts @@ -13,6 +13,8 @@ import { ReceptionHousekeeping } from './classes.housekeeping.js'; import { OrganizationManager } from './classes.organizationmanager.js'; import { RoleManager } from './classes.rolemanager.js'; import { BillingPlanManager } from './classes.billingplanmanager.js'; +import { AppManager } from './classes.appmanager.js'; +import { AppConnectionManager } from './classes.appconnectionmanager.js'; export interface IReceptionOptions { /** @@ -41,6 +43,8 @@ export class Reception { public organizationmanager = new OrganizationManager(this); public roleManager = new RoleManager(this); public billingPlanManager = new BillingPlanManager(this); + public appManager = new AppManager(this); + public appConnectionManager = new AppConnectionManager(this); housekeeping = new ReceptionHousekeeping(this); constructor(public options: IReceptionOptions) { diff --git a/ts_idpclient/classes.idpclient.ts b/ts_idpclient/classes.idpclient.ts index d23643c..bfcf41d 100644 --- a/ts_idpclient/classes.idpclient.ts +++ b/ts_idpclient/classes.idpclient.ts @@ -11,12 +11,12 @@ export class IdpClient { // INSTANCE PUBLIC - public appData: plugins.idpInterfaces.data.IApp; + public appData: plugins.idpInterfaces.data.IAppLegacy; public rolesReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1); public organizationsReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1); public parsedReceptionUrl: plugins.smarturl.Smarturl; - constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IApp) { + constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IAppLegacy) { if (receptionBaseUrlArg.endsWith('/')) { receptionBaseUrlArg = receptionBaseUrlArg.slice(0, -1); } @@ -146,7 +146,7 @@ export class IdpClient { /** * can be used to switch between pages */ - public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IApp): Promise { + public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise { const jwt = await this.performJwtHousekeeping(); const extractedJwt = await this.helpers.extractDataFromJwtString(jwt); const getTransferToken = diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 051bd20..63909bc 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -1,4 +1,5 @@ export * from './loint-reception.app.js'; +export * from './loint-reception.appconnection.js'; export * from './loint-reception.billingplan.js'; export * from './loint-reception.device.js'; export * from './loint-reception.jwt.js'; diff --git a/ts_interfaces/data/loint-reception.app.ts b/ts_interfaces/data/loint-reception.app.ts index d439d86..4a21baa 100644 --- a/ts_interfaces/data/loint-reception.app.ts +++ b/ts_interfaces/data/loint-reception.app.ts @@ -1,4 +1,78 @@ -export interface IApp { +// App Types +export type TAppType = 'global' | 'partner' | 'custom_oidc'; +export type TAppApprovalStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'suspended'; + +// OAuth Credentials +export interface IOAuthCredentials { + clientId: string; + clientSecretHash: string; + redirectUris: string[]; + allowedScopes: string[]; + grantTypes: ('authorization_code' | 'client_credentials' | 'refresh_token')[]; +} + +// Base app data shared by all app types +export interface IAppBaseData { + name: string; + description: string; + logoUrl: string; + appUrl: string; +} + +// Global App - First-party apps managed by platform (foss.global, task.vc, etc.) +export interface IGlobalApp { + id: string; + type: 'global'; + data: IAppBaseData & { + oauthCredentials: IOAuthCredentials; + isActive: boolean; + category: string; + }; +} + +// Partner App - Third-party apps submitted to AppStore +export interface IPartnerApp { + id: string; + type: 'partner'; + data: IAppBaseData & { + ownerOrganizationId: string; + oauthCredentials: IOAuthCredentials; + appStoreMetadata: { + shortDescription: string; + longDescription: string; + screenshots: string[]; + category: string; + tags: string[]; + pricing: { model: 'free' | 'paid' | 'freemium' }; + }; + approvalStatus: TAppApprovalStatus; + isPublished: boolean; + installCount: number; + }; +} + +// Custom OIDC App - Organization-created OAuth clients +export interface ICustomOidcApp { + id: string; + type: 'custom_oidc'; + data: IAppBaseData & { + ownerOrganizationId: string; + oauthCredentials: IOAuthCredentials; + oidcSettings: { + accessTokenLifetime: number; // seconds + refreshTokenLifetime: number; // seconds + }; + }; +} + +// Union type for all app types +export type IApp = IGlobalApp | IPartnerApp | ICustomOidcApp; + +/** + * Legacy interface for backwards compatibility with existing code + * that expects a flat app structure (e.g., idpclient, transfermanager) + */ +export interface IAppLegacy { /** * must be unique */ @@ -11,3 +85,13 @@ export interface IApp { logoUrl: string; appUrl: string; } + +/** + * Storage interface for SmartData documents + * Uses the discriminated union approach with a 'type' field + */ +export interface IAppDocument { + id: string; + type: TAppType; + data: IGlobalApp['data'] | IPartnerApp['data'] | ICustomOidcApp['data']; +} diff --git a/ts_interfaces/data/loint-reception.appconnection.ts b/ts_interfaces/data/loint-reception.appconnection.ts new file mode 100644 index 0000000..302136d --- /dev/null +++ b/ts_interfaces/data/loint-reception.appconnection.ts @@ -0,0 +1,16 @@ +import type { TAppType } from './loint-reception.app.js'; + +export type TAppConnectionStatus = 'active' | 'disconnected'; + +export interface IAppConnection { + id: string; + data: { + organizationId: string; + appId: string; + appType: TAppType; + status: TAppConnectionStatus; + connectedAt: number; + connectedByUserId: string; + grantedScopes: string[]; + }; +} diff --git a/ts_interfaces/request/index.ts b/ts_interfaces/request/index.ts index a9cbe78..b18854e 100644 --- a/ts_interfaces/request/index.ts +++ b/ts_interfaces/request/index.ts @@ -1,4 +1,5 @@ export * from './loint-reception.apitoken.js'; +export * from './loint-reception.app.js'; export * from './loint-reception.authorization.js'; export * from './loint-reception.billingplan.js'; export * from './loint-reception.jwt.js'; diff --git a/ts_interfaces/request/loint-reception.app.ts b/ts_interfaces/request/loint-reception.app.ts new file mode 100644 index 0000000..72cac41 --- /dev/null +++ b/ts_interfaces/request/loint-reception.app.ts @@ -0,0 +1,52 @@ +import * as data from '../data/index.js'; +import * as plugins from '../loint-reception.plugins.js'; + +// Get all global apps +export interface IReq_GetGlobalApps + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_GetGlobalApps + > { + method: 'getGlobalApps'; + request: { + jwt: string; + }; + response: { + apps: data.IGlobalApp[]; + }; +} + +// Get app connections for an organization +export interface IReq_GetAppConnections + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_GetAppConnections + > { + method: 'getAppConnections'; + request: { + jwt: string; + organizationId: string; + }; + response: { + connections: data.IAppConnection[]; + }; +} + +// Connect/disconnect an app for an organization +export interface IReq_ToggleAppConnection + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_ToggleAppConnection + > { + method: 'toggleAppConnection'; + request: { + jwt: string; + organizationId: string; + appId: string; + action: 'connect' | 'disconnect'; + }; + response: { + success: boolean; + connection?: data.IAppConnection; + }; +} diff --git a/ts_interfaces/request/loint-reception.login.ts b/ts_interfaces/request/loint-reception.login.ts index 7d52f3c..849eebe 100644 --- a/ts_interfaces/request/loint-reception.login.ts +++ b/ts_interfaces/request/loint-reception.login.ts @@ -103,7 +103,7 @@ export interface IReq_ExchangeRefreshTokenAndTransferToken request: { transferToken?: string; refreshToken?: string; - appData: data.IApp; + appData: data.IAppLegacy; }; response: { refreshToken?: string; diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index a0ee570..8e7a843 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.5.0', + version: '1.6.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 0d884d0..57726f4 100644 --- a/ts_web/elements/account/content.ts +++ b/ts_web/elements/account/content.ts @@ -139,6 +139,16 @@ export class IdpAccountContent extends DeesElement { await this.domtools.convenience.smartdelay.delayFor(300); }); + this.subrouter.on('/org/:orgName/apps', async () => { + viewcontainer.classList.add('changing'); + await this.domtools.convenience.smartdelay.delayFor(300); + console.log('We are viewing the apps page'); + await cleanupViews(); + viewcontainer.append(new views.AppsView()); + 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 3c5b3eb..1f971c7 100644 --- a/ts_web/elements/account/navigation.ts +++ b/ts_web/elements/account/navigation.ts @@ -214,7 +214,13 @@ export class LeleAccountNavigation extends DeesElement {