8 Commits

Author SHA1 Message Date
jkunz 173735a84e v1.8.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 18:56:16 +00:00
jkunz 8756258324 feat(reception): Add activity logging, session metadata and org-selection UI (backend and frontend) 2025-12-01 18:56:16 +00:00
jkunz d11f5a0c72 fix(deps): update @push.rocks/smartdata and @git.zone/tswatch versions; refactor App and Jwt manager instantiation 2025-12-01 18:07:34 +00:00
jkunz cc040e5088 v1.7.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 09:44:37 +00:00
jkunz af0c24f7ca feat(admin): Add global admin functionality: backend admin APIs, model fields and UI integration 2025-12-01 09:44:37 +00:00
jkunz fd089b2cee v1.6.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 09:18:48 +00:00
jkunz 6b04c529da feat(apps): Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation 2025-12-01 09:18:48 +00:00
jkunz f54588e877 update paddle view nav 2025-12-01 04:44:47 +00:00
50 changed files with 5228 additions and 269 deletions
+38
View File
@@ -1,5 +1,43 @@
# Changelog # Changelog
## 2025-12-01 - 1.8.0 - feat(reception)
Add activity logging, session metadata and org-selection UI (backend and frontend)
- Introduce ActivityLog and ActivityLogManager to track user actions (TActivityAction, IActivityLog) for audit/display.
- Export new activity interface (IActivityLog) from ts_interfaces and add type TActivityAction.
- Wire ActivityLogManager into Reception so activity logging is available via the typed router.
- Enhance LoginSession data model with deviceInfo, createdAt and lastActive fields for richer session metadata.
- Add getUserSessions typed handler to return detailed session list (device, browser, os, ip, createdAt, lastActive, isCurrent).
- Revoke session endpoint now logs a 'session_revoked' activity when a session is revoked (and blocks revoking the current session).
- Add request interfaces IReq_GetUserSessions and IReq_GetUserActivity to typed request definitions.
- Frontend: account element now includes org-select and create-org modals, OrgView route, and handlers to open modals and navigate to new org/billing pages.
- Frontend: organization dropdown adds a '+ Create new...' option and wiring to open the creation modal.
- Minor refactors and routing exports: account index exports new modal components and views updated (OrgView).
## 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
- 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) ## 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 Refactor account UI styles into reusable design tokens, apply updated styles across views and fix login submit behavior
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@idp.global/idp.global", "name": "@idp.global/idp.global",
"version": "1.5.0", "version": "1.8.0",
"description": "An identity provider software managing user authentications, registrations, and sessions.", "description": "An identity provider software managing user authentications, registrations, and sessions.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
@@ -26,7 +26,7 @@
"@design.estate/dees-element": "^2.1.3", "@design.estate/dees-element": "^2.1.3",
"@push.rocks/lik": "^6.2.2", "@push.rocks/lik": "^6.2.2",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartdata": "^7.0.14", "@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smarthash": "^3.2.6", "@push.rocks/smarthash": "^3.2.6",
"@push.rocks/smartjson": "^5.2.0", "@push.rocks/smartjson": "^5.2.0",
@@ -52,7 +52,7 @@
"@git.zone/tsbuild": "^3.1.2", "@git.zone/tsbuild": "^3.1.2",
"@git.zone/tsbundle": "^2.6.2", "@git.zone/tsbundle": "^2.6.2",
"@git.zone/tsrun": "^2.0.0", "@git.zone/tsrun": "^2.0.0",
"@git.zone/tswatch": "^2.2.1", "@git.zone/tswatch": "^2.2.2",
"@push.rocks/projectinfo": "^5.0.1", "@push.rocks/projectinfo": "^5.0.1",
"@types/node": "^24.10.1" "@types/node": "^24.10.1"
}, },
+25 -23
View File
@@ -39,8 +39,8 @@ importers:
specifier: ^6.1.3 specifier: ^6.1.3
version: 6.1.3 version: 6.1.3
'@push.rocks/smartdata': '@push.rocks/smartdata':
specifier: ^7.0.14 specifier: ^7.0.15
version: 7.0.14 version: 7.0.15
'@push.rocks/smartdelay': '@push.rocks/smartdelay':
specifier: ^3.0.5 specifier: ^3.0.5
version: 3.0.5 version: 3.0.5
@@ -112,8 +112,8 @@ importers:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
'@git.zone/tswatch': '@git.zone/tswatch':
specifier: ^2.2.1 specifier: ^2.2.2
version: 2.2.1 version: 2.2.2
'@push.rocks/projectinfo': '@push.rocks/projectinfo':
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.2 version: 5.0.2
@@ -687,16 +687,12 @@ packages:
resolution: {integrity: sha512-o2/jvNsdLC8SRdH1kQ7JjNOQNu9el0FpJ/QOW3mgiC5C9reuTp18iU4kijsVVLgvw4KZv6Z289SoKPh3HPsS0g==} resolution: {integrity: sha512-o2/jvNsdLC8SRdH1kQ7JjNOQNu9el0FpJ/QOW3mgiC5C9reuTp18iU4kijsVVLgvw4KZv6Z289SoKPh3HPsS0g==}
hasBin: true hasBin: true
'@git.zone/tsrun@1.6.2':
resolution: {integrity: sha512-SOHbQqBg3/769/jPQcdpPCmugdEtIJINiG0O6aWx+su91GvGhheha5dAhccsCutJYErr+aJcBqBYuUYfhOfkFQ==}
hasBin: true
'@git.zone/tsrun@2.0.0': '@git.zone/tsrun@2.0.0':
resolution: {integrity: sha512-yA6zCjL+kn7xfZe6sL/m4K+zYqgkznG/pF6++i/E17iwzpG6dHmW+VZmYldHe86sW4DcLMvqM6CxM+KlgaEpKw==} resolution: {integrity: sha512-yA6zCjL+kn7xfZe6sL/m4K+zYqgkznG/pF6++i/E17iwzpG6dHmW+VZmYldHe86sW4DcLMvqM6CxM+KlgaEpKw==}
hasBin: true hasBin: true
'@git.zone/tswatch@2.2.1': '@git.zone/tswatch@2.2.2':
resolution: {integrity: sha512-Q3CS0c2wEioeX8thyjZBZsriLsi6znCcV9S6j8ENb11986SS5N8YvhgPaOHkgcxFHQ/ShZpfC+VxS7GrxLvuMg==} resolution: {integrity: sha512-dscBvB1Pg8bIvMLHMPrOnkh0AHXE9v5zuSz9t9BBmWL1ecR94gPSmIYalObMvyMrtXW4L7mBne1kU8N7DY9Otw==}
hasBin: true hasBin: true
'@happy-dom/global-registrator@15.11.7': '@happy-dom/global-registrator@15.11.7':
@@ -922,8 +918,8 @@ packages:
'@push.rocks/smartdata@5.16.7': '@push.rocks/smartdata@5.16.7':
resolution: {integrity: sha512-bu/YSIjQcwxWXkAsuhqE6zs7eT+bTIKV8+/H7TbbjpzeioLCyB3dZ/41cLZk37c/EYt4d4GHgZ0ww80OiKOUMg==} resolution: {integrity: sha512-bu/YSIjQcwxWXkAsuhqE6zs7eT+bTIKV8+/H7TbbjpzeioLCyB3dZ/41cLZk37c/EYt4d4GHgZ0ww80OiKOUMg==}
'@push.rocks/smartdata@7.0.14': '@push.rocks/smartdata@7.0.15':
resolution: {integrity: sha512-FOb7E2gxzQo5G6McJa76YMrUp8tIeMo6pitDPKvb6q1x3k5r+CiulPui40EA9xklj4aT6wVMZo6Aozm+pOARMg==} resolution: {integrity: sha512-j09BUekmjiGZuvXmdGBiIpBTXFFnxrzG4rOBjZvPO/hG1BwNrvSkIVq20mIwdYomn8JGgya6oJ4Y7NL+FKTqEA==}
'@push.rocks/smartdelay@3.0.5': '@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
@@ -1087,6 +1083,10 @@ packages:
'@push.rocks/smartversion@3.0.5': '@push.rocks/smartversion@3.0.5':
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==} resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
'@push.rocks/smartwatch@5.0.0':
resolution: {integrity: sha512-uuWUlTo0l5LWOWoOuTMG7zzxpUNKBcyqoB+zyQ24NHTtSYNcaUJtaQzTO2gxMXr5sqiZDkohlThS0KvsBc3g7w==}
engines: {node: '>=20.0.0'}
'@push.rocks/smartxml@2.0.0': '@push.rocks/smartxml@2.0.0':
resolution: {integrity: sha512-1d06zYJX4Zt8s5w5qFOUg2LAEz9ykrh9d6CQPK4WAgOBIefb1xzVEWHc7yoxicc2OkzNgC3IBCEg3s6BncZKWw==} resolution: {integrity: sha512-1d06zYJX4Zt8s5w5qFOUg2LAEz9ykrh9d6CQPK4WAgOBIefb1xzVEWHc7yoxicc2OkzNgC3IBCEg3s6BncZKWw==}
@@ -4731,32 +4731,26 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@git.zone/tsrun@1.6.2':
dependencies:
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartshell': 3.3.0
tsx: 4.20.6
'@git.zone/tsrun@2.0.0': '@git.zone/tsrun@2.0.0':
dependencies: dependencies:
'@push.rocks/smartfile': 11.2.7 '@push.rocks/smartfile': 11.2.7
'@push.rocks/smartshell': 3.3.0 '@push.rocks/smartshell': 3.3.0
tsx: 4.20.6 tsx: 4.20.6
'@git.zone/tswatch@2.2.1': '@git.zone/tswatch@2.2.2':
dependencies: dependencies:
'@api.global/typedserver': 3.0.80 '@api.global/typedserver': 3.0.80
'@git.zone/tsbundle': 2.6.2 '@git.zone/tsbundle': 2.6.2
'@git.zone/tsrun': 1.6.2 '@git.zone/tsrun': 2.0.0
'@push.rocks/early': 4.0.4 '@push.rocks/early': 4.0.4
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartchok': 1.2.0
'@push.rocks/smartcli': 4.0.19 '@push.rocks/smartcli': 4.0.19
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfile': 11.2.7 '@push.rocks/smartfs': 1.1.3
'@push.rocks/smartlog': 3.1.10 '@push.rocks/smartlog': 3.1.10
'@push.rocks/smartlog-destination-local': 9.0.2 '@push.rocks/smartlog-destination-local': 9.0.2
'@push.rocks/smartshell': 3.3.0 '@push.rocks/smartshell': 3.3.0
'@push.rocks/smartwatch': 5.0.0
'@push.rocks/taskbuffer': 3.4.0 '@push.rocks/taskbuffer': 3.4.0
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit' - '@nuxt/kit'
@@ -5172,7 +5166,7 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@push.rocks/smartdata@7.0.14': '@push.rocks/smartdata@7.0.15':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -5626,6 +5620,14 @@ snapshots:
'@types/semver': 7.7.1 '@types/semver': 7.7.1
semver: 7.7.3 semver: 7.7.3
'@push.rocks/smartwatch@5.0.0':
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartenv': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
picomatch: 4.0.3
'@push.rocks/smartxml@2.0.0': '@push.rocks/smartxml@2.0.0':
dependencies: dependencies:
fast-xml-parser: 5.3.2 fast-xml-parser: 5.3.2
+11 -6
View File
@@ -7,9 +7,9 @@ This directory contains user stories for the idp.global Identity Provider platfo
``` ```
stories/ stories/
├── end-user/ # Stories for regular users (8) ├── end-user/ # Stories for regular users (8)
├── organization-owner/ # Stories for organization admins (8) ├── organization-owner/ # Stories for organization admins (11)
├── developer/ # Stories for API/SDK consumers (7) ├── developer/ # Stories for API/SDK consumers (8)
└── admin/ # Stories for platform administrators (7) └── admin/ # Stories for platform administrators (8)
``` ```
## Story Index ## Story Index
@@ -37,6 +37,9 @@ stories/
| ORG-006 | [Configure SSO for Organization](organization-owner/ORG-006-sso-config.md) | High | New | | 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-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-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) ### Developer (DEV)
| ID | Title | Priority | Source | | ID | Title | Priority | Source |
@@ -48,6 +51,7 @@ stories/
| DEV-005 | [Register OAuth Client App](developer/DEV-005-oauth-client.md) | Medium | New | | 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-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-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) ### Platform Admin (ADM)
| ID | Title | Priority | Source | | ID | Title | Priority | Source |
@@ -59,15 +63,16 @@ stories/
| ADM-005 | [Security Monitoring Dashboard](admin/ADM-005-security-dashboard.md) | Medium | New | | 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-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-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 Summary
| Priority | Count | Stories | | Priority | Count | Stories |
|----------|-------|---------| |----------|-------|---------|
| Critical | 3 | EU-002, ORG-002, ADM-001 | | 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 | | 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 | 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 | | 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 | 5 | EU-007, EU-008, DEV-006, ADM-006 | | Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
## Source Legend ## Source Legend
@@ -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<IGlobalApp['data']>;
};
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
+19 -3
View File
@@ -19,10 +19,26 @@ As a developer, I want to properly register my application with a unique App ID
## Technical Notes ## Technical Notes
- Current client has `id: ''` placeholder (TODO in code) - Current client has `id: ''` placeholder (TODO in code)
- Need Application model in database - App ID is now part of the unified Apps model (`IApp` discriminated union)
- App credentials similar to OAuth client credentials - 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 - 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 ## Related TODOs
- `ts_idpclient/classes.idpclient.ts:30` - `id: '', // TODO` - `ts_idpclient/classes.idpclient.ts:30` - `id: '', // TODO`
+26 -3
View File
@@ -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 - [ ] Client credentials flow for server-to-server
## Technical Notes ## Technical Notes
- OAuth keywords in package.json suggest this is planned - OAuth/OIDC client registration is now part of the Apps system
- Implement OAuth 2.0 authorization server endpoints - **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 - Scopes: openid, profile, email, organizations
- Consider OpenID Connect for identity layer
- PKCE is required for mobile and SPA security - 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 ## Related TODOs
- New feature - OAuth server implementation - New feature - OAuth server implementation
@@ -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)
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.5.0', version: '1.8.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.' description: 'An identity provider software managing user authentications, registrations, and sessions.'
} }
+62
View File
@@ -0,0 +1,62 @@
import * as plugins from '../plugins.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
/**
* ActivityLog tracks user actions for audit and display purposes
*/
@plugins.smartdata.Manager()
export class ActivityLog extends plugins.smartdata.SmartDataDbDoc<
ActivityLog,
plugins.idpInterfaces.data.IActivityLog,
ActivityLogManager
> {
// ======
// static
// ======
public static async createActivityLog(
managerArg: ActivityLogManager,
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
const activityLog = new managerArg.CActivityLog();
activityLog.id = plugins.smartunique.shortId();
activityLog.data = {
userId,
action,
timestamp: Date.now(),
metadata: {
description,
...metadata,
},
};
await activityLog.save();
return activityLog;
}
// ========
// INSTANCE
// ========
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IActivityLog['data'] = {
userId: null,
action: null,
timestamp: null,
metadata: {
description: null,
},
};
constructor() {
super();
}
}
@@ -0,0 +1,77 @@
import * as plugins from '../plugins.js';
import { ActivityLog } from './classes.activitylog.js';
import { Reception } from './classes.reception.js';
export class ActivityLogManager {
// refs
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public CActivityLog = plugins.smartdata.setDefaultManagerForDoc(this, ActivityLog);
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
// Get user activity handler
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'getUserActivity',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
const limit = requestArg.limit || 20;
const offset = requestArg.offset || 0;
// Get activities for this user
const activities = await this.CActivityLog.getInstances({
'data.userId': jwt.data.userId,
});
// Sort by timestamp descending
const sortedActivities = activities
.sort((a, b) => b.data.timestamp - a.data.timestamp)
.slice(offset, offset + limit);
return {
activities: sortedActivities.map((a) => ({
id: a.id,
data: a.data,
})),
total: activities.length,
};
}
)
);
}
/**
* Log a user activity
*/
public async logActivity(
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
return await ActivityLog.createActivityLog(
this,
userId,
action,
description,
metadata
);
}
}
+40
View File
@@ -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';
}
}
+41
View File
@@ -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<void> {
this.data.status = 'disconnected';
await this.save();
}
/**
* Reconnect the app
*/
public async reconnect(userId: string): Promise<void> {
this.data.status = 'active';
this.data.connectedAt = Date.now();
this.data.connectedByUserId = userId;
await this.save();
}
}
@@ -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<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'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<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
'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<AppConnection[]> {
return await this.CAppConnection.getInstances({
'data.organizationId': organizationId,
});
}
/**
* Get connection for a specific app and organization
*/
public async getConnection(
organizationId: string,
appId: string
): Promise<AppConnection | null> {
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<boolean> {
const connection = await this.getConnection(organizationId, appId);
return connection?.isActive() || false;
}
}
+316
View File
@@ -0,0 +1,316 @@
import * as plugins from '../plugins.js';
import type { Reception } from './classes.reception.js';
import { App } from './classes.app.js';
// Note: App class is imported for use with setDefaultManagerForDoc
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 (for org owners)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
'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',
'data.isActive': true,
});
const appObjects = await Promise.all(
globalApps.map(async (app) => await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp)
);
return {
apps: appObjects,
};
}
)
);
// Handler: Check if user is global admin
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
'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<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
'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<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
'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 this.CApp();
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<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
'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<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
'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<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
'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;
}
/**
* Get all global apps
*/
public async getGlobalApps(): Promise<App[]> {
return await this.CApp.getInstances({
type: 'global',
});
}
/**
* Get app by ID
*/
public async getAppById(appId: string): Promise<App | null> {
return await this.CApp.getInstance({
id: appId,
});
}
/**
* Seed initial global apps (for development/testing)
*/
public async seedGlobalApps() {
const defaultGlobalApps: Partial<plugins.idpInterfaces.data.IGlobalApp>[] = [
{
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',
createdAt: Date.now(),
createdByUserId: 'system',
},
},
{
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',
createdAt: Date.now(),
createdByUserId: 'system',
},
},
];
for (const appData of defaultGlobalApps) {
const existing = await this.CApp.getInstance({ id: appData.id });
if (!existing) {
const app = new this.CApp();
app.id = appData.id!;
app.type = appData.type!;
app.data = appData.data as any;
await app.save();
}
}
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ export class JwtManager {
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> { public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> {
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg); const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
const jwt = await Jwt.getInstance({ const jwt = await this.CJwt.getInstance({
id: jwtData.id, id: jwtData.id,
}); });
if (jwt.blocked) { if (jwt.blocked) {
+4 -1
View File
@@ -60,7 +60,10 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }), validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
invalidated: false, invalidated: false,
refreshToken: null, refreshToken: null,
deviceId: null deviceId: null,
deviceInfo: null,
createdAt: Date.now(),
lastActive: Date.now(),
}; };
public transferToken: string; public transferToken: string;
@@ -259,6 +259,83 @@ export class LoginSessionManager {
ok: false ok: false
} }
}) })
);
// Get all sessions for the current user
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'getUserSessions',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
// Get the current session's refresh token to identify the current session
const currentRefreshToken = jwt.data.refreshToken;
// Get all sessions for this user
const sessions = await this.CLoginSession.getInstances({
'data.userId': jwt.data.userId,
'data.invalidated': false,
});
return {
sessions: sessions.map((session) => ({
id: session.id,
deviceId: session.data.deviceId || 'unknown',
deviceName: session.data.deviceInfo?.deviceName || 'Unknown Device',
browser: session.data.deviceInfo?.browser || 'Unknown Browser',
os: session.data.deviceInfo?.os || 'Unknown OS',
ip: session.data.deviceInfo?.ip || 'Unknown',
lastActive: session.data.lastActive || session.data.createdAt || Date.now(),
createdAt: session.data.createdAt || Date.now(),
isCurrent: session.data.refreshToken === currentRefreshToken,
})),
};
}
) )
);
// Revoke a specific session
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokeSession>(
'revokeSession',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
// Get the session to revoke
const sessionToRevoke = await this.CLoginSession.getInstance({
id: requestArg.sessionId,
'data.userId': jwt.data.userId, // Ensure user can only revoke their own sessions
});
if (!sessionToRevoke) {
throw new plugins.typedrequest.TypedResponseError('Session not found');
}
// Don't allow revoking the current session via this method
if (sessionToRevoke.data.refreshToken === jwt.data.refreshToken) {
throw new plugins.typedrequest.TypedResponseError(
'Cannot revoke current session. Use logout instead.'
);
}
await sessionToRevoke.invalidate();
// Log the activity
await this.receptionRef.activityLogManager.logActivity(
jwt.data.userId,
'session_revoked',
`Revoked session on ${sessionToRevoke.data.deviceInfo?.deviceName || 'unknown device'}`
);
return { success: true };
}
)
);
} }
} }
+6
View File
@@ -13,6 +13,9 @@ import { ReceptionHousekeeping } from './classes.housekeeping.js';
import { OrganizationManager } from './classes.organizationmanager.js'; import { OrganizationManager } from './classes.organizationmanager.js';
import { RoleManager } from './classes.rolemanager.js'; import { RoleManager } from './classes.rolemanager.js';
import { BillingPlanManager } from './classes.billingplanmanager.js'; import { BillingPlanManager } from './classes.billingplanmanager.js';
import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
export interface IReceptionOptions { export interface IReceptionOptions {
/** /**
@@ -41,6 +44,9 @@ export class Reception {
public organizationmanager = new OrganizationManager(this); public organizationmanager = new OrganizationManager(this);
public roleManager = new RoleManager(this); public roleManager = new RoleManager(this);
public billingPlanManager = new BillingPlanManager(this); public billingPlanManager = new BillingPlanManager(this);
public appManager = new AppManager(this);
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
housekeeping = new ReceptionHousekeeping(this); housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) { constructor(public options: IReceptionOptions) {
+1
View File
@@ -51,6 +51,7 @@ export class UserManager {
connectedOrgs: user.data.connectedOrgs, connectedOrgs: user.data.connectedOrgs,
status: null, status: null,
password: null, password: null,
isGlobalAdmin: user.data.isGlobalAdmin,
} as plugins.idpInterfaces.data.IUser['data'] } as plugins.idpInterfaces.data.IUser['data']
} }
} }
+3 -3
View File
@@ -11,12 +11,12 @@ export class IdpClient {
// INSTANCE PUBLIC // INSTANCE PUBLIC
public appData: plugins.idpInterfaces.data.IApp; public appData: plugins.idpInterfaces.data.IAppLegacy;
public rolesReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1); public rolesReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1);
public organizationsReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1); public organizationsReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1);
public parsedReceptionUrl: plugins.smarturl.Smarturl; public parsedReceptionUrl: plugins.smarturl.Smarturl;
constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IApp) { constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IAppLegacy) {
if (receptionBaseUrlArg.endsWith('/')) { if (receptionBaseUrlArg.endsWith('/')) {
receptionBaseUrlArg = receptionBaseUrlArg.slice(0, -1); receptionBaseUrlArg = receptionBaseUrlArg.slice(0, -1);
} }
@@ -146,7 +146,7 @@ export class IdpClient {
/** /**
* can be used to switch between pages * can be used to switch between pages
*/ */
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IApp): Promise<string> { public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string> {
const jwt = await this.performJwtHousekeeping(); const jwt = await this.performJwtHousekeeping();
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt); const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
const getTransferToken = const getTransferToken =
+2
View File
@@ -1,4 +1,6 @@
export * from './loint-reception.activity.js';
export * from './loint-reception.app.js'; export * from './loint-reception.app.js';
export * from './loint-reception.appconnection.js';
export * from './loint-reception.billingplan.js'; export * from './loint-reception.billingplan.js';
export * from './loint-reception.device.js'; export * from './loint-reception.device.js';
export * from './loint-reception.jwt.js'; export * from './loint-reception.jwt.js';
@@ -0,0 +1,28 @@
export type TActivityAction =
| 'login'
| 'logout'
| 'session_created'
| 'session_revoked'
| 'org_created'
| 'org_joined'
| 'org_left'
| 'role_changed'
| 'profile_updated'
| 'app_connected'
| 'app_disconnected';
export interface IActivityLog {
id: string;
data: {
userId: string;
action: TActivityAction;
timestamp: number;
metadata: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
description: string;
};
};
}
+87 -1
View File
@@ -1,4 +1,80 @@
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;
createdAt: number;
createdByUserId: 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 * must be unique
*/ */
@@ -11,3 +87,13 @@ export interface IApp {
logoUrl: string; logoUrl: string;
appUrl: 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'];
}
@@ -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[];
};
}
@@ -10,5 +10,22 @@ export interface ILoginSession {
* in different contexts on the same device * in different contexts on the same device
*/ */
deviceId: string; deviceId: string;
/**
* Device metadata for session display
*/
deviceInfo?: {
deviceName: string;
browser: string;
os: string;
ip: string;
};
/**
* When this session was created
*/
createdAt?: number;
/**
* Last time this session was active (e.g., refreshed)
*/
lastActive?: number;
}; };
} }
@@ -26,5 +26,11 @@ export interface IUser {
* speeds up lookup * speeds up lookup
*/ */
connectedOrgs: string[]; 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;
}; };
} }
+2
View File
@@ -1,4 +1,6 @@
export * from './loint-reception.admin.js';
export * from './loint-reception.apitoken.js'; export * from './loint-reception.apitoken.js';
export * from './loint-reception.app.js';
export * from './loint-reception.authorization.js'; export * from './loint-reception.authorization.js';
export * from './loint-reception.billingplan.js'; export * from './loint-reception.billingplan.js';
export * from './loint-reception.jwt.js'; export * from './loint-reception.jwt.js';
@@ -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
};
}
@@ -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;
};
}
@@ -103,7 +103,7 @@ export interface IReq_ExchangeRefreshTokenAndTransferToken
request: { request: {
transferToken?: string; transferToken?: string;
refreshToken?: string; refreshToken?: string;
appData: data.IApp; appData: data.IAppLegacy;
}; };
response: { response: {
refreshToken?: string; refreshToken?: string;
@@ -84,3 +84,59 @@ export interface IReq_WhoIs {
user: data.IUser; user: data.IUser;
}; };
} }
export interface IReq_GetUserSessions
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetUserSessions
> {
method: 'getUserSessions';
request: {
jwt: string;
};
response: {
sessions: Array<{
id: string;
deviceId: string;
deviceName: string;
browser: string;
os: string;
ip: string;
lastActive: number;
createdAt: number;
isCurrent: boolean;
}>;
};
}
export interface IReq_RevokeSession
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RevokeSession
> {
method: 'revokeSession';
request: {
jwt: string;
sessionId: string;
};
response: {
success: boolean;
};
}
export interface IReq_GetUserActivity
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetUserActivity
> {
method: 'getUserActivity';
request: {
jwt: string;
limit?: number;
offset?: number;
};
response: {
activities: data.IActivityLog[];
total: number;
};
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.5.0', version: '1.8.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.' description: 'An identity provider software managing user authentications, registrations, and sessions.'
} }
+72
View File
@@ -12,6 +12,8 @@ import {
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import { LeleAccountNavigation } from './navigation.js'; import { LeleAccountNavigation } from './navigation.js';
import { OrgSelectModal } from './org-select-modal.js';
import { CreateOrgModal } from './create-org-modal.js';
import { accountDesignTokens } from './sharedstyles.js'; import { accountDesignTokens } from './sharedstyles.js';
import * as views from './views/index.js'; import * as views from './views/index.js';
@@ -91,6 +93,8 @@ export class IdpAccountContent extends DeesElement {
<!--<lele-accountview-subscription></lele-accountview-subscription>--> <!--<lele-accountview-subscription></lele-accountview-subscription>-->
</div> </div>
</div> </div>
<idp-org-select-modal></idp-org-select-modal>
<idp-create-org-modal></idp-create-org-modal>
`; `;
} }
@@ -100,6 +104,34 @@ export class IdpAccountContent extends DeesElement {
this.subrouter = this.domtools.router.createSubRouter('/account'); this.subrouter = this.domtools.router.createSubRouter('/account');
const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer'); const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer');
// Get modal references
const orgSelectModal = this.shadowRoot.querySelector('idp-org-select-modal') as OrgSelectModal;
const createOrgModal = this.shadowRoot.querySelector('idp-create-org-modal') as CreateOrgModal;
// Setup event listeners for modals
this.addEventListener('open-org-select-modal', ((e: CustomEvent) => {
orgSelectModal.show({
targetPath: e.detail.targetPath,
title: e.detail.title,
description: e.detail.description,
});
}) as EventListener);
this.addEventListener('open-create-org-modal', () => {
createOrgModal.show();
});
// Handle org selection from modal
orgSelectModal.addEventListener('org-selected', ((e: CustomEvent) => {
this.subrouter.pushUrl(e.detail.path);
}) as EventListener);
// Handle org creation - navigate to billing
createOrgModal.addEventListener('org-created', ((e: CustomEvent) => {
const org = e.detail.org;
this.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
}) as EventListener);
const cleanupViews = async () => { const cleanupViews = async () => {
for (const child of Array.from(viewcontainer.children)) { for (const child of Array.from(viewcontainer.children)) {
viewcontainer.removeChild(child); viewcontainer.removeChild(child);
@@ -129,6 +161,46 @@ export class IdpAccountContent extends DeesElement {
await this.domtools.convenience.smartdelay.delayFor(300); await this.domtools.convenience.smartdelay.delayFor(300);
}); });
this.subrouter.on('/org/:orgName/paddlesetup', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the paddle setup page');
await cleanupViews();
viewcontainer.append(new views.PaddleSetupView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the org overview page');
await cleanupViews();
viewcontainer.append(new views.OrgView());
viewcontainer.classList.remove('changing');
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.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.subrouter._handleRouteState();
this.registerGarbageFunction(async () => { this.registerGarbageFunction(async () => {
+455
View File
@@ -0,0 +1,455 @@
import * as plugins from '../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from './sharedstyles.js';
import * as accountStateModule from '../../states/accountstate.js';
import { IdpState } from '../../states/idp.state.js';
declare global {
interface HTMLElementTagNameMap {
'idp-create-org-modal': CreateOrgModal;
}
}
@customElement('idp-create-org-modal')
export class CreateOrgModal extends DeesElement {
@state()
accessor visible: boolean = false;
@state()
accessor orgName: string = '';
@state()
accessor orgSlug: string = '';
@state()
accessor validating: boolean = false;
@state()
accessor validationResult: { available: boolean; message: string } | null = null;
@state()
accessor creating: boolean = false;
@state()
accessor error: string = '';
private validationDebounceTimer: any = null;
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: none;
}
:host([visible]) {
display: block;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: #18181b;
border: 1px solid #27272a;
border-radius: 16px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #fafafa;
}
.modal-description {
font-size: 14px;
color: #71717a;
margin: 0;
}
.modal-body {
padding: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.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;
transition: border-color 0.15s ease;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
}
.form-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slug-preview {
margin-top: 12px;
padding: 12px 16px;
background: #0a0a0a;
border: 1px solid #27272a;
border-radius: 8px;
}
.slug-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #71717a;
margin-bottom: 4px;
}
.slug-value {
font-family: 'Geist Mono', monospace;
font-size: 14px;
color: #fafafa;
}
.validation-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.validation-status.validating {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.validation-status.available {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.validation-status.unavailable {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.validation-status dees-icon {
font-size: 16px;
}
.error-message {
margin-top: 16px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #27272a;
}
`,
];
public render(): TemplateResult {
if (!this.visible) {
return html``;
}
const canCreate = this.orgName.length > 0 &&
this.validationResult?.available &&
!this.validating &&
!this.creating;
return html`
<div class="overlay" @click=${this.handleOverlayClick}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
<div class="modal-header">
<h2 class="modal-title">Create Organization</h2>
<p class="modal-description">Create a new organization to manage apps, users, and billing.</p>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Organization Name</label>
<input
type="text"
class="form-input"
placeholder="e.g., Acme Inc."
.value=${this.orgName}
@input=${this.handleNameInput}
?disabled=${this.creating}
/>
</div>
${this.orgSlug ? html`
<div class="slug-preview">
<div class="slug-label">Organization URL Slug</div>
<div class="slug-value">${this.orgSlug}</div>
</div>
` : ''}
${this.renderValidationStatus()}
${this.error ? html`
<div class="error-message">${this.error}</div>
` : ''}
</div>
<div class="modal-footer">
<dees-button type="secondary" @clicked=${this.handleCancel} ?disabled=${this.creating}>
Cancel
</dees-button>
<dees-button @clicked=${this.handleCreate} ?disabled=${!canCreate} .status=${this.creating ? 'pending' : 'normal'}>
${this.creating ? 'Creating...' : 'Create Organization'}
</dees-button>
</div>
</div>
</div>
`;
}
private renderValidationStatus(): TemplateResult | null {
if (!this.orgSlug) {
return null;
}
if (this.validating) {
return html`
<div class="validation-status validating">
<dees-icon .icon=${'lucide:loader-2'}></dees-icon>
Checking availability...
</div>
`;
}
if (this.validationResult) {
if (this.validationResult.available) {
return html`
<div class="validation-status available">
<dees-icon .icon=${'lucide:check-circle'}></dees-icon>
${this.validationResult.message}
</div>
`;
} else {
return html`
<div class="validation-status unavailable">
<dees-icon .icon=${'lucide:x-circle'}></dees-icon>
${this.validationResult.message}
</div>
`;
}
}
return null;
}
public show() {
this.orgName = '';
this.orgSlug = '';
this.validating = false;
this.validationResult = null;
this.creating = false;
this.error = '';
this.visible = true;
this.setAttribute('visible', '');
}
public hide() {
this.visible = false;
this.removeAttribute('visible');
if (this.validationDebounceTimer) {
clearTimeout(this.validationDebounceTimer);
}
}
private handleOverlayClick(e: Event) {
if ((e.target as HTMLElement).classList.contains('overlay') && !this.creating) {
this.hide();
}
}
private handleCancel() {
if (!this.creating) {
this.hide();
}
}
private handleNameInput(e: Event) {
const input = e.target as HTMLInputElement;
this.orgName = input.value;
this.orgSlug = this.generateSlug(this.orgName);
this.error = '';
// Debounce validation
if (this.validationDebounceTimer) {
clearTimeout(this.validationDebounceTimer);
}
if (this.orgSlug) {
this.validating = true;
this.validationResult = null;
this.validationDebounceTimer = setTimeout(() => {
this.validateSlug();
}, 500);
} else {
this.validating = false;
this.validationResult = null;
}
}
private generateSlug(name: string): string {
return name
.replace(/[^a-zA-Z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
}
private async validateSlug() {
if (!this.orgSlug) {
this.validating = false;
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const result = await idpState.idpClient.createOrganization(
this.orgName,
this.orgSlug,
'checkAvailability'
);
this.validationResult = {
available: result.nameAvailable,
message: result.nameAvailable
? 'This name is available!'
: 'This name is already taken. Please choose another.',
};
} catch (error) {
console.error('Validation error:', error);
this.validationResult = {
available: false,
message: 'Unable to validate. Please try again.',
};
} finally {
this.validating = false;
}
}
private async handleCreate() {
if (!this.validationResult?.available || this.creating) {
return;
}
this.creating = true;
this.error = '';
try {
const idpState = await IdpState.getSingletonInstance();
const result = await idpState.idpClient.createOrganization(
this.orgName,
this.orgSlug,
'manifest'
);
// Update state with new organization
const currentState = accountStateModule.accountState.getState();
currentState.organizations.push(result.resultingOrganization);
accountStateModule.accountState.dispatchAction(
accountStateModule.setSelectedOrg,
result.resultingOrganization
);
this.dispatchEvent(new CustomEvent('org-created', {
bubbles: true,
composed: true,
detail: { org: result.resultingOrganization },
}));
this.hide();
} catch (error) {
console.error('Error creating organization:', error);
this.error = error instanceof Error ? error.message : 'Failed to create organization. Please try again.';
} finally {
this.creating = false;
}
}
}
+2
View File
@@ -1,2 +1,4 @@
export * from './content.js'; export * from './content.js';
export * from './navigation.js'; export * from './navigation.js';
export * from './org-select-modal.js';
export * from './create-org-modal.js';
+126 -16
View File
@@ -6,6 +6,7 @@ import {
cssManager, cssManager,
unsafeCSS, unsafeCSS,
css, css,
state,
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
@@ -24,6 +25,9 @@ declare global {
@customElement('lele-accountnavigation') @customElement('lele-accountnavigation')
export class LeleAccountNavigation extends DeesElement { export class LeleAccountNavigation extends DeesElement {
@state()
accessor isGlobalAdmin: boolean = false;
constructor() { constructor() {
super(); super();
} }
@@ -179,14 +183,6 @@ export class LeleAccountNavigation extends DeesElement {
<dees-icon .icon=${'lucide:shield'}></dees-icon> <dees-icon .icon=${'lucide:shield'}></dees-icon>
Manage Roles Manage Roles
</div> </div>
<div
class="navigationOption"
@click=${async () => {
}}
>
<dees-icon .icon=${'lucide:plus'}></dees-icon>
Create Organization
</div>
<div <div
class="navigationOption" class="navigationOption"
@click=${async () => { @click=${async () => {
@@ -203,18 +199,77 @@ export class LeleAccountNavigation extends DeesElement {
<div class="navigationGroupLabel">Organization</div> <div class="navigationGroupLabel">Organization</div>
<dees-input-dropdown <dees-input-dropdown
.label=${'Select organization'} .label=${'Select organization'}
@selectedOption=${(eventArg: CustomEvent) => { @selectedOption=${async (eventArg: CustomEvent) => {
// Handle "Create new..." option
if (eventArg.detail.key === '__create_new__') {
this.dispatchEvent(new CustomEvent('open-create-org-modal', {
bubbles: true,
composed: true,
}));
return;
}
const currentState = states.accountState.getState(); const currentState = states.accountState.getState();
states.accountState.dispatchAction( const newOrg = currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload);
states.setSelectedOrg, states.accountState.dispatchAction(states.setSelectedOrg, newOrg);
currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload)
); // Auto-navigate to new org's current page type (reactivity)
const currentPath = window.location.pathname;
const subrouter = await this.getAccountRouter();
if (currentPath.includes('/org/') && newOrg) {
// Extract the page type (apps, billing, etc.) and navigate to new org
const pathParts = currentPath.split('/');
const pageType = pathParts[5]; // /account/org/:orgName/:pageType
if (pageType) {
subrouter.pushUrl(`/org/${newOrg.data.slug}/${pageType}`);
} else {
subrouter.pushUrl(`/org/${newOrg.data.slug}`);
}
}
}} }}
></dees-input-dropdown> ></dees-input-dropdown>
<div <div
class="navigationOption" class="navigationOption"
@click=${async () => {}} @click=${async () => {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName',
title: 'Select Organization',
description: 'Choose an organization to view its overview.',
},
}));
}
}}
>
<dees-icon .icon=${'lucide:home'}></dees-icon>
Overview
</div>
<div
class="navigationOption"
@click=${async () => {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/apps`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName/apps',
title: 'Select Organization',
description: 'Choose an organization to view its apps.',
},
}));
}
}}
> >
<dees-icon .icon=${'lucide:box'}></dees-icon> <dees-icon .icon=${'lucide:box'}></dees-icon>
Apps Apps
@@ -235,17 +290,55 @@ export class LeleAccountNavigation extends DeesElement {
</div> </div>
<div <div
class="navigationOption" class="navigationOption"
@click=${async () => {}} @click=${async () => {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/billing`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName/billing',
title: 'Select Organization',
description: 'Choose an organization to view its billing.',
},
}));
}
}}
> >
<dees-icon .icon=${'lucide:wallet'}></dees-icon> <dees-icon .icon=${'lucide:wallet'}></dees-icon>
Billing Billing
</div> </div>
${this.renderAdminLink()}
</div> </div>
<div class="commitinfo">v${commitinfo.version}</div> <div class="commitinfo">v${commitinfo.version}</div>
`; `;
} }
private renderAdminLink(): TemplateResult | null {
if (!this.isGlobalAdmin) {
return null;
}
return html`
<div class="divider"></div>
<div class="navigationGroupLabel">Platform</div>
<div
class="navigationOption"
@click=${async () => {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl('/admin');
}}
>
<dees-icon .icon=${'lucide:shield'}></dees-icon>
Global Admin
</div>
`;
}
public firstUpdated() { public firstUpdated() {
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown'); const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => { const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
@@ -258,11 +351,21 @@ export class LeleAccountNavigation extends DeesElement {
payload: orgArg.data.slug, payload: orgArg.data.slug,
}; };
}; };
// "Create new..." option to add at the end
const createNewOption = {
option: '+ Create new...',
key: '__create_new__',
payload: '__create_new__',
};
states.accountState states.accountState
.select((stateArg) => stateArg.organizations) .select((stateArg) => stateArg.organizations)
.pipe( .pipe(
plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => { plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => {
return orgArrayArg.map(orgToMenuEntry); const orgEntries = orgArrayArg.map(orgToMenuEntry);
// Add "Create new..." at the end
return [...orgEntries, createNewOption];
}) })
) )
.subscribe((menuEntries) => { .subscribe((menuEntries) => {
@@ -274,5 +377,12 @@ export class LeleAccountNavigation extends DeesElement {
.subscribe((selectedOrgArg) => { .subscribe((selectedOrgArg) => {
deesInputDropdown.selectedOption = selectedOrgArg; deesInputDropdown.selectedOption = selectedOrgArg;
}); });
// Check if user is global admin
states.accountState
.select((stateArg) => stateArg.user)
.subscribe((user) => {
this.isGlobalAdmin = user?.data?.isGlobalAdmin ?? false;
});
} }
} }
+318
View File
@@ -0,0 +1,318 @@
import * as plugins from '../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from './sharedstyles.js';
import * as accountStateModule from '../../states/accountstate.js';
declare global {
interface HTMLElementTagNameMap {
'idp-org-select-modal': OrgSelectModal;
}
}
@customElement('idp-org-select-modal')
export class OrgSelectModal extends DeesElement {
@state()
accessor visible: boolean = false;
@state()
accessor organizations: plugins.idpInterfaces.data.IOrganization[] = [];
@state()
accessor targetPath: string = '';
@state()
accessor title: string = 'Select Organization';
@state()
accessor description: string = 'Choose an organization to continue.';
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: none;
}
:host([visible]) {
display: block;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: #18181b;
border: 1px solid #27272a;
border-radius: 16px;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #fafafa;
}
.modal-description {
font-size: 14px;
color: #71717a;
margin: 0;
}
.modal-body {
padding: 0;
}
.org-list {
display: flex;
flex-direction: column;
}
.org-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 24px;
border-bottom: 1px solid #27272a;
cursor: pointer;
transition: background 0.15s ease;
}
.org-item:last-child {
border-bottom: none;
}
.org-item:hover {
background: #27272a;
}
.org-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.org-item:hover .org-icon {
background: #3f3f46;
}
.org-icon dees-icon {
opacity: 0.7;
}
.org-info {
flex: 1;
min-width: 0;
}
.org-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
color: #fafafa;
}
.org-slug {
font-size: 12px;
color: #71717a;
}
.org-arrow {
opacity: 0.5;
}
.empty-state {
text-align: center;
padding: 40px 24px;
color: #71717a;
}
.empty-state dees-icon {
font-size: 40px;
opacity: 0.5;
margin-bottom: 12px;
}
.empty-state p {
margin: 0 0 16px 0;
font-size: 14px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #27272a;
}
`,
];
public render(): TemplateResult {
if (!this.visible) {
return html``;
}
return html`
<div class="overlay" @click=${this.handleOverlayClick}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
<div class="modal-header">
<h2 class="modal-title">${this.title}</h2>
<p class="modal-description">${this.description}</p>
</div>
<div class="modal-body">
${this.organizations.length === 0
? this.renderEmptyState()
: this.renderOrgList()}
</div>
<div class="modal-footer">
<dees-button type="secondary" @clicked=${this.handleCancel}>
Cancel
</dees-button>
</div>
</div>
</div>
`;
}
private renderOrgList(): TemplateResult {
return html`
<div class="org-list">
${this.organizations.map((org) => html`
<div class="org-item" @click=${() => this.handleSelectOrg(org)}>
<div class="org-icon">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
</div>
<div class="org-info">
<div class="org-name">${org.data.name}</div>
<div class="org-slug">${org.data.slug}</div>
</div>
<dees-icon class="org-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
`)}
</div>
`;
}
private renderEmptyState(): TemplateResult {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
<p>You don't have any organizations yet.</p>
<dees-button @clicked=${this.handleCreateOrg}>
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
Create Organization
</dees-button>
</div>
`;
}
public show(options: {
targetPath: string;
title?: string;
description?: string;
}) {
this.targetPath = options.targetPath;
this.title = options.title || 'Select Organization';
this.description = options.description || 'Choose an organization to continue.';
// Load organizations from state
const state = accountStateModule.accountState.getState();
this.organizations = state.organizations;
this.visible = true;
this.setAttribute('visible', '');
}
public hide() {
this.visible = false;
this.removeAttribute('visible');
}
private handleOverlayClick(e: Event) {
if ((e.target as HTMLElement).classList.contains('overlay')) {
this.hide();
}
}
private handleCancel() {
this.hide();
}
private handleSelectOrg(org: plugins.idpInterfaces.data.IOrganization) {
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
// Replace :orgName placeholder with actual slug
const path = this.targetPath.replace(':orgName', org.data.slug);
this.dispatchEvent(new CustomEvent('org-selected', {
bubbles: true,
composed: true,
detail: { org, path },
}));
this.hide();
}
private handleCreateOrg() {
this.hide();
this.dispatchEvent(new CustomEvent('open-create-org-modal', {
bubbles: true,
composed: true,
}));
}
}
+759
View File
@@ -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`
<div class="container">
<div class="header">
<div>
<h1>Global Admin</h1>
<p class="subtitle">Manage platform-wide settings and global apps</p>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">${this.apps.length}</div>
<div class="stat-label">Total Global Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.apps.filter(a => a.app.data.isActive).length}</div>
<div class="stat-label">Active Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.apps.reduce((sum, a) => sum + a.connectionCount, 0)}</div>
<div class="stat-label">Total Connections</div>
</div>
</div>
<div class="apps-section">
<div class="section-header">
<span class="section-title">Global Apps</span>
<dees-button
@clicked=${() => this.showCreateDialog = true}
>
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
Create App
</dees-button>
</div>
${this.loading ? this.renderLoading() : this.renderAppList()}
</div>
</div>
${this.showCreateDialog ? this.renderCreateDialog() : null}
${this.editingApp ? this.renderEditDialog() : null}
${this.newClientSecret ? this.renderSecretDialog() : null}
`;
}
private renderLoading(): TemplateResult {
return html`
<div class="loading">
<span>Loading apps...</span>
</div>
`;
}
private renderAppList(): TemplateResult {
if (this.apps.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:box'}></dees-icon>
<h3>No Global Apps</h3>
<p>Create your first global app to get started.</p>
</div>
`;
}
return html`
<div class="app-list">
${this.apps.map(({ app, connectionCount }) => html`
<div class="app-item">
<div class="app-logo">
${app.data.logoUrl
? html`<img src="${app.data.logoUrl}" alt="${app.data.name}" />`
: html`<dees-icon .icon=${'lucide:box'}></dees-icon>`
}
</div>
<div class="app-info">
<div class="app-name">${app.data.name}</div>
<div class="app-details">
<span>${app.data.category}</span>
<span>${connectionCount} connections</span>
<span>${app.data.appUrl}</span>
</div>
</div>
<span class="app-status ${app.data.isActive ? 'active' : 'inactive'}">
${app.data.isActive ? 'Active' : 'Inactive'}
</span>
<div class="app-actions">
<button class="action-btn" @click=${() => this.editingApp = app}>
Edit
</button>
<button class="action-btn" @click=${() => this.regenerateCredentials(app.id)}>
Regenerate
</button>
<button class="action-btn danger" @click=${() => this.deleteApp(app.id)}>
Delete
</button>
</div>
</div>
`)}
</div>
`;
}
private renderCreateDialog(): TemplateResult {
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.showCreateDialog = false;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Create Global App</h2>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">App Name</label>
<input type="text" class="form-input" id="app-name" placeholder="e.g., foss.global" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-input form-textarea" id="app-description" placeholder="Describe what this app does..."></textarea>
</div>
<div class="form-group">
<label class="form-label">App URL</label>
<input type="url" class="form-input" id="app-url" placeholder="https://app.example.com" />
</div>
<div class="form-group">
<label class="form-label">Logo URL</label>
<input type="url" class="form-input" id="app-logo" placeholder="https://example.com/logo.png" />
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" class="form-input" id="app-category" placeholder="e.g., Productivity" />
</div>
<div class="form-group">
<label class="form-label">Redirect URIs (comma-separated)</label>
<input type="text" class="form-input" id="app-redirects" placeholder="https://app.example.com/callback" />
</div>
<div class="form-group">
<label class="form-label">Allowed Scopes (comma-separated)</label>
<input type="text" class="form-input" id="app-scopes" placeholder="openid, profile, email" />
</div>
</div>
<div class="dialog-footer">
<dees-button type="secondary" @clicked=${() => this.showCreateDialog = false}>
Cancel
</dees-button>
<dees-button @clicked=${this.createApp}>
Create App
</dees-button>
</div>
</div>
</div>
`;
}
private renderEditDialog(): TemplateResult {
const app = this.editingApp!;
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.editingApp = null;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Edit ${app.data.name}</h2>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">App Name</label>
<input type="text" class="form-input" id="edit-name" .value=${app.data.name} />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-input form-textarea" id="edit-description">${app.data.description}</textarea>
</div>
<div class="form-group">
<label class="form-label">App URL</label>
<input type="url" class="form-input" id="edit-url" .value=${app.data.appUrl} />
</div>
<div class="form-group">
<label class="form-label">Logo URL</label>
<input type="url" class="form-input" id="edit-logo" .value=${app.data.logoUrl} />
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" class="form-input" id="edit-category" .value=${app.data.category} />
</div>
<div class="form-group">
<label class="form-label">Status</label>
<dees-input-checkbox
.label=${'App is active'}
.value=${app.data.isActive}
id="edit-active"
></dees-input-checkbox>
</div>
</div>
<div class="dialog-footer">
<dees-button type="secondary" @clicked=${() => this.editingApp = null}>
Cancel
</dees-button>
<dees-button @clicked=${this.updateApp}>
Save Changes
</dees-button>
</div>
</div>
</div>
`;
}
private renderSecretDialog(): TemplateResult {
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.newClientSecret = null;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Client Secret Generated</h2>
</div>
<div class="dialog-body">
<p>Your new client secret has been generated. Copy it now - you won't be able to see it again.</p>
<div class="secret-display">
<div class="secret-label">Client Secret</div>
<div class="secret-value">${this.newClientSecret}</div>
</div>
<div class="secret-warning">
<dees-icon .icon=${'lucide:alert-triangle'}></dees-icon>
This secret will only be shown once. Store it securely.
</div>
</div>
<div class="dialog-footer">
<dees-button @clicked=${() => {
navigator.clipboard.writeText(this.newClientSecret!);
}}>
Copy to Clipboard
</dees-button>
<dees-button type="secondary" @clicked=${() => this.newClientSecret = null}>
Close
</dees-button>
</div>
</div>
</div>
`;
}
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<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
'/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<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
'/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<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
'/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<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
'/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<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
'/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');
}
}
}
+450
View File
@@ -0,0 +1,450 @@
import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
} from '@design.estate/dees-element';
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
import * as accountState from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
declare global {
interface HTMLElementTagNameMap {
'lele-accountview-apps': AppsView;
}
}
interface IAppDisplay {
id: string;
name: string;
description: string;
logoUrl: string;
appUrl: string;
category: string;
isConnected: boolean;
}
@customElement('lele-accountview-apps')
export class AppsView extends DeesElement {
@state()
accessor globalApps: IAppDisplay[] = [];
@state()
accessor loading: boolean = true;
@state()
accessor activeTab: 'global' | 'store' | 'custom' = 'global';
@state()
accessor organizationId: string = '';
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
cardStyles,
typographyStyles,
css`
:host {
display: block;
padding: 48px;
max-width: 1000px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 4px;
margin-bottom: 32px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.tab {
padding: 10px 20px;
border-radius: 8px 8px 0 0;
font-size: 14px;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.15s ease;
border: none;
background: transparent;
}
.tab:hover {
color: var(--foreground);
background: var(--muted);
}
.tab.active {
color: var(--foreground);
background: var(--muted);
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.app-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
transition: all 0.15s ease;
}
.app-card:hover {
border-color: var(--muted-foreground);
}
.app-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--muted);
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: 16px;
font-weight: 600;
color: var(--foreground);
margin: 0 0 4px 0;
}
.app-category {
font-size: 12px;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.app-description {
font-size: 14px;
color: var(--muted-foreground);
line-height: 1.5;
margin: 0 0 16px 0;
}
.app-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.app-link {
font-size: 13px;
color: var(--muted-foreground);
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
transition: color 0.15s ease;
}
.app-link:hover {
color: var(--foreground);
}
.app-link dees-icon {
font-size: 14px;
}
.toggle-container {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-label {
font-size: 13px;
color: var(--muted-foreground);
}
.empty-state {
text-align: center;
padding: 48px;
color: var(--muted-foreground);
}
.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: var(--muted-foreground);
}
.coming-soon {
text-align: center;
padding: 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
}
.coming-soon dees-icon {
font-size: 48px;
opacity: 0.5;
margin-bottom: 16px;
}
`,
];
public render() {
return html`
<h1>Apps</h1>
<p>Manage apps connected to your organization. Connect global apps, browse the AppStore, or create custom OAuth clients.</p>
<div class="tabs">
<button
class="tab ${this.activeTab === 'global' ? 'active' : ''}"
@click=${() => this.activeTab = 'global'}
>
Global Apps
</button>
<button
class="tab ${this.activeTab === 'store' ? 'active' : ''}"
@click=${() => this.activeTab = 'store'}
>
App Store
</button>
<button
class="tab ${this.activeTab === 'custom' ? 'active' : ''}"
@click=${() => this.activeTab = 'custom'}
>
Custom OIDC
</button>
</div>
${this.renderTabContent()}
`;
}
private renderTabContent() {
switch (this.activeTab) {
case 'global':
return this.renderGlobalApps();
case 'store':
return this.renderAppStore();
case 'custom':
return this.renderCustomOidc();
}
}
private renderGlobalApps() {
if (this.loading) {
return html`
<div class="loading">
<span>Loading apps...</span>
</div>
`;
}
if (this.globalApps.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:box'}></dees-icon>
<h2>No Global Apps Available</h2>
<p>There are no global apps configured yet.</p>
</div>
`;
}
return html`
<div class="app-grid">
${this.globalApps.map(app => html`
<div class="app-card">
<div class="app-header">
<div class="app-logo">
${app.logoUrl ? html`<img src="${app.logoUrl}" alt="${app.name}" />` : html`<dees-icon .icon=${'lucide:box'}></dees-icon>`}
</div>
<div class="app-info">
<h3 class="app-name">${app.name}</h3>
<span class="app-category">${app.category}</span>
</div>
</div>
<p class="app-description">${app.description}</p>
<div class="app-actions">
<a class="app-link" href="${app.appUrl}" target="_blank">
<dees-icon .icon=${'lucide:external-link'}></dees-icon>
Visit App
</a>
<div class="toggle-container">
<span class="toggle-label">${app.isConnected ? 'Connected' : 'Disconnected'}</span>
<dees-input-checkbox
.value=${app.isConnected}
@change=${(e: CustomEvent) => this.toggleAppConnection(app.id, e.detail)}
></dees-input-checkbox>
</div>
</div>
</div>
`)}
</div>
`;
}
private renderAppStore() {
return html`
<div class="coming-soon">
<dees-icon .icon=${'lucide:store'}></dees-icon>
<h2>App Store</h2>
<p>Browse and install partner apps from other organizations.</p>
<p><em>Coming soon in Phase 3</em></p>
</div>
`;
}
private renderCustomOidc() {
return html`
<div class="coming-soon">
<dees-icon .icon=${'lucide:key'}></dees-icon>
<h2>Custom OIDC Apps</h2>
<p>Create and manage your own OAuth/OIDC client applications.</p>
<p><em>Coming soon in Phase 2</em></p>
</div>
`;
}
public async firstUpdated() {
await this.loadApps();
}
private async loadApps() {
this.loading = true;
try {
// Get the organization ID from the URL
const pathParts = window.location.pathname.split('/');
const orgSlug = pathParts[3];
const currentState = accountState.accountState.getState();
const selectedOrg = currentState.organizations.find(org => org.data.slug === orgSlug);
if (!selectedOrg) {
console.error('Organization not found');
this.loading = false;
return;
}
this.organizationId = selectedOrg.id;
// Get JWT from IdpState
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
// Fetch global apps
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
'/typedrequest',
'getGlobalApps'
);
const appsResponse = await typedRequest.fire({
jwt,
});
// Fetch connections for this organization
const connectionsRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'/typedrequest',
'getAppConnections'
);
const connectionsResponse = await connectionsRequest.fire({
jwt,
organizationId: this.organizationId,
});
// Map apps with connection status
const connectionMap = new Map(
connectionsResponse.connections
.filter(c => c.data.status === 'active')
.map(c => [c.data.appId, true])
);
this.globalApps = appsResponse.apps.map(app => ({
id: app.id,
name: app.data.name,
description: app.data.description,
logoUrl: app.data.logoUrl,
appUrl: app.data.appUrl,
category: app.data.category,
isConnected: connectionMap.has(app.id),
}));
} catch (error) {
console.error('Error loading apps:', error);
} finally {
this.loading = false;
}
}
private async toggleAppConnection(appId: string, isConnected: boolean) {
try {
// Get JWT from IdpState
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
'/typedrequest',
'toggleAppConnection'
);
await typedRequest.fire({
jwt,
organizationId: this.organizationId,
appId: appId,
action: isConnected ? 'connect' : 'disconnect',
});
// Update local state
this.globalApps = this.globalApps.map(app =>
app.id === appId ? { ...app, isConnected } : app
);
} catch (error) {
console.error('Error toggling app connection:', error);
// Revert the checkbox on error
await this.loadApps();
}
}
}
+756 -170
View File
@@ -5,13 +5,14 @@ import {
property, property,
html, html,
cssManager, cssManager,
unsafeCSS,
css, css,
render, state,
directives, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js'; import { accountDesignTokens } from '../sharedstyles.js';
import * as accountStateModule from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -19,219 +20,804 @@ declare global {
} }
} }
import * as state from '../../../states/accountstate.js'; interface ISessionDisplay {
id: string;
deviceId: string;
deviceName: string;
browser: string;
os: string;
ip: string;
lastActive: number;
createdAt: number;
isCurrent: boolean;
}
interface IActivityDisplay {
id: string;
data: plugins.idpInterfaces.data.IActivityLog['data'];
}
@customElement('lele-accountview-baseview') @customElement('lele-accountview-baseview')
export class BaseView extends DeesElement { export class BaseView extends DeesElement {
@property({ @state()
type: Array, accessor loading: boolean = true;
})
accessor subscriptions: any[] = [ @state()
{ accessor sessions: ISessionDisplay[] = [];
organization: 'org1',
'subscription type': 'workspace.global SaaS', @state()
price: '4€', accessor activities: IActivityDisplay[] = [];
userFactor: 4,
total: '16.00€', @state()
}, accessor user: plugins.idpInterfaces.data.IUser | null = null;
{
organization: 'org1', @state()
'subscription type': 'workspace.global IaaS Base Access', accessor organizations: plugins.idpInterfaces.data.IOrganization[] = [];
price: '0€',
userFactor: 4, @state()
total: '0€', accessor roles: plugins.idpInterfaces.data.IRole[] = [];
},
{
organization: 'org1',
'subscription type': 'workspace.global SLA Senior',
price: '2000€',
userFactor: 'none',
total: '2000.00€',
},
];
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
accountDesignTokens, accountDesignTokens,
cardStyles,
typographyStyles,
css` css`
:host { :host {
display: block; display: block;
padding: 48px; min-height: 100%;
background: var(--background);
color: var(--foreground);
} }
.viewHost { .container {
max-width: 600px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
padding: 32px 24px;
}
.header {
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;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
} }
.card { .card {
background: var(--card); background: #18181b;
border: 1px solid var(--border); border: 1px solid #27272a;
border-radius: 12px; border-radius: 12px;
padding: 32px; overflow: hidden;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
} }
.slug { .card.full-width {
color: var(--foreground); grid-column: 1 / -1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #27272a;
}
.card-title {
font-size: 16px;
font-weight: 600; font-weight: 600;
font-family: 'Geist Mono', monospace; display: flex;
align-items: center;
gap: 8px;
} }
.hint { .card-title dees-icon {
display: block; opacity: 0.7;
font-size: 13px;
color: var(--muted-foreground);
margin: 16px 0;
padding: 12px 16px;
background: var(--muted);
border-radius: 8px;
} }
dees-form { .card-body {
padding: 16px 20px;
}
.card-body.no-padding {
padding: 0;
}
/* Profile Card */
.profile-info {
display: flex;
align-items: center;
gap: 16px;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.profile-details {
flex: 1;
min-width: 0;
}
.profile-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.profile-email {
font-size: 14px;
color: #71717a;
word-break: break-all;
}
/* Organizations */
.org-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
margin-top: 24px;
} }
.orgGrid { .org-item {
display: grid; display: flex;
grid-gap: 16px; align-items: center;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px;
margin-top: 24px; padding: 12px 20px;
} border-bottom: 1px solid #27272a;
.org {
padding: 20px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--foreground);
transition: all 0.15s ease;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease;
} }
.org:hover { .org-item:last-child {
background: var(--muted); border-bottom: none;
border-color: var(--muted-foreground);
} }
.org dees-icon { .org-item:hover {
background: #27272a;
}
.org-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.org-icon dees-icon {
opacity: 0.7; opacity: 0.7;
} }
.org-info {
flex: 1;
min-width: 0;
}
.org-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
}
.org-role {
font-size: 12px;
color: #71717a;
}
.role-badge {
padding: 4px 10px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.role-badge.admin {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.role-badge.owner {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
/* Sessions */
.session-list {
display: flex;
flex-direction: column;
}
.session-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid #27272a;
}
.session-item:last-child {
border-bottom: none;
}
.session-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.session-icon dees-icon {
opacity: 0.7;
}
.session-icon.current {
background: rgba(34, 197, 94, 0.1);
}
.session-icon.current dees-icon {
color: #22c55e;
opacity: 1;
}
.session-info {
flex: 1;
min-width: 0;
}
.session-device {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 8px;
}
.current-badge {
padding: 2px 8px;
border-radius: 9999px;
font-size: 10px;
font-weight: 500;
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.session-details {
font-size: 12px;
color: #71717a;
}
.session-actions {
flex-shrink: 0;
}
.revoke-btn {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
border: 1px solid #27272a;
background: transparent;
color: #fafafa;
cursor: pointer;
transition: all 0.15s ease;
}
.revoke-btn:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
/* Activity */
.activity-list {
display: flex;
flex-direction: column;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid #27272a;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.activity-icon dees-icon {
font-size: 14px;
opacity: 0.7;
}
.activity-icon.login {
background: rgba(34, 197, 94, 0.1);
}
.activity-icon.login dees-icon {
color: #22c55e;
opacity: 1;
}
.activity-icon.logout {
background: rgba(239, 68, 68, 0.1);
}
.activity-icon.logout dees-icon {
color: #ef4444;
opacity: 1;
}
.activity-info {
flex: 1;
min-width: 0;
}
.activity-description {
font-size: 14px;
margin-bottom: 2px;
}
.activity-time {
font-size: 12px;
color: #71717a;
}
/* Empty states */
.empty-state {
text-align: center;
padding: 32px 20px;
color: #71717a;
}
.empty-state dees-icon {
font-size: 32px;
opacity: 0.5;
margin-bottom: 12px;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Loading state */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: #71717a;
}
/* Create org button */
.create-org-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
border: 1px solid #27272a;
background: transparent;
color: #fafafa;
cursor: pointer;
transition: all 0.15s ease;
}
.create-org-btn:hover {
background: #27272a;
}
.create-org-btn dees-icon {
font-size: 14px;
}
`, `,
]; ];
public render() { public render(): TemplateResult {
if (this.loading) {
return html` return html`
<div class="viewHost"> <div class="container">
<div class="loading">Loading your account...</div>
</div> `;
}
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
await this.domtoolsPromise;
super.firstUpdated(_changedProperties);
const viewHost: HTMLDivElement = this.shadowRoot.querySelector('.viewHost');
await state.accountState.dispatchAction(state.getOrganizationsAction, null);
console.log('got orgs');
if (state.accountState.getState().organizations.length === 0) {
render(
html`
<div class="card">
<h1>Setup Your Account</h1>
<p>
There are no organizations for your account. Please create one now. Alternatively you
can ask an admin of an existing organization to invite you.
</p>
<dees-form>
<dees-input-text .label=${'Organization Name'} .key=${'orgName'}></dees-input-text>
</dees-form>
<p>
The organization slug will be:<br />
<span class="slug"
>${directives.subscribe(
state.accountState.select((stateArg) => stateArg.newOrg.chosenSlug)
)}</span
>
</p>
<span class="hint"></span>
<dees-button .disabled=${true}>Create the Organization</dees-button>
</div> </div>
`, `;
viewHost
);
const subscriptions: plugins.deesDomtools.plugins.smartrx.rxjs.Subscription[] = [];
const form = this.shadowRoot.querySelector('dees-form');
const orgInput = this.shadowRoot.querySelector('dees-input-text');
const hint = this.shadowRoot.querySelector('.hint');
const button = this.shadowRoot.querySelector('dees-button');
const newOrgSubscription = state.accountState
.select((stateArg) => stateArg.newOrg)
.subscribe((data) => {
if (data.chosenSlug) {
hint.innerHTML = 'Waiting: Validating...';
} else {
hint.innerHTML = 'Hint: Enter a valid organization name.';
} }
if (data.validated && data.validationOk) {
hint.innerHTML =
'Success: Name is available. Please click the button to create the organization.';
button.disabled = false;
} else if (!data.validated || !data.validationOk) {
hint.innerHTML = `Info: Name not available. Please choose another one.`;
button.disabled = true;
}
});
subscriptions.push(newOrgSubscription);
const formSubscription = form.changeSubject.subscribe(async (dataArg: any) => { const userInitial = this.user?.data?.username?.charAt(0).toUpperCase() ||
await state.accountState.dispatchAction(state.setNewOrgName, dataArg.orgName); this.user?.data?.email?.charAt(0).toUpperCase() || '?';
});
subscriptions.push(formSubscription);
button.addEventListener('clicked', async () => {
orgInput.disabled = true;
button.text = 'creating org...';
button.status = 'pending';
hint.innerHTML = 'Waiting for creation of the organization...';
await state.accountState.dispatchAction(state.manifestNewOrgName, null);
hint.innerHTML = `The Organization with name ${
state.accountState.getState().organizations[0].data.name
} has been created!`;
button.text = 'created!';
button.status = 'success';
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(
`/org/${state.accountState.getState().organizations[0].data.slug}/billing`
);
});
} else {
render(
html`
<h1>Select An Organization</h1>
<p>Choose an organization to manage its settings and billing.</p>
<div class="orgGrid">
${state.accountState.getState().organizations.map((orgArg) => {
return html` return html`
<div <div class="container">
class="org" <div class="header">
@click=${() => { <h1>Account Overview</h1>
state.accountState.dispatchAction(state.setSelectedOrg, orgArg); <p class="subtitle">Manage your profile, organizations, and security settings</p>
const parentElement = (this.getRootNode() as any).host; </div>
parentElement.subrouter.pushUrl(`/org/${orgArg.data.slug}/billing`);
}} <div class="dashboard-grid">
> <!-- Profile Card -->
<dees-icon .icon=${'lucide:building2'} style="display: inline-block; transform: translateY(3px); padding-right: 8px;"></dees-icon> ${orgArg.data.name} <div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:user'}></dees-icon>
Profile
</span>
</div>
<div class="card-body">
<div class="profile-info">
<div class="avatar">${userInitial}</div>
<div class="profile-details">
<div class="profile-name">${this.user?.data?.username || 'Unknown User'}</div>
<div class="profile-email">${this.user?.data?.email || 'No email'}</div>
</div>
</div>
</div>
</div>
<!-- Organizations Card -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
Organizations
</span>
<button class="create-org-btn" @click=${this.handleCreateOrg}>
<dees-icon .icon=${'lucide:plus'}></dees-icon>
New
</button>
</div>
<div class="card-body no-padding">
${this.renderOrganizations()}
</div>
</div>
<!-- Sessions Card -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:monitor-smartphone'}></dees-icon>
Active Sessions
</span>
</div>
<div class="card-body no-padding">
${this.renderSessions()}
</div>
</div>
<!-- Activity Card -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:activity'}></dees-icon>
Recent Activity
</span>
</div>
<div class="card-body no-padding">
${this.renderActivity()}
</div>
</div>
</div>
</div>
`;
}
private renderOrganizations(): TemplateResult {
if (this.organizations.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
<p>You're not a member of any organizations yet.</p>
</div>
`;
}
return html`
<div class="org-list">
${this.organizations.map((org) => {
const roleObj = this.roles.find(r => r.data.organizationId === org.id);
const roleName = roleObj?.data.role || 'member';
const roleClass = roleName === 'owner' ? 'owner' :
roleName === 'admin' ? 'admin' : '';
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
return html`
<div class="org-item" @click=${() => this.handleSelectOrg(org)}>
<div class="org-icon">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
</div>
<div class="org-info">
<div class="org-name">${org.data.name}</div>
<div class="org-role">${org.data.slug}</div>
</div>
<span class="role-badge ${roleClass}">${roleDisplay}</span>
</div> </div>
`; `;
})} })}
</div> </div>
`, `;
viewHost
);
} }
private renderSessions(): TemplateResult {
if (this.sessions.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:monitor'}></dees-icon>
<p>No active sessions found.</p>
</div>
`;
}
return html`
<div class="session-list">
${this.sessions.map((session) => html`
<div class="session-item">
<div class="session-icon ${session.isCurrent ? 'current' : ''}">
<dees-icon .icon=${this.getDeviceIcon(session.os)}></dees-icon>
</div>
<div class="session-info">
<div class="session-device">
${session.deviceName || 'Unknown Device'}
${session.isCurrent ? html`<span class="current-badge">Current</span>` : ''}
</div>
<div class="session-details">
${session.browser} · ${session.os} · Last active ${this.formatTimeAgo(session.lastActive)}
</div>
</div>
${!session.isCurrent ? html`
<div class="session-actions">
<button class="revoke-btn" @click=${() => this.handleRevokeSession(session.id)}>
Revoke
</button>
</div>
` : ''}
</div>
`)}
</div>
`;
}
private renderActivity(): TemplateResult {
if (this.activities.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:activity'}></dees-icon>
<p>No recent activity.</p>
</div>
`;
}
return html`
<div class="activity-list">
${this.activities.slice(0, 5).map((activity) => html`
<div class="activity-item">
<div class="activity-icon ${this.getActivityIconClass(activity.data.action)}">
<dees-icon .icon=${this.getActivityIcon(activity.data.action)}></dees-icon>
</div>
<div class="activity-info">
<div class="activity-description">${activity.data.metadata.description}</div>
<div class="activity-time">${this.formatTimeAgo(activity.data.timestamp)}</div>
</div>
</div>
`)}
</div>
`;
}
private getDeviceIcon(os: string): string {
const osLower = os?.toLowerCase() || '';
if (osLower.includes('mac') || osLower.includes('ios')) {
return 'lucide:laptop';
} else if (osLower.includes('android')) {
return 'lucide:smartphone';
} else if (osLower.includes('windows')) {
return 'lucide:monitor';
} else if (osLower.includes('linux')) {
return 'lucide:terminal';
}
return 'lucide:monitor';
}
private getActivityIcon(action: string): string {
switch (action) {
case 'login':
return 'lucide:log-in';
case 'logout':
return 'lucide:log-out';
case 'session_created':
return 'lucide:key';
case 'session_revoked':
return 'lucide:shield-off';
case 'org_created':
return 'lucide:building-2';
case 'org_joined':
return 'lucide:user-plus';
case 'org_left':
return 'lucide:user-minus';
case 'role_changed':
return 'lucide:shield';
case 'profile_updated':
return 'lucide:user-cog';
case 'app_connected':
return 'lucide:plug';
case 'app_disconnected':
return 'lucide:unplug';
default:
return 'lucide:activity';
}
}
private getActivityIconClass(action: string): string {
if (action === 'login' || action === 'session_created' || action === 'org_joined' || action === 'app_connected') {
return 'login';
}
if (action === 'logout' || action === 'session_revoked' || action === 'org_left' || action === 'app_disconnected') {
return 'logout';
}
return '';
}
private formatTimeAgo(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString();
}
public async firstUpdated() {
await this.loadDashboardData();
}
private async loadDashboardData() {
this.loading = true;
try {
const idpState = await IdpState.getSingletonInstance();
// Load organizations and roles from account state
await accountStateModule.accountState.dispatchAction(accountStateModule.getOrganizationsAction, null);
const state = accountStateModule.accountState.getState();
this.organizations = state.organizations;
this.roles = state.roles;
this.user = state.user;
// Load sessions
await this.loadSessions();
// Load activity
await this.loadActivity();
} catch (error) {
console.error('Error loading dashboard data:', error);
} finally {
this.loading = false;
}
}
private async loadSessions() {
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'/typedrequest',
'getUserSessions'
);
const response = await typedRequest.fire({ jwt });
this.sessions = response?.sessions ?? [];
} catch (error) {
console.error('Error loading sessions:', error);
this.sessions = [];
}
}
private async loadActivity() {
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'/typedrequest',
'getUserActivity'
);
const response = await typedRequest.fire({ jwt, limit: 10 });
this.activities = response?.activities ?? [];
} catch (error) {
console.error('Error loading activity:', error);
this.activities = [];
}
}
private async handleRevokeSession(sessionId: string) {
if (!confirm('Are you sure you want to revoke this session? The device will be logged out.')) {
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
'/typedrequest',
'revokeSession'
);
await typedRequest.fire({ jwt, sessionId });
await this.loadSessions();
} catch (error) {
console.error('Error revoking session:', error);
alert('Failed to revoke session');
}
}
private handleSelectOrg(org: plugins.idpInterfaces.data.IOrganization) {
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
}
private handleCreateOrg() {
// Dispatch event to open create org modal
this.dispatchEvent(new CustomEvent('open-create-org-modal', {
bubbles: true,
composed: true,
}));
} }
} }
+3
View File
@@ -1,4 +1,7 @@
export * from './adminview.js';
export * from './appsview.js';
export * from './baseview.js'; export * from './baseview.js';
export * from './orgsetup.js'; export * from './orgsetup.js';
export * from './orgview.js';
export * from './paddlesetup.js'; export * from './paddlesetup.js';
export * from './subscriptions.js'; export * from './subscriptions.js';
+514
View File
@@ -0,0 +1,514 @@
import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from '../sharedstyles.js';
import * as accountStateModule from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
declare global {
interface HTMLElementTagNameMap {
'lele-accountview-orgview': OrgView;
}
}
interface IOrgStats {
memberCount: number;
appCount: number;
}
@customElement('lele-accountview-orgview')
export class OrgView extends DeesElement {
@state()
accessor loading: boolean = true;
@state()
accessor organization: plugins.idpInterfaces.data.IOrganization | null = null;
@state()
accessor userRole: plugins.idpInterfaces.data.IRole | null = null;
@state()
accessor stats: IOrgStats = { memberCount: 0, appCount: 0 };
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: block;
min-height: 100%;
background: var(--background);
color: var(--foreground);
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 32px 24px;
}
.header {
margin-bottom: 32px;
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 12px;
}
h1 dees-icon {
opacity: 0.7;
}
.subtitle {
color: #71717a;
margin-top: 8px;
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
.stat-card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
overflow: hidden;
}
.card.full-width {
grid-column: 1 / -1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #27272a;
}
.card-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card-title dees-icon {
opacity: 0.7;
}
.card-body {
padding: 16px 20px;
}
.card-body.no-padding {
padding: 0;
}
/* Info rows */
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #27272a;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: 13px;
color: #71717a;
}
.info-value {
font-size: 14px;
font-weight: 500;
}
.info-value.slug {
font-family: 'Geist Mono', monospace;
background: #27272a;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
}
/* Role badge */
.role-badge {
padding: 4px 12px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.role-badge.admin {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.role-badge.owner {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
/* Quick actions */
.action-list {
display: flex;
flex-direction: column;
}
.action-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid #27272a;
cursor: pointer;
transition: background 0.15s ease;
}
.action-item:last-child {
border-bottom: none;
}
.action-item:hover {
background: #27272a;
}
.action-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-item:hover .action-icon {
background: #3f3f46;
}
.action-icon dees-icon {
opacity: 0.7;
}
.action-info {
flex: 1;
}
.action-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
}
.action-description {
font-size: 12px;
color: #71717a;
}
.action-arrow {
color: #71717a;
}
/* Billing status */
.billing-status {
display: flex;
align-items: center;
gap: 8px;
}
.billing-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #71717a;
}
.billing-indicator.active {
background: #22c55e;
}
.billing-indicator.none {
background: #f59e0b;
}
/* Loading state */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: #71717a;
}
`,
];
public render(): TemplateResult {
if (this.loading) {
return html`
<div class="container">
<div class="loading">Loading organization...</div>
</div>
`;
}
if (!this.organization) {
return html`
<div class="container">
<div class="loading">Organization not found</div>
</div>
`;
}
const roleName = this.userRole?.data.role || 'member';
const roleClass = roleName === 'owner' ? 'owner' : roleName === 'admin' ? 'admin' : '';
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
return html`
<div class="container">
<div class="header">
<h1>
<dees-icon .icon=${'lucide:building2'}></dees-icon>
${this.organization.data.name}
</h1>
<p class="subtitle">Organization dashboard and settings</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${this.stats.memberCount}</div>
<div class="stat-label">Members</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.stats.appCount}</div>
<div class="stat-label">Connected Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">
<span class="role-badge ${roleClass}">${roleDisplay}</span>
</div>
<div class="stat-label">Your Role</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Organization Info -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:info'}></dees-icon>
Organization Info
</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">${this.organization.data.name}</span>
</div>
<div class="info-row">
<span class="info-label">Slug</span>
<span class="info-value slug">${this.organization.data.slug}</span>
</div>
<div class="info-row">
<span class="info-label">Billing</span>
<span class="info-value">
<div class="billing-status">
<span class="billing-indicator ${this.organization.data.billingPlanId ? 'active' : 'none'}"></span>
${this.organization.data.billingPlanId ? 'Active' : 'Not configured'}
</div>
</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:zap'}></dees-icon>
Quick Actions
</span>
</div>
<div class="card-body no-padding">
<div class="action-list">
<div class="action-item" @click=${this.navigateToApps}>
<div class="action-icon">
<dees-icon .icon=${'lucide:box'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">Manage Apps</div>
<div class="action-description">Connect and configure applications</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
<div class="action-item" @click=${this.navigateToBilling}>
<div class="action-icon">
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">View Billing</div>
<div class="action-description">Manage subscription and invoices</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
<div class="action-item" @click=${this.handleInviteUser}>
<div class="action-icon">
<dees-icon .icon=${'lucide:user-plus'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">Invite Member</div>
<div class="action-description">Add team members to your organization</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
</div>
</div>
</div>
</div>
</div>
`;
}
public async firstUpdated() {
await this.loadOrgData();
}
private async loadOrgData() {
this.loading = true;
try {
// Get the organization slug from the URL
const pathParts = window.location.pathname.split('/');
const orgSlug = pathParts[3];
const currentState = accountStateModule.accountState.getState();
const selectedOrg = currentState.organizations.find(org => org.data.slug === orgSlug);
if (!selectedOrg) {
console.error('Organization not found');
this.loading = false;
return;
}
this.organization = selectedOrg;
// Find user's role in this org
this.userRole = currentState.roles.find(r => r.data.organizationId === selectedOrg.id) || null;
// Calculate stats
const memberCount = selectedOrg.data.roleIds?.length || 1;
// Get app connections count
let appCount = 0;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const connectionsRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'/typedrequest',
'getAppConnections'
);
const connectionsResponse = await connectionsRequest.fire({
jwt,
organizationId: selectedOrg.id,
});
appCount = connectionsResponse.connections?.filter(c => c.data.status === 'active').length || 0;
} catch (error) {
console.error('Error loading app connections:', error);
}
this.stats = { memberCount, appCount };
} catch (error) {
console.error('Error loading org data:', error);
} finally {
this.loading = false;
}
}
private async navigateToApps() {
if (!this.organization) return;
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${this.organization.data.slug}/apps`);
}
private async navigateToBilling() {
if (!this.organization) return;
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${this.organization.data.slug}/billing`);
}
private handleInviteUser() {
// TODO: Implement invite user modal
alert('Invite member functionality coming soon');
}
}
+5 -11
View File
@@ -54,26 +54,24 @@ export class PaddleSetupView extends DeesElement {
Paddle takes care of tax compliance for us. This allows us to sell our products world wide Paddle takes care of tax compliance for us. This allows us to sell our products world wide
while Paddle makes sure any sales are in compliance with local laws. while Paddle makes sure any sales are in compliance with local laws.
</p> </p>
<dees-button>Let's do it!</dees-button> <dees-button @clicked=${() => this.openPaddle()}>Let's do it!</dees-button>
`; `;
} }
/** public async openPaddle() {
*
*/
public async firstUpdated() {
await this.domtoolsPromise; await this.domtoolsPromise;
const paddleButton = this.shadowRoot.querySelector('dees-button'); const paddleButton = this.shadowRoot.querySelector('dees-button');
const openPaddle = async () => {
await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/paddle.js'); await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/paddle.js');
globalThis.Paddle.Setup({ globalThis.Paddle.Setup({
vendor: 30954, vendor: 30954,
eventCallback: async (dataArg) => { eventCallback: async (dataArg: any) => {
// The data.event will specify the event type // The data.event will specify the event type
if (dataArg.event === 'Checkout.Complete') { if (dataArg.event === 'Checkout.Complete') {
const data: plugins.idpInterfaces.data.IPaddleCheckoutData = dataArg.eventData; const data: plugins.idpInterfaces.data.IPaddleCheckoutData = dataArg.eventData;
const paddleIframe = document.body.querySelector('iframe'); const paddleIframe = document.body.querySelector('iframe');
if (paddleIframe) {
document.body.removeChild(paddleIframe); document.body.removeChild(paddleIframe);
}
paddleButton.status = 'pending'; paddleButton.status = 'pending';
paddleButton.text = 'Processing...'; paddleButton.text = 'Processing...';
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, data.checkout.id); await state.accountState.dispatchAction(state.updatePaddleCheckoutId, data.checkout.id);
@@ -86,9 +84,5 @@ export class PaddleSetupView extends DeesElement {
product: 561076, product: 561076,
email: 'phil@kunz.io', email: 'phil@kunz.io',
}); });
};
paddleButton.addEventListener('clicked', async () => {
openPaddle();
});
} }
} }
@@ -100,8 +100,12 @@ export class SubscriptionView extends DeesElement {
<h3>Paddle</h3> <h3>Paddle</h3>
<dees-button @click=${async () => { <dees-button @click=${async () => {
await this.domtoolsPromise; // Extract org slug from current URL: /account/org/{orgSlug}/billing
this.domtools.router.pushUrl(`/org/${state.accountState.getState().selectedOrg.data.slug}/paddlesetup`) const pathParts = window.location.pathname.split('/');
const orgSlug = pathParts[3];
// Use parent's subrouter for proper navigation within account section
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${orgSlug}/paddlesetup`);
}}>Set up Paddle.com</dees-button> }}>Set up Paddle.com</dees-button>
<h3>Enterprise Billing</h3> <h3>Enterprise Billing</h3>
+1 -1
View File
@@ -21,7 +21,7 @@ declare global {
@customElement('idp-transfermanager') @customElement('idp-transfermanager')
export class IdpTransfermanager extends DeesElement { export class IdpTransfermanager extends DeesElement {
public appData: plugins.idpInterfaces.data.IApp; public appData: plugins.idpInterfaces.data.IAppLegacy;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
+5
View File
@@ -46,6 +46,11 @@ export const getOrganizationsAction = accountState.createAction<void>(
const response = await idpState.idpClient.getRolesAndOrganizations(); const response = await idpState.idpClient.getRolesAndOrganizations();
currentState.organizations = response.organizations; currentState.organizations = response.organizations;
currentState.roles = response.roles; 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; return currentState;
} }
); );