Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02c700e44d | |||
| e9f1b5dac9 | |||
| 6645806a87 | |||
| dc3f232f43 | |||
| cc9d56ff4b | |||
| 47ca5934a6 | |||
| dddd968796 | |||
| 2cdf86744e | |||
| 9d9f90c1d5 | |||
| 833cf3b4b8 | |||
| 8df44b99b9 | |||
| d32103618f | |||
| a83858beb0 | |||
| 5f29edf449 | |||
| 173735a84e | |||
| 8756258324 | |||
| d11f5a0c72 | |||
| cc040e5088 | |||
| af0c24f7ca | |||
| fd089b2cee | |||
| 6b04c529da | |||
| f54588e877 | |||
| ff1387df9f | |||
| 401d35186f | |||
| 9d012cd59f | |||
| b541340ca5 | |||
| 531909e88c | |||
| e92bdeaa2b | |||
| 19f016a476 | |||
| 014fb3080a | |||
| c8b8013200 | |||
| 0b8639b033 | |||
| 08828d6771 |
+106
@@ -1,5 +1,111 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-15 - 1.12.1 - fix(dependencies)
|
||||
fix(deps): bump @uptime.link/webwidget to ^1.2.6
|
||||
|
||||
- Updated dependency @uptime.link/webwidget from ^1.2.5 to ^1.2.6 in package.json
|
||||
- No other files changed; this is a dependency patch update
|
||||
|
||||
## 2025-12-15 - 1.12.0 - feat(interfaces)
|
||||
Add JWT public-key and blocklist request interfaces, publish ordering files, and update dependencies
|
||||
|
||||
- Introduce IReq_GetPublicKeyForValidation and IReq_PushPublicKeyForValidation with documentation in ts_interfaces/request/loint-reception.jwt.ts to support fetching and pushing JWT public keys for validation.
|
||||
- Clarify IReq_PushOrGetJwtIdBlocklist to describe both GET (client requests blocklist) and PUSH (server pushes revoked JWT IDs) directions and required client handlers.
|
||||
- Add tspublish.json ordering files for packaging: ts_interfaces (order: 1), ts (order: 2), ts_idpclient (order: 3), ts_web (order: 4).
|
||||
- Update package.json dependencies to include @git.zone/tspublish and additional @push.rocks packages (@push.rocks/smartcli, @push.rocks/smartfile, @push.rocks/smartinteract).
|
||||
|
||||
## 2025-12-14 - 1.11.0 - feat(idpcli)
|
||||
Add idp CLI (IdpCli) with commands, file-based credential storage, typed request APIs; bump deps and update config
|
||||
|
||||
- Introduce a new CLI implementation under ts_idpcli: IdpCli class, runCli entrypoint and multiple commands (login, login-token, logout, whoami, orgs, orgs-create, members, invite, sessions, revoke, admin-check, admin-apps, admin-suspend, etc.).
|
||||
- Add plugins module that exports node built-ins and common libraries (smartcli, smartinteract, smartpromise, smartrx, typedrequest, typedsocket) for the CLI.
|
||||
- Expose many typed request accessors in classes.idprequests (authentication, registration, user/org/member management, billing, JWT/key management, admin operations).
|
||||
- Implement file-based credential storage (~/.idp-global/credentials.json) with load/store/delete helpers to persist refresh tokens and JWTs for the CLI.
|
||||
- Update ts/index.ts to start the website server on port 2999 (was previously started without explicit port).
|
||||
- Bump and add dependencies/devDependencies: @api.global/typedserver -> ^7.11.1, @design.estate/dees-catalog -> ^3.3.1, @push.rocks/smartjson -> ^6.0.0; add @push.rocks/smartcli, smartfile, smartinteract; upgrade @git.zone/tsbuild to ^4.0.2 and update tsrun/tswatch versions.
|
||||
- Rework npmextra.json: reorganized npmci and tsdoc sections, added release configuration (registries and accessLevel) and other npmci/docker mapping entries.
|
||||
|
||||
## 2025-12-07 - 1.10.0 - feat(billingplan)
|
||||
Add Paddle v2 checkout support and backend config endpoint; add CSP headers and bump typedserver
|
||||
|
||||
- Add getPaddleConfig typedrequest handler in BillingPlanManager to expose PADDLE_TOKEN and PADDLE_PRICE_ID from environment.
|
||||
- Introduce IReq_GetPaddleConfig typedrequest interface.
|
||||
- Update frontend paddlesetup to use Paddle v2: load v2 script, call Paddle.Initialize with token, open Checkout using items.priceId and customer.email, and handle checkout.completed events (store transaction_id).
|
||||
- Attempt to obtain user email from account state or via idpClient.whoIs before starting checkout; show error if email unavailable.
|
||||
- Add Content Security Policy securityHeaders to website server configuration to allow Paddle, ProfitWell, Sentry and related assets/connections.
|
||||
- Bump dependency @api.global/typedserver from ^7.8.17 to ^7.10.2.
|
||||
|
||||
## 2025-12-01 - 1.9.0 - feat(account)
|
||||
Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking
|
||||
|
||||
- Replace inline modal elements with programmatic / static show() calls for OrgSelectModal and CreateOrgModal; navigation now reacts to the results returned from show() and pushes appropriate URLs.
|
||||
- Remove embedded <idp-org-select-modal> and <idp-create-org-modal> elements from the account template to use on-demand modal invocation.
|
||||
- Navigation component now exposes currentPath state, listens to popstate, and watches for external URL changes (requestAnimationFrame loop) to keep UI in sync with location changes.
|
||||
- Updated readme.hints.md with guidance for dees-catalog components and clarified dees-input-* event pattern (use RxJS Subjects, subscribe to changeSubject and access element.value).
|
||||
|
||||
## 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)
|
||||
Refactor account UI styles into reusable design tokens, apply updated styles across views and fix login submit behavior
|
||||
|
||||
- Introduce accountDesignTokens and split shared styles into tokens (accountDesignTokens), cardStyles and typographyStyles while keeping a legacy default export for compatibility
|
||||
- Apply new design tokens to account components (content, baseview, subscriptions) and switch background to use CSS variable (--background)
|
||||
- Small UI tweaks: smoother transition easing on view container, updated icon for organization entries and adjusted spacing
|
||||
- Add placeholder sections for Upcoming Billable Items and Past Invoices in subscriptions view
|
||||
- Fix login prompt submit handling by disabling the submit button via its #loginSubmitButton selector and improving button text logic
|
||||
|
||||
## 2025-04-03 - 1.4.3 - fix(website)
|
||||
Update packageManager configuration in package.json and refine view container background styling
|
||||
|
||||
- Add 'packageManager' field in package.json to pin pnpm version
|
||||
- Adjust background style in ts_web/views/viewcontainer.ts for improved UI consistency
|
||||
|
||||
## 2024-12-11 - 1.5.0 - feat(UI)
|
||||
Added 'Learn more about idp.global' button
|
||||
|
||||
- Added a new button for learning more about idp.global in the welcome component
|
||||
|
||||
## 2024-12-11 - 1.5.0 - feat(UI)
|
||||
Added 'Learn more about idp.global' button
|
||||
|
||||
- Added a new button for learning more about idp.global in the welcome component
|
||||
|
||||
## 2024-10-12 - 1.4.2 - fix(UI)
|
||||
Improve text rendering in account navigation.
|
||||
|
||||
|
||||
+19
-14
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "registry.npmjs.org"
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "website",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -31,19 +44,11 @@
|
||||
"user data",
|
||||
"user sessions"
|
||||
]
|
||||
},
|
||||
"services": ["mongodb", "minio"],
|
||||
"release": {
|
||||
"registries": ["https://verdaccio.lossless.digital"],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "registry.npmjs.org"
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
}
|
||||
}
|
||||
+34
-29
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@idp.global/idp.global",
|
||||
"version": "1.4.2",
|
||||
"version": "1.12.1",
|
||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
@@ -16,45 +16,49 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.0.32",
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^3.0.51",
|
||||
"@api.global/typedsocket": "^3.0.1",
|
||||
"@consentsoftware_private/catalog": "^1.0.73",
|
||||
"@design.estate/dees-catalog": "^1.2.0",
|
||||
"@design.estate/dees-domtools": "^2.0.64",
|
||||
"@design.estate/dees-element": "^2.0.39",
|
||||
"@push.rocks/lik": "^6.0.15",
|
||||
"@push.rocks/qenv": "^6.0.5",
|
||||
"@push.rocks/smartdata": "^5.2.10",
|
||||
"@api.global/typedserver": "^7.11.1",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@consent.software/catalog": "^2.0.1",
|
||||
"@design.estate/dees-catalog": "^3.3.1",
|
||||
"@design.estate/dees-domtools": "^2.3.6",
|
||||
"@design.estate/dees-element": "^2.1.3",
|
||||
"@git.zone/tspublish": "^1.10.3",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartcli": "^4.0.19",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smarthash": "^3.0.4",
|
||||
"@push.rocks/smartjson": "^5.0.20",
|
||||
"@push.rocks/smartfile": "^13.1.0",
|
||||
"@push.rocks/smarthash": "^3.2.6",
|
||||
"@push.rocks/smartinteract": "^2.0.6",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.0.7",
|
||||
"@push.rocks/smartmail": "^1.0.24",
|
||||
"@push.rocks/smartpath": "^5.0.5",
|
||||
"@push.rocks/smartpromise": "^4.0.4",
|
||||
"@push.rocks/smartrx": "^3.0.7",
|
||||
"@push.rocks/smartstate": "^2.0.19",
|
||||
"@push.rocks/smarttime": "^4.0.8",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartmail": "^2.2.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.27",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smarturl": "^3.1.0",
|
||||
"@push.rocks/taskbuffer": "^3.1.7",
|
||||
"@push.rocks/taskbuffer": "^3.5.0",
|
||||
"@push.rocks/webjwt": "^1.0.9",
|
||||
"@push.rocks/websetup": "^3.0.15",
|
||||
"@push.rocks/webstore": "^2.0.20",
|
||||
"@serve.zone/platformclient": "^1.1.2",
|
||||
"@tsclass/tsclass": "^4.1.2",
|
||||
"@uptime.link/webwidget": "^1.1.2"
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"@uptime.link/webwidget": "^1.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.17",
|
||||
"@git.zone/tsbundle": "^2.0.3",
|
||||
"@git.zone/tsrun": "^1.2.8",
|
||||
"@git.zone/tswatch": "^2.0.1",
|
||||
"@git.zone/tsbuild": "^4.0.2",
|
||||
"@git.zone/tsbundle": "^2.6.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tswatch": "^2.3.13",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@types/node": "^22.7.4"
|
||||
"@types/node": "^24.10.1"
|
||||
},
|
||||
"private": true,
|
||||
"repository": {
|
||||
@@ -101,5 +105,6 @@
|
||||
"API",
|
||||
"user data",
|
||||
"user sessions"
|
||||
]
|
||||
],
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
}
|
||||
|
||||
Generated
+5195
-5457
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -1,3 +1,23 @@
|
||||
# Project Readme Hints
|
||||
|
||||
This is the initial readme hints file.
|
||||
## UI Components
|
||||
Always check dees-catalog for available elements before implementing custom solutions:
|
||||
- Documentation: https://code.foss.global/design.estate/dees-catalog
|
||||
- Key components: `dees-modal`, `dees-button`, `dees-input-*`, `dees-form`, etc.
|
||||
|
||||
### dees-input-* Event Pattern
|
||||
All dees-input components use **RxJS Subjects** for value changes, NOT DOM events:
|
||||
```typescript
|
||||
// Subscribe to value changes in firstUpdated():
|
||||
const inputElement = this.shadowRoot.querySelector('dees-input-text');
|
||||
inputElement.changeSubject.subscribe((element) => {
|
||||
const value = element.value;
|
||||
// handle value change
|
||||
});
|
||||
```
|
||||
- Do NOT use `@changeValue` or similar DOM events - they don't exist
|
||||
- The Subject emits the element itself, access value via `element.value`
|
||||
|
||||
## Project Structure
|
||||
- `ts_web/elements/account/` - Account dashboard components
|
||||
- `ts_web/states/` - State management (accountstate, idp.state)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# idp.global User Stories
|
||||
|
||||
This directory contains user stories for the idp.global Identity Provider platform, organized by persona.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
stories/
|
||||
├── end-user/ # Stories for regular users (8)
|
||||
├── organization-owner/ # Stories for organization admins (11)
|
||||
├── developer/ # Stories for API/SDK consumers (8)
|
||||
└── admin/ # Stories for platform administrators (8)
|
||||
```
|
||||
|
||||
## Story Index
|
||||
|
||||
### End User (EU)
|
||||
| ID | Title | Priority | Source |
|
||||
|----|-------|----------|--------|
|
||||
| EU-001 | [Multi-Device Login Sessions](end-user/EU-001-multi-device-login.md) | High | TODO |
|
||||
| EU-002 | [Complete Password Reset Flow](end-user/EU-002-password-reset.md) | Critical | Incomplete |
|
||||
| EU-003 | [View and Manage Logged-in Devices](end-user/EU-003-device-management.md) | Medium | TODO |
|
||||
| EU-004 | [Enable Two-Factor Authentication](end-user/EU-004-two-factor-auth.md) | High | New |
|
||||
| EU-005 | [Login with Social Providers](end-user/EU-005-social-login.md) | Medium | New |
|
||||
| EU-006 | [Delete My Account](end-user/EU-006-account-deletion.md) | Medium | New |
|
||||
| EU-007 | [View Login History](end-user/EU-007-session-history.md) | Low | New |
|
||||
| EU-008 | [Upload Profile Avatar](end-user/EU-008-profile-avatar.md) | Low | New |
|
||||
|
||||
### Organization Owner (ORG)
|
||||
| ID | Title | Priority | Source |
|
||||
|----|-------|----------|--------|
|
||||
| ORG-001 | [Sync Billing Plans with Users](organization-owner/ORG-001-billing-sync.md) | High | TODO |
|
||||
| ORG-002 | [Invite and Manage Team Members](organization-owner/ORG-002-member-management.md) | Critical | Complete |
|
||||
| ORG-003 | [Assign Roles to Members](organization-owner/ORG-003-role-assignment.md) | High | Partial |
|
||||
| ORG-004 | [Customize Organization Branding](organization-owner/ORG-004-org-branding.md) | Medium | New |
|
||||
| ORG-005 | [View Organization Usage Analytics](organization-owner/ORG-005-usage-analytics.md) | Medium | 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-008 | [Manage Subscription and Billing](organization-owner/ORG-008-subscription-management.md) | Medium | Enhance |
|
||||
| ORG-009 | [Connect Global Apps](organization-owner/ORG-009-global-apps.md) | High | New |
|
||||
| ORG-010 | [Browse and Install Partner Apps](organization-owner/ORG-010-app-store.md) | Medium | New |
|
||||
| ORG-011 | [Create Custom OIDC Apps](organization-owner/ORG-011-custom-oidc-apps.md) | Medium | New |
|
||||
|
||||
### Developer (DEV)
|
||||
| ID | Title | Priority | Source |
|
||||
|----|-------|----------|--------|
|
||||
| DEV-001 | [Create and Manage API Tokens](developer/DEV-001-api-token-management.md) | High | Partial |
|
||||
| DEV-002 | [Comprehensive SDK Documentation](developer/DEV-002-sdk-documentation.md) | High | New |
|
||||
| DEV-003 | [Configure Webhook Notifications](developer/DEV-003-webhook-events.md) | Medium | New |
|
||||
| DEV-004 | [Proper App ID Initialization](developer/DEV-004-app-id-setup.md) | High | TODO |
|
||||
| DEV-005 | [Register OAuth Client App](developer/DEV-005-oauth-client.md) | Medium | New |
|
||||
| DEV-006 | [Understand API Rate Limits](developer/DEV-006-rate-limiting.md) | Low | New |
|
||||
| DEV-007 | [Validate JWTs in My Application](developer/DEV-007-jwt-validation.md) | Medium | Enhance |
|
||||
| DEV-008 | [Submit App to AppStore](developer/DEV-008-submit-partner-app.md) | Low | New |
|
||||
|
||||
### Platform Admin (ADM)
|
||||
| ID | Title | Priority | Source |
|
||||
|----|-------|----------|--------|
|
||||
| ADM-001 | [Secure JWT Endpoints with Backend Token](admin/ADM-001-backend-token-security.md) | Critical | TODO |
|
||||
| ADM-002 | [Suspend and Delete Users](admin/ADM-002-user-suspension.md) | High | Partial |
|
||||
| ADM-003 | [Platform-wide Audit Logging](admin/ADM-003-global-audit-log.md) | High | New |
|
||||
| ADM-004 | [Customize Email Templates](admin/ADM-004-email-templates.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-007 | [Manage JWT Blocklist](admin/ADM-007-blocklist-management.md) | Medium | Enhance |
|
||||
| ADM-008 | [Manage Global Apps](admin/ADM-008-global-app-management.md) | High | In Development |
|
||||
|
||||
## Priority Summary
|
||||
|
||||
| Priority | Count | Stories |
|
||||
|----------|-------|---------|
|
||||
| Critical | 2 | EU-002, ADM-001 |
|
||||
| High | 12 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003, ADM-008 |
|
||||
| Medium | 14 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, ORG-010, ORG-011, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 |
|
||||
| Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
|
||||
|
||||
## Source Legend
|
||||
|
||||
- **TODO**: Derived from TODO comments in codebase
|
||||
- **Incomplete**: Feature exists but implementation is incomplete
|
||||
- **Partial**: Infrastructure exists, needs completion
|
||||
- **Enhance**: Feature works, could be improved
|
||||
- **New**: New feature not currently in codebase
|
||||
|
||||
## Related Code References
|
||||
|
||||
Stories derived from code TODOs reference these files:
|
||||
- `ts/reception/classes.jwt.ts:39`
|
||||
- `ts/reception/classes.jwtmanager.ts:40,52`
|
||||
- `ts/reception/classes.loginsessionmanager.ts:229-238,256`
|
||||
- `ts/reception/classes.billingplan.ts:16`
|
||||
- `ts_idpclient/classes.idpclient.ts:30`
|
||||
@@ -0,0 +1,28 @@
|
||||
# Secure JWT Endpoints with Backend Token
|
||||
|
||||
**ID:** ADM-001
|
||||
**Priority:** Critical
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a platform administrator, I want JWT-related endpoints to be secured with backend token validation so that only authorized services can access sensitive security operations.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Public key endpoint requires valid backend token
|
||||
- [ ] JWT blocklist endpoint requires valid backend token
|
||||
- [ ] Backend tokens are securely generated and distributed
|
||||
- [ ] Token validation is performed on every request
|
||||
- [ ] Invalid/missing token returns 401 Unauthorized
|
||||
- [ ] Tokens can be rotated without service interruption
|
||||
- [ ] Audit log for all backend token usage
|
||||
|
||||
## Technical Notes
|
||||
- Two TODOs exist for backend token validation in JwtManager
|
||||
- `getPublicKeyForValidation` and `pushOrGetJwtIdBlocklist` need protection
|
||||
- Backend token should be separate from user JWT
|
||||
- Consider service-to-service authentication pattern
|
||||
- Environment variable for backend token configuration
|
||||
|
||||
## Related TODOs
|
||||
- `ts/reception/classes.jwtmanager.ts:40` - `// TODO control backend token`
|
||||
- `ts/reception/classes.jwtmanager.ts:52` - `// TODO control backend token`
|
||||
@@ -0,0 +1,28 @@
|
||||
# Suspend and Delete Users
|
||||
|
||||
**ID:** ADM-002
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a platform administrator, I want to suspend and delete user accounts so that I can handle policy violations, security incidents, and account removal requests.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Admin can search for users by email, name, or ID
|
||||
- [ ] Admin can suspend a user account with reason
|
||||
- [ ] Suspended users cannot log in
|
||||
- [ ] Suspended users' active sessions are invalidated
|
||||
- [ ] Admin can unsuspend accounts
|
||||
- [ ] Admin can permanently delete suspended accounts
|
||||
- [ ] Deletion removes all user data (GDPR compliance)
|
||||
- [ ] Audit log for all suspension/deletion actions
|
||||
|
||||
## Technical Notes
|
||||
- `suspendUser` and `deleteSuspendedUser` endpoints exist
|
||||
- Need admin UI for user management
|
||||
- Consider soft delete with retention period
|
||||
- Handle organization ownership before deletion
|
||||
- Email notification to user on suspension
|
||||
|
||||
## Related TODOs
|
||||
- Partial implementation in UserManager
|
||||
@@ -0,0 +1,28 @@
|
||||
# Platform-wide Audit Logging
|
||||
|
||||
**ID:** ADM-003
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a platform administrator, I want to view platform-wide audit logs so that I can monitor security events, investigate incidents, and demonstrate compliance.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Log all authentication events (login, logout, failed attempts)
|
||||
- [ ] Log all administrative actions (user changes, config changes)
|
||||
- [ ] Log all security events (password changes, 2FA changes, token revocations)
|
||||
- [ ] Searchable log interface with filters
|
||||
- [ ] Real-time log streaming for monitoring
|
||||
- [ ] Export logs in standard formats (JSON, CSV, CEF)
|
||||
- [ ] Log retention configuration
|
||||
- [ ] Integration with external SIEM systems
|
||||
|
||||
## Technical Notes
|
||||
- Separate from organization audit logs (ORG-007)
|
||||
- Platform-wide view across all organizations
|
||||
- Consider ELK stack or similar for log aggregation
|
||||
- Structured logging format for parsing
|
||||
- Compliance: SOC 2, ISO 27001, GDPR audit requirements
|
||||
|
||||
## Related TODOs
|
||||
- New feature - platform security requirement
|
||||
@@ -0,0 +1,28 @@
|
||||
# Customize Email Templates
|
||||
|
||||
**ID:** ADM-004
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a platform administrator, I want to customize email templates so that all system emails match our branding and communication style.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Edit templates for: registration, password reset, login verification, welcome
|
||||
- [ ] Rich text editor for template content
|
||||
- [ ] Variable placeholders ({{userName}}, {{resetLink}}, etc.)
|
||||
- [ ] Preview emails before saving
|
||||
- [ ] Send test emails to verify
|
||||
- [ ] Localization support for multiple languages
|
||||
- [ ] Reset to default template option
|
||||
- [ ] Version history for templates
|
||||
|
||||
## Technical Notes
|
||||
- ReceptionMailer handles email sending
|
||||
- Currently uses hardcoded or simple templates
|
||||
- Consider template engine (Handlebars, Mjml for responsive)
|
||||
- Store templates in database for dynamic updates
|
||||
- Support HTML and plain text versions
|
||||
|
||||
## Related TODOs
|
||||
- New feature - enhance ReceptionMailer
|
||||
@@ -0,0 +1,28 @@
|
||||
# Security Monitoring Dashboard
|
||||
|
||||
**ID:** ADM-005
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a platform administrator, I want a security monitoring dashboard so that I can quickly identify and respond to potential security threats.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Real-time metrics: active sessions, login rate, failure rate
|
||||
- [ ] Anomaly detection alerts (unusual login patterns)
|
||||
- [ ] Geographic map of login locations
|
||||
- [ ] Failed login attempt heatmap
|
||||
- [ ] Blocked JWT/token statistics
|
||||
- [ ] Suspicious activity indicators
|
||||
- [ ] Configurable alert thresholds
|
||||
- [ ] Integration with alerting systems (PagerDuty, Slack)
|
||||
|
||||
## Technical Notes
|
||||
- Aggregate metrics from login events
|
||||
- Real-time updates via WebSocket
|
||||
- Consider time-series database for metrics
|
||||
- Machine learning for anomaly detection (future)
|
||||
- Alert rules engine for custom notifications
|
||||
|
||||
## Related TODOs
|
||||
- New feature - security operations
|
||||
@@ -0,0 +1,28 @@
|
||||
# Impersonate Users for Support
|
||||
|
||||
**ID:** ADM-006
|
||||
**Priority:** Low
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a platform administrator, I want to temporarily impersonate a user so that I can troubleshoot issues they're experiencing without asking for their credentials.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Admin can initiate impersonation session for any user
|
||||
- [ ] Impersonation requires confirmation and reason
|
||||
- [ ] Clear visual indicator when in impersonation mode
|
||||
- [ ] Admin can end impersonation and return to their session
|
||||
- [ ] All actions during impersonation are logged
|
||||
- [ ] User is optionally notified of impersonation
|
||||
- [ ] Impersonation sessions have time limit
|
||||
- [ ] Cannot impersonate other admins without super-admin
|
||||
|
||||
## Technical Notes
|
||||
- Special JWT claim to indicate impersonation
|
||||
- Original admin identity preserved in token
|
||||
- Audit log must capture both admin and impersonated user
|
||||
- Consider "read-only" impersonation mode
|
||||
- Security review required before implementation
|
||||
|
||||
## Related TODOs
|
||||
- New feature - support tooling
|
||||
@@ -0,0 +1,28 @@
|
||||
# Manage JWT Blocklist
|
||||
|
||||
**ID:** ADM-007
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a platform administrator, I want to view and manage the JWT blocklist so that I can revoke tokens during security incidents and verify that revocations are working.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] View all blocked JWT IDs with metadata
|
||||
- [ ] Search blocklist by JWT ID or user
|
||||
- [ ] Manually add JWTs to blocklist
|
||||
- [ ] View reason for each blocklist entry
|
||||
- [ ] Blocklist entries show expiration (when they can be removed)
|
||||
- [ ] Bulk revoke all tokens for a user
|
||||
- [ ] Bulk revoke all tokens for an organization
|
||||
- [ ] Automatic cleanup of expired blocklist entries
|
||||
|
||||
## Technical Notes
|
||||
- JwtManager has `blockedJwtIdList` infrastructure
|
||||
- `pushOrGetJwtIdBlocklist` endpoint exists
|
||||
- Need admin UI for blocklist management
|
||||
- ReceptionHousekeeping could handle cleanup
|
||||
- Consider Redis for high-performance blocklist checks
|
||||
|
||||
## Related TODOs
|
||||
- Enhancement to existing blocklist infrastructure
|
||||
@@ -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
|
||||
@@ -0,0 +1,28 @@
|
||||
# Create and Manage API Tokens
|
||||
|
||||
**ID:** DEV-001
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a developer, I want to create and manage API tokens so that I can integrate my applications with the identity provider programmatically.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Developer can create new API tokens with custom names
|
||||
- [ ] Token is shown once at creation (cannot be retrieved later)
|
||||
- [ ] Developer can set token expiration (or no expiration)
|
||||
- [ ] Developer can set token scopes/permissions
|
||||
- [ ] List all tokens with creation date and last used
|
||||
- [ ] Revoke individual tokens
|
||||
- [ ] Revoke all tokens at once
|
||||
- [ ] Rate limiting information shown per token
|
||||
|
||||
## Technical Notes
|
||||
- ApiTokenManager exists with basic infrastructure
|
||||
- `loginWithApiToken` endpoint available
|
||||
- Need UI for token management (currently backend only)
|
||||
- Tokens should be hashed before storage (show once)
|
||||
- Consider token prefixes for easy identification (idp_...)
|
||||
|
||||
## Related TODOs
|
||||
- Partial implementation in ApiTokenManager
|
||||
@@ -0,0 +1,28 @@
|
||||
# Comprehensive SDK Documentation
|
||||
|
||||
**ID:** DEV-002
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a developer, I want comprehensive documentation for the IDP client SDK so that I can integrate authentication into my applications quickly and correctly.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Getting started guide with installation and setup
|
||||
- [ ] Authentication flow explanations with diagrams
|
||||
- [ ] API reference for all client methods
|
||||
- [ ] Code examples for common use cases
|
||||
- [ ] TypeScript type definitions documented
|
||||
- [ ] Error handling guide with error codes
|
||||
- [ ] Migration guides for version upgrades
|
||||
- [ ] Interactive API playground/sandbox
|
||||
|
||||
## Technical Notes
|
||||
- `ts_idpclient/` contains the client SDK
|
||||
- README.md has basic usage but needs expansion
|
||||
- Generate API docs from TypeScript using TypeDoc
|
||||
- Host documentation on dedicated site or GitHub pages
|
||||
- Consider OpenAPI/Swagger spec for REST endpoints
|
||||
|
||||
## Related TODOs
|
||||
- New feature - documentation enhancement
|
||||
@@ -0,0 +1,28 @@
|
||||
# Configure Webhook Notifications
|
||||
|
||||
**ID:** DEV-003
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a developer, I want to configure webhooks so that my application is notified in real-time when authentication events occur.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Register webhook endpoints with URL and secret
|
||||
- [ ] Select which events to subscribe to
|
||||
- [ ] Events include: user.created, user.login, user.logout, org.member.added, etc.
|
||||
- [ ] Webhook payloads include event type, timestamp, and relevant data
|
||||
- [ ] Signature verification using shared secret (HMAC)
|
||||
- [ ] Retry logic for failed deliveries (exponential backoff)
|
||||
- [ ] Webhook delivery logs with success/failure status
|
||||
- [ ] Test webhook button to send sample event
|
||||
|
||||
## Technical Notes
|
||||
- Create Webhook model with URL, secret, events, and status
|
||||
- Queue webhook deliveries for reliability (consider bull/bullmq)
|
||||
- Sign payloads with HMAC-SHA256
|
||||
- Timeout for webhook delivery (10 seconds)
|
||||
- Dashboard for delivery monitoring and debugging
|
||||
|
||||
## Related TODOs
|
||||
- New feature - event-driven integration
|
||||
@@ -0,0 +1,44 @@
|
||||
# Proper App ID Initialization
|
||||
|
||||
**ID:** DEV-004
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a developer, I want to properly register my application with a unique App ID so that the identity provider can identify and configure my app correctly.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Developer can register new applications
|
||||
- [ ] Each app gets unique App ID and App Secret
|
||||
- [ ] Configure allowed redirect URIs per app
|
||||
- [ ] Configure allowed origins (CORS) per app
|
||||
- [ ] App-specific settings (token expiry, etc.)
|
||||
- [ ] View app analytics (logins per app)
|
||||
- [ ] Regenerate app secret if compromised
|
||||
- [ ] Delete/deactivate applications
|
||||
|
||||
## Technical Notes
|
||||
- Current client has `id: ''` placeholder (TODO in code)
|
||||
- App ID is now part of the unified Apps model (`IApp` discriminated union)
|
||||
- Three app types exist: Global Apps, Partner Apps, Custom OIDC Apps
|
||||
- For custom applications, use the Custom OIDC Apps flow (ORG-011)
|
||||
- App credentials stored as `IOAuthCredentials` with hashed client secret
|
||||
- Validate redirect URIs to prevent open redirector attacks
|
||||
- App ID/Client ID is included in JWT claims
|
||||
|
||||
## Apps Architecture
|
||||
|
||||
The Apps system supports three types:
|
||||
1. **Global Apps** (ORG-009) - First-party platform apps (foss.global, task.vc)
|
||||
2. **Partner Apps** (ORG-010, DEV-008) - AppStore model for third-party apps
|
||||
3. **Custom OIDC Apps** (ORG-011) - Organization-created OAuth/OIDC clients
|
||||
|
||||
## Related Stories
|
||||
- ORG-009: Connect Global Apps
|
||||
- ORG-010: Browse and Install Partner Apps
|
||||
- ORG-011: Create Custom OIDC Apps
|
||||
- DEV-005: Register OAuth Client App
|
||||
- DEV-008: Submit App to AppStore
|
||||
|
||||
## Related TODOs
|
||||
- `ts_idpclient/classes.idpclient.ts:30` - `id: '', // TODO`
|
||||
@@ -0,0 +1,51 @@
|
||||
# Register OAuth Client App
|
||||
|
||||
**ID:** DEV-005
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a developer, I want to register my application as an OAuth client so that users can authorize my app to access their data using standard OAuth 2.0 flows.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Register OAuth 2.0 client application
|
||||
- [ ] Support Authorization Code flow
|
||||
- [ ] Support PKCE for public clients (mobile/SPA)
|
||||
- [ ] Configure allowed scopes per client
|
||||
- [ ] Consent screen customization
|
||||
- [ ] Token endpoint for code exchange
|
||||
- [ ] Refresh token support
|
||||
- [ ] Client credentials flow for server-to-server
|
||||
|
||||
## Technical Notes
|
||||
- OAuth/OIDC client registration is now part of the Apps system
|
||||
- **For organization owners**: Use Custom OIDC Apps (ORG-011) to create OAuth clients
|
||||
- **For third-party developers**: Submit to AppStore (DEV-008) for public apps
|
||||
- Standard OAuth 2.0 / OpenID Connect flows supported
|
||||
- Scopes: openid, profile, email, organizations
|
||||
- PKCE is required for mobile and SPA security
|
||||
|
||||
## Implementation Path
|
||||
|
||||
This story's functionality is now implemented through:
|
||||
1. **Custom OIDC Apps** (ORG-011) - Create org-specific OAuth clients via the Apps UI
|
||||
2. **Partner Apps** (DEV-008) - Submit public apps to the AppStore
|
||||
|
||||
Both use the same underlying `IOAuthCredentials` model:
|
||||
```typescript
|
||||
interface IOAuthCredentials {
|
||||
clientId: string;
|
||||
clientSecretHash: string;
|
||||
redirectUris: string[];
|
||||
allowedScopes: string[];
|
||||
grantTypes: ('authorization_code' | 'client_credentials' | 'refresh_token')[];
|
||||
}
|
||||
```
|
||||
|
||||
## Related Stories
|
||||
- ORG-011: Create Custom OIDC Apps (primary implementation)
|
||||
- DEV-004: Proper App ID Initialization
|
||||
- DEV-008: Submit App to AppStore
|
||||
|
||||
## Related TODOs
|
||||
- New feature - OAuth server implementation
|
||||
@@ -0,0 +1,27 @@
|
||||
# Understand API Rate Limits
|
||||
|
||||
**ID:** DEV-006
|
||||
**Priority:** Low
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a developer, I want to understand and monitor API rate limits so that I can build applications that respect limits and handle throttling gracefully.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Clear documentation of rate limits per endpoint
|
||||
- [ ] Rate limit headers in API responses (X-RateLimit-*)
|
||||
- [ ] Different limits for different API token tiers
|
||||
- [ ] Dashboard showing current usage vs limits
|
||||
- [ ] Alerts when approaching rate limits
|
||||
- [ ] Retry-After header when rate limited
|
||||
- [ ] Ability to request limit increase
|
||||
|
||||
## Technical Notes
|
||||
- Implement rate limiting middleware (consider express-rate-limit)
|
||||
- Store rate limit counters in Redis for distributed systems
|
||||
- Different limits: login attempts, API calls, token operations
|
||||
- Consider sliding window algorithm for smooth limits
|
||||
- 429 Too Many Requests response with helpful error message
|
||||
|
||||
## Related TODOs
|
||||
- New feature - API management
|
||||
@@ -0,0 +1,27 @@
|
||||
# Validate JWTs in My Application
|
||||
|
||||
**ID:** DEV-007
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As a developer, I want clear guidance and tools to validate JWTs issued by the identity provider so that I can securely authenticate users in my backend services.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Public key endpoint for JWT validation (JWKS format)
|
||||
- [ ] Documentation explaining JWT structure and claims
|
||||
- [ ] Example code for validation in multiple languages
|
||||
- [ ] Key rotation with multiple valid keys during transition
|
||||
- [ ] Token introspection endpoint for server-side validation
|
||||
- [ ] Clear error messages for invalid tokens
|
||||
- [ ] Guidance on caching public keys
|
||||
|
||||
## Technical Notes
|
||||
- `getPublicKeyForValidation` endpoint exists
|
||||
- Consider standard JWKS endpoint (/.well-known/jwks.json)
|
||||
- OpenID Connect discovery endpoint would help
|
||||
- JWTs contain: sub, email, roles, orgId, exp, iat
|
||||
- Document all custom claims in JWT
|
||||
|
||||
## Related TODOs
|
||||
- Enhancement to existing JWT infrastructure
|
||||
@@ -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,24 @@
|
||||
# Multi-Device Login Sessions
|
||||
|
||||
**ID:** EU-001
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an end user, I want to stay logged in on multiple devices simultaneously so that I can access my account from my phone, tablet, and computer without being logged out elsewhere.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] User can have active sessions on multiple devices at the same time
|
||||
- [ ] Each device gets its own refresh token
|
||||
- [ ] Logging out on one device does not affect sessions on other devices
|
||||
- [ ] User can see all active sessions in account settings
|
||||
- [ ] User can revoke individual sessions remotely
|
||||
|
||||
## Technical Notes
|
||||
- Currently only one refresh token per login session is supported
|
||||
- Need to refactor `LoginSession` to support multiple refresh tokens
|
||||
- Consider storing device metadata (browser, OS, last active time) with each token
|
||||
- JWT blocklist needs to handle individual token revocation
|
||||
|
||||
## Related TODOs
|
||||
- `ts/reception/classes.jwt.ts:39` - `// TODO: handle multiple refresh tokens`
|
||||
@@ -0,0 +1,26 @@
|
||||
# Complete Password Reset Flow
|
||||
|
||||
**ID:** EU-002
|
||||
**Priority:** Critical
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an end user, I want to reset my password when I forget it so that I can regain access to my account securely.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] User can request a password reset via email
|
||||
- [ ] Reset email contains a secure, time-limited token link
|
||||
- [ ] Clicking the link opens a form to set a new password
|
||||
- [ ] Password must meet security requirements (length, complexity)
|
||||
- [ ] Old password is invalidated after successful reset
|
||||
- [ ] User receives confirmation email after password change
|
||||
- [ ] All existing sessions are invalidated after password reset
|
||||
|
||||
## Technical Notes
|
||||
- `resetPassword` handler exists but `setNewPassword` is a stub (returns `{ status: 'ok' }` without implementation)
|
||||
- Need to implement actual password update logic
|
||||
- Should use `ReceptionMailer` for email sending
|
||||
- Consider rate limiting reset requests to prevent abuse
|
||||
|
||||
## Related TODOs
|
||||
- `ts/reception/classes.loginsessionmanager.ts:229-238` - `setNewPassword` handler is incomplete
|
||||
@@ -0,0 +1,25 @@
|
||||
# View and Manage Logged-in Devices
|
||||
|
||||
**ID:** EU-003
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an end user, I want to view all devices where I'm logged in and remotely log out of specific devices so that I can maintain control over my account security.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] User can view a list of all active sessions/devices
|
||||
- [ ] Each device entry shows: device type, browser, location (approximate), last activity
|
||||
- [ ] User can name/label devices for easy identification
|
||||
- [ ] User can log out of any individual device remotely
|
||||
- [ ] User can log out of all devices except the current one
|
||||
- [ ] User receives notification when a new device logs in
|
||||
|
||||
## Technical Notes
|
||||
- Device ID tracking infrastructure exists but is blocked by JWT handling issues
|
||||
- Need to complete `attachDeviceId` handler (currently returns `ok: false`)
|
||||
- Store device fingerprint, user agent, IP-based geolocation
|
||||
- Integrate with multi-refresh-token system (EU-001)
|
||||
|
||||
## Related TODOs
|
||||
- `ts/reception/classes.loginsessionmanager.ts:256` - `// TODO: Blocked by proper JWT handling`
|
||||
@@ -0,0 +1,27 @@
|
||||
# Enable Two-Factor Authentication
|
||||
|
||||
**ID:** EU-004
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an end user, I want to enable two-factor authentication on my account so that my account is protected even if my password is compromised.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] User can enable 2FA from account settings
|
||||
- [ ] Support for TOTP apps (Google Authenticator, Authy, etc.)
|
||||
- [ ] Backup codes are generated and shown once during setup
|
||||
- [ ] User must verify 2FA code during setup to confirm it works
|
||||
- [ ] Login flow prompts for 2FA code when enabled
|
||||
- [ ] User can disable 2FA (requires current 2FA code)
|
||||
- [ ] Account recovery option if 2FA device is lost
|
||||
|
||||
## Technical Notes
|
||||
- Mobile verification infrastructure exists (SMS OTP in registration)
|
||||
- Can leverage existing `smarttwilio` integration for SMS-based 2FA
|
||||
- TOTP implementation needs `otplib` or similar library
|
||||
- Store encrypted TOTP secret in User model
|
||||
- Consider supporting multiple 2FA methods (TOTP, SMS, security keys)
|
||||
|
||||
## Related TODOs
|
||||
- New feature - no existing TODO
|
||||
@@ -0,0 +1,28 @@
|
||||
# Login with Social Providers
|
||||
|
||||
**ID:** EU-005
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an end user, I want to log in using my existing Google, GitHub, or Microsoft account so that I don't have to remember another password.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] User can sign in with Google
|
||||
- [ ] User can sign in with GitHub
|
||||
- [ ] User can sign in with Microsoft
|
||||
- [ ] First-time social login creates a new account automatically
|
||||
- [ ] Social login can be linked to existing account
|
||||
- [ ] User can unlink social providers from settings
|
||||
- [ ] Profile data (name, email, avatar) is imported from provider
|
||||
- [ ] User can still set a password for email/password login
|
||||
|
||||
## Technical Notes
|
||||
- Package.json keywords mention OAuth - infrastructure may be partially planned
|
||||
- Implement OAuth 2.0 / OpenID Connect flows
|
||||
- Store provider tokens securely for API access if needed
|
||||
- Handle email conflicts (social email matches existing account)
|
||||
- Consider using passport.js or similar for provider abstraction
|
||||
|
||||
## Related TODOs
|
||||
- New feature - OAuth mentioned in package.json keywords but not implemented
|
||||
@@ -0,0 +1,28 @@
|
||||
# Delete My Account
|
||||
|
||||
**ID:** EU-006
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an end user, I want to permanently delete my account and all associated data so that I can exercise my right to be forgotten (GDPR compliance).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] User can request account deletion from settings
|
||||
- [ ] Deletion requires password confirmation or 2FA
|
||||
- [ ] User sees summary of what will be deleted
|
||||
- [ ] Grace period (e.g., 30 days) before permanent deletion
|
||||
- [ ] User receives email confirmation of deletion request
|
||||
- [ ] User can cancel deletion during grace period
|
||||
- [ ] All personal data is removed after grace period
|
||||
- [ ] User is removed from all organizations they belong to
|
||||
|
||||
## Technical Notes
|
||||
- `suspendUser` and `deleteSuspendedUser` endpoints exist in admin context
|
||||
- Need user-facing self-service deletion flow
|
||||
- Consider soft delete with scheduled hard delete
|
||||
- Must handle organization ownership transfer if user owns orgs
|
||||
- Audit log should retain anonymized record for compliance
|
||||
|
||||
## Related TODOs
|
||||
- New feature - builds on existing suspension infrastructure
|
||||
@@ -0,0 +1,26 @@
|
||||
# View Login History
|
||||
|
||||
**ID:** EU-007
|
||||
**Priority:** Low
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an end user, I want to view my login history so that I can detect any unauthorized access to my account.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] User can view list of recent logins (last 30 days)
|
||||
- [ ] Each entry shows: date/time, IP address, location, device/browser
|
||||
- [ ] Failed login attempts are also shown
|
||||
- [ ] Suspicious logins are highlighted (new location, unusual time)
|
||||
- [ ] User can export login history
|
||||
- [ ] User receives alert for logins from new locations/devices
|
||||
|
||||
## Technical Notes
|
||||
- Login events need to be logged with metadata
|
||||
- Create new LoginHistory collection in MongoDB
|
||||
- IP geolocation service needed (consider MaxMind or ipinfo.io)
|
||||
- Privacy considerations: IP retention policy, GDPR compliance
|
||||
- Could integrate with EU-003 (device management) for unified view
|
||||
|
||||
## Related TODOs
|
||||
- New feature - no existing infrastructure
|
||||
@@ -0,0 +1,28 @@
|
||||
# Upload Profile Avatar
|
||||
|
||||
**ID:** EU-008
|
||||
**Priority:** Low
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an end user, I want to upload a profile picture so that my identity is visually recognizable across applications that use this identity provider.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] User can upload an image from their device
|
||||
- [ ] Supported formats: JPEG, PNG, GIF
|
||||
- [ ] Maximum file size: 5MB
|
||||
- [ ] Image is automatically resized/cropped to standard dimensions
|
||||
- [ ] User can crop/adjust image before saving
|
||||
- [ ] Avatar is served via CDN for fast loading
|
||||
- [ ] Default avatar (initials or Gravatar) when no upload
|
||||
- [ ] Avatar is available to connected applications via API
|
||||
|
||||
## Technical Notes
|
||||
- User model needs avatar URL field
|
||||
- Consider using cloud storage (S3, Cloudflare R2) for images
|
||||
- Implement image processing for resize/crop (sharp library)
|
||||
- Gravatar integration as fallback using email hash
|
||||
- Expose avatar in JWT claims or user info endpoint
|
||||
|
||||
## Related TODOs
|
||||
- New feature - no existing infrastructure
|
||||
@@ -0,0 +1,27 @@
|
||||
# Sync Billing Plans with Users
|
||||
|
||||
**ID:** ORG-001
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want billing plans to automatically sync with user seats so that I'm only charged for active users and can easily manage costs.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Adding a user to org automatically updates billing (for per-seat plans)
|
||||
- [ ] Removing a user adjusts billing accordingly
|
||||
- [ ] Prorated charges/credits for mid-cycle changes
|
||||
- [ ] Organization dashboard shows current seat count vs plan limit
|
||||
- [ ] Warning notification when approaching seat limit
|
||||
- [ ] Automatic upgrade prompt when exceeding limit
|
||||
- [ ] Billing history shows seat changes over time
|
||||
|
||||
## Technical Notes
|
||||
- `BillingPlan.syncForUser()` method exists but is not implemented
|
||||
- Paddle integration exists for payment processing
|
||||
- Need to track user-to-organization seat assignments
|
||||
- Consider grace period for temporary overages
|
||||
- Webhook from Paddle for payment confirmations
|
||||
|
||||
## Related TODOs
|
||||
- `ts/reception/classes.billingplan.ts:16` - `// TODO sync this for user`
|
||||
@@ -0,0 +1,128 @@
|
||||
# Invite and Manage Team Members
|
||||
|
||||
**ID:** ORG-002
|
||||
**Priority:** Critical
|
||||
**Status:** Complete
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to invite team members to my organization and manage their access so that my team can collaborate securely.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [x] Owner can invite users via email address
|
||||
- [x] Invited user receives email with invitation link
|
||||
- [x] Invitation can be accepted by existing users or during registration
|
||||
- [x] Owner can view pending invitations and resend/cancel them
|
||||
- [x] Owner can see all current members with their roles
|
||||
- [x] Owner can remove members from organization
|
||||
- [x] Owner can transfer ownership to another member
|
||||
- [x] Bulk invite via CSV upload
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### UserInvitation System
|
||||
|
||||
The invitation system uses a shared `UserInvitation` model that supports multiple organizations inviting the same email address.
|
||||
|
||||
#### Invitation Lifecycle
|
||||
|
||||
1. **Create**: Org admin invites email → `UserInvitation` created (or existing one is updated)
|
||||
2. **Share**: Multiple orgs can link to the same invitation (by email)
|
||||
3. **Convert**: When user registers with that email → invitation converts to real User
|
||||
4. **Fold**: If existing user adds that email as secondary → invitation folds into existing user
|
||||
5. **Expire**: Auto-delete after 90 days with cleanup of all org refs
|
||||
|
||||
#### Data Model
|
||||
|
||||
```typescript
|
||||
// IUserInvitation
|
||||
{
|
||||
id: string;
|
||||
data: {
|
||||
email: string; // Unique key for sharing
|
||||
token: string; // Secure invitation link token
|
||||
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
|
||||
createdAt: number;
|
||||
expiresAt: number; // 90 days from creation
|
||||
organizationRefs: Array<{ // Multiple orgs can share
|
||||
organizationId: string;
|
||||
invitedByUserId: string;
|
||||
invitedAt: number;
|
||||
roles: string[]; // Roles to assign on acceptance
|
||||
}>;
|
||||
acceptedAt?: number;
|
||||
convertedToUserId?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Role System Enhancement
|
||||
|
||||
Users can have multiple roles within an organization:
|
||||
|
||||
```typescript
|
||||
// IRole
|
||||
{
|
||||
id: string;
|
||||
data: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
roles: string[]; // e.g., ['owner', 'billing-admin', 'developer']
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Standard roles: `owner`, `admin`, `editor`, `viewer`, `guest`
|
||||
Custom roles are also supported.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `createInvitation` | Invite email to org with roles |
|
||||
| `getOrgInvitations` | List pending invitations |
|
||||
| `getOrgMembers` | List members with roles |
|
||||
| `cancelInvitation` | Cancel pending invitation |
|
||||
| `resendInvitation` | Resend invitation email |
|
||||
| `removeMember` | Remove user from org |
|
||||
| `updateMemberRoles` | Change member's roles |
|
||||
| `transferOwnership` | Transfer org ownership |
|
||||
| `acceptInvitation` | Accept invitation |
|
||||
| `getInvitationByToken` | Get invitation details for landing page |
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
The Users page (`/account/org/:orgName/users`) provides:
|
||||
|
||||
- **Members tab**: List all members with roles, remove/edit actions
|
||||
- **Pending tab**: List pending invitations with resend/cancel
|
||||
- **Invite tab**: Form to invite by email with role selection
|
||||
|
||||
### Files
|
||||
|
||||
**Backend:**
|
||||
- `ts_interfaces/data/loint-reception.userinvitation.ts` - Data interface
|
||||
- `ts_interfaces/request/loint-reception.userinvitation.ts` - API contracts
|
||||
- `ts/reception/classes.userinvitation.ts` - Model
|
||||
- `ts/reception/classes.userinvitationmanager.ts` - Manager with handlers
|
||||
- `ts/reception/classes.receptionmailer.ts` - Invitation email
|
||||
|
||||
**Frontend:**
|
||||
- `ts_web/elements/account/views/usersview.ts` - Users page component
|
||||
- `ts_web/elements/account/content.ts` - Route registration
|
||||
- `ts_web/elements/account/navigation.ts` - Nav link
|
||||
|
||||
## Technical Notes
|
||||
- Organization and User models exist with association
|
||||
- UserInvitation model stores invitation data with 90-day expiry
|
||||
- `ReceptionMailer.sendInvitationEmail()` handles email delivery
|
||||
- RoleManager updated to support `roles: string[]` array
|
||||
- Backward compatible with existing single-role data
|
||||
|
||||
## Related Stories
|
||||
- ORG-003: Assign Roles to Members (enhanced with multi-role support)
|
||||
|
||||
## Related TODOs
|
||||
- [ ] Integrate invitation acceptance into registration flow
|
||||
- [ ] Add email verification flow for secondary emails (folding)
|
||||
- [ ] Implement scheduled cleanup job for expired invitations
|
||||
- [ ] Add CSV bulk invite feature
|
||||
@@ -0,0 +1,28 @@
|
||||
# Assign Roles to Members
|
||||
|
||||
**ID:** ORG-003
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to assign different roles to team members so that I can control what each person can access and do within the organization.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Owner can create custom roles for the organization
|
||||
- [ ] Default roles: Owner, Admin, Member, Viewer
|
||||
- [ ] Each role has configurable permissions
|
||||
- [ ] Owner can assign/change roles for any member
|
||||
- [ ] Role changes take effect immediately
|
||||
- [ ] Members can view their own role and permissions
|
||||
- [ ] Audit log for role changes
|
||||
- [ ] At least one Owner must exist at all times
|
||||
|
||||
## Technical Notes
|
||||
- RoleManager exists with basic role infrastructure
|
||||
- `getRolesAndOrganizationsForUserId` endpoint available
|
||||
- Need to expand Role model with permissions array
|
||||
- Consider permission inheritance (Admin inherits Member permissions)
|
||||
- JWT claims should include role for authorization
|
||||
|
||||
## Related TODOs
|
||||
- Partial implementation exists in RoleManager
|
||||
@@ -0,0 +1,27 @@
|
||||
# Customize Organization Branding
|
||||
|
||||
**ID:** ORG-004
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to customize the branding of my organization's login and account pages so that my team sees our company identity when authenticating.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Upload organization logo
|
||||
- [ ] Set primary and secondary brand colors
|
||||
- [ ] Custom login page welcome message
|
||||
- [ ] Organization name displayed on login/register
|
||||
- [ ] Preview branding changes before saving
|
||||
- [ ] Reset to default branding option
|
||||
- [ ] Branding applies to email templates (org-specific emails)
|
||||
|
||||
## Technical Notes
|
||||
- Organization model needs branding fields (logo URL, colors, message)
|
||||
- Frontend components need to accept branding props
|
||||
- Email templates should support organization branding
|
||||
- Consider white-label subdomain support (org.idp.global)
|
||||
- Image storage similar to user avatars (EU-008)
|
||||
|
||||
## Related TODOs
|
||||
- New feature - no existing infrastructure
|
||||
@@ -0,0 +1,27 @@
|
||||
# View Organization Usage Analytics
|
||||
|
||||
**ID:** ORG-005
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to view analytics about how my organization uses the identity platform so that I can understand adoption and identify potential issues.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Dashboard showing key metrics (active users, logins, registrations)
|
||||
- [ ] Time-series charts for login activity
|
||||
- [ ] Most active users ranking
|
||||
- [ ] Failed login attempts summary
|
||||
- [ ] Authentication method breakdown (password, email link, 2FA)
|
||||
- [ ] Date range selector for historical data
|
||||
- [ ] Export analytics data (CSV, PDF)
|
||||
|
||||
## Technical Notes
|
||||
- Need to aggregate login events per organization
|
||||
- Consider time-series database or aggregation pipeline in MongoDB
|
||||
- Privacy: show aggregates, not individual user activity details
|
||||
- Cache analytics for performance
|
||||
- Real-time updates via WebSocket for dashboard
|
||||
|
||||
## Related TODOs
|
||||
- New feature - requires event logging infrastructure
|
||||
@@ -0,0 +1,28 @@
|
||||
# Configure SSO for Organization
|
||||
|
||||
**ID:** ORG-006
|
||||
**Priority:** High
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to configure Single Sign-On with my company's identity provider so that employees can use their corporate credentials.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Support SAML 2.0 SSO configuration
|
||||
- [ ] Support OIDC/OAuth SSO configuration
|
||||
- [ ] Test connection before enabling
|
||||
- [ ] Auto-provision users on first SSO login (JIT provisioning)
|
||||
- [ ] Map SSO attributes to user profile fields
|
||||
- [ ] Option to require SSO for all org members
|
||||
- [ ] Bypass SSO for emergency admin access
|
||||
- [ ] Support multiple SSO providers per organization
|
||||
|
||||
## Technical Notes
|
||||
- Implement SAML assertion consumer service
|
||||
- Store SSO configuration securely (encrypted secrets)
|
||||
- Certificate management for SAML
|
||||
- Consider using passport-saml and passport-openidconnect
|
||||
- Metadata endpoint for easy IdP configuration
|
||||
|
||||
## Related TODOs
|
||||
- New feature - enterprise SSO capability
|
||||
@@ -0,0 +1,28 @@
|
||||
# View Organization Audit Logs
|
||||
|
||||
**ID:** ORG-007
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to view audit logs for my organization so that I can track security-relevant events and meet compliance requirements.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Log all security-relevant events (logins, role changes, member changes)
|
||||
- [ ] Searchable audit log interface
|
||||
- [ ] Filter by event type, user, date range
|
||||
- [ ] Each entry shows: timestamp, actor, action, target, IP address
|
||||
- [ ] Immutable logs (cannot be deleted or modified)
|
||||
- [ ] Export logs for compliance (CSV, JSON)
|
||||
- [ ] Retention policy configuration (90 days default)
|
||||
- [ ] Real-time event streaming option
|
||||
|
||||
## Technical Notes
|
||||
- Create AuditLog collection with write-only access pattern
|
||||
- Index for efficient querying
|
||||
- Consider separate database/collection for audit data
|
||||
- Comply with SOC 2 / ISO 27001 logging requirements
|
||||
- Webhook option for SIEM integration
|
||||
|
||||
## Related TODOs
|
||||
- New feature - compliance and security requirement
|
||||
@@ -0,0 +1,28 @@
|
||||
# Manage Subscription and Billing
|
||||
|
||||
**ID:** ORG-008
|
||||
**Priority:** Medium
|
||||
**Status:** Planned
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to manage my subscription plan and billing details so that I can upgrade, downgrade, or update payment methods as needed.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] View current subscription plan and features
|
||||
- [ ] Compare available plans with feature matrix
|
||||
- [ ] Upgrade to higher plan with immediate effect
|
||||
- [ ] Downgrade with effect at end of billing period
|
||||
- [ ] Update payment method (credit card via Paddle)
|
||||
- [ ] View billing history and download invoices
|
||||
- [ ] Cancel subscription with confirmation
|
||||
- [ ] Apply coupon/discount codes
|
||||
|
||||
## Technical Notes
|
||||
- Paddle integration exists (`paddlesetup` view, `BillingPlanManager`)
|
||||
- Enhance existing subscription view with more functionality
|
||||
- Paddle handles PCI compliance for payment data
|
||||
- Webhook handlers for subscription status changes
|
||||
- VAT handling for EU customers (Paddle manages this)
|
||||
|
||||
## Related TODOs
|
||||
- Enhancement to existing Paddle integration
|
||||
@@ -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
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.4.2',
|
||||
version: '1.12.1',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
+17
-5
@@ -8,21 +8,33 @@ export const runCli = async () => {
|
||||
feedMetadata: null,
|
||||
domain: 'idp.global',
|
||||
serveDir: paths.distWebDir,
|
||||
securityHeaders: {
|
||||
csp: {
|
||||
defaultSrc: "'self'",
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.paddle.com", "https://public.profitwell.com"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.paddle.com", "https://assetbroker.lossless.one"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
fontSrc: ["'self'", "data:"],
|
||||
connectSrc: ["'self'", "https://*.paddle.com", "https://buy.paddle.com", "https://checkout.paddle.com", "https://checkout-service.paddle.com", "https://cdn.paddle.com", "https://*.sentry.io", "https://public.profitwell.com", "wss:"],
|
||||
frameSrc: ["https://buy.paddle.com", "https://checkout.paddle.com", "https://*.paddle.com"],
|
||||
},
|
||||
},
|
||||
addCustomRoutes: async (typedserver) => {
|
||||
// Enable SPA fallback - serves index.html for non-file routes (e.g., /login, /dashboard)
|
||||
typedserver.options.spaFallback = true;
|
||||
},
|
||||
});
|
||||
|
||||
// lets add the reception routes
|
||||
const reception = new Reception({
|
||||
name: (await serviceQenv.getEnvVarOnDemand('INSTANCE_NAME')) || 'idp.global',
|
||||
mongoDescriptor: {
|
||||
mongoDbUser: await serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
|
||||
mongoDbName: await serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
|
||||
mongoDbPass: await serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
|
||||
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
|
||||
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
|
||||
},
|
||||
websiteServer: websiteServer,
|
||||
baseUrl: await serviceQenv.getEnvVarOnDemand('IDP_BASEURL'),
|
||||
});
|
||||
await reception.start();
|
||||
|
||||
await websiteServer.start();
|
||||
await websiteServer.start(2999);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,17 @@ export class BillingPlanManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
// Paddle configuration endpoint
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPaddleConfig>(
|
||||
'getPaddleConfig',
|
||||
async () => ({
|
||||
paddleToken: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PADDLE_TOKEN'),
|
||||
paddlePriceId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PADDLE_PRICE_ID'),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class JwtManager {
|
||||
|
||||
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> {
|
||||
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,
|
||||
});
|
||||
if (jwt.blocked) {
|
||||
|
||||
@@ -60,7 +60,10 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
||||
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
|
||||
invalidated: false,
|
||||
refreshToken: null,
|
||||
deviceId: null
|
||||
deviceId: null,
|
||||
deviceInfo: null,
|
||||
createdAt: Date.now(),
|
||||
lastActive: Date.now(),
|
||||
};
|
||||
|
||||
public transferToken: string;
|
||||
|
||||
@@ -259,6 +259,83 @@ export class LoginSessionManager {
|
||||
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 };
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,6 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
|
||||
|
||||
public async checkIfUserIsAdmin(userArg: User) {
|
||||
const role = await this.manager.receptionRef.roleManager.getRoleForUserAndOrg(userArg, this);
|
||||
return role.data.role === 'admin';
|
||||
return role.data.roles?.includes('admin') || role.data.roles?.includes('owner');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,13 +50,14 @@ export class OrganizationManager {
|
||||
action: 'create',
|
||||
organizationId: newOrg.id,
|
||||
userId: userData.id,
|
||||
role: 'admin',
|
||||
roles: ['owner'],
|
||||
});
|
||||
newOrg.data.roleIds.push(role.id);
|
||||
await newOrg.save();
|
||||
return {
|
||||
nameAvailable: true,
|
||||
resultingOrganization: await newOrg.createSavableObject()
|
||||
resultingOrganization: await newOrg.createSavableObject(),
|
||||
role: await role.createSavableObject(),
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import { ReceptionHousekeeping } from './classes.housekeeping.js';
|
||||
import { OrganizationManager } from './classes.organizationmanager.js';
|
||||
import { RoleManager } from './classes.rolemanager.js';
|
||||
import { BillingPlanManager } from './classes.billingplanmanager.js';
|
||||
import { AppManager } from './classes.appmanager.js';
|
||||
import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
||||
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
||||
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
||||
|
||||
export interface IReceptionOptions {
|
||||
/**
|
||||
@@ -41,6 +45,10 @@ export class Reception {
|
||||
public organizationmanager = new OrganizationManager(this);
|
||||
public roleManager = new RoleManager(this);
|
||||
public billingPlanManager = new BillingPlanManager(this);
|
||||
public appManager = new AppManager(this);
|
||||
public appConnectionManager = new AppConnectionManager(this);
|
||||
public activityLogManager = new ActivityLogManager(this);
|
||||
public userInvitationManager = new UserInvitationManager(this);
|
||||
housekeeping = new ReceptionHousekeeping(this);
|
||||
|
||||
constructor(public options: IReceptionOptions) {
|
||||
|
||||
@@ -268,4 +268,33 @@ export class ReceptionMailer {
|
||||
`),
|
||||
});
|
||||
}
|
||||
|
||||
public sendInvitationEmail(
|
||||
email: string,
|
||||
organizationName: string,
|
||||
invitationToken: string,
|
||||
baseUrl: string
|
||||
) {
|
||||
const invitationUrl = `${baseUrl}/invite?token=${encodeURI(invitationToken)}`;
|
||||
|
||||
this.receptionRef.szPlatformClient.emailConnector.sendEmail({
|
||||
from: `idp.global@${this.receptionRef.options.baseUrl} <noreply@mail.workspace.global>`,
|
||||
title: `You've been invited to join ${organizationName}`,
|
||||
to: email,
|
||||
body: this.createBodyString(`
|
||||
<h1>You're Invited!</h1>
|
||||
<p>You've been invited to join <b>${organizationName}</b> on idp.global.</p>
|
||||
<p>Click the button below to accept the invitation and join the organization.</p>
|
||||
<a href="${invitationUrl}"><div class="button">
|
||||
Accept Invitation
|
||||
</div></a>
|
||||
<p style="color: #888888; font-size: 12px; margin-top: 20px;">
|
||||
If you don't have an account yet, you'll be able to create one when you accept the invitation.
|
||||
</p>
|
||||
<p style="color: #888888; font-size: 12px;">
|
||||
This invitation will expire in 90 days.
|
||||
</p>
|
||||
`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,24 @@ export class RoleManager {
|
||||
this.receptionRef = receptionRefArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create, change, or delete a role for a user in an organization.
|
||||
* Supports both old single-role and new multi-role patterns.
|
||||
*/
|
||||
public async modifyRoleForUserAtOrg(optionsArg: {
|
||||
action: 'create' | 'change' | 'delete';
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
role: plugins.idpInterfaces.data.IRole['data']['role'];
|
||||
/** @deprecated Use `roles` instead */
|
||||
role?: string;
|
||||
/** Array of roles to assign */
|
||||
roles?: string[];
|
||||
}) {
|
||||
let returnRole: Role;
|
||||
|
||||
// Support both old single role and new roles array
|
||||
const roles = optionsArg.roles || (optionsArg.role ? [optionsArg.role] : ['viewer']);
|
||||
|
||||
switch (optionsArg.action) {
|
||||
case 'create':
|
||||
returnRole = new this.CRole();
|
||||
@@ -29,9 +40,35 @@ export class RoleManager {
|
||||
returnRole.data = {
|
||||
userId: optionsArg.userId,
|
||||
organizationId: optionsArg.organizationId,
|
||||
role: optionsArg.role,
|
||||
roles: roles,
|
||||
};
|
||||
await returnRole.save();
|
||||
break;
|
||||
|
||||
case 'change':
|
||||
returnRole = await this.CRole.getInstance({
|
||||
data: {
|
||||
userId: optionsArg.userId,
|
||||
organizationId: optionsArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (returnRole) {
|
||||
returnRole.data.roles = roles;
|
||||
await returnRole.save();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
returnRole = await this.CRole.getInstance({
|
||||
data: {
|
||||
userId: optionsArg.userId,
|
||||
organizationId: optionsArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (returnRole) {
|
||||
await returnRole.delete();
|
||||
}
|
||||
break;
|
||||
}
|
||||
return returnRole;
|
||||
}
|
||||
@@ -54,4 +91,13 @@ export class RoleManager {
|
||||
});
|
||||
return roles;
|
||||
}
|
||||
|
||||
public async getAllRolesForOrg(organizationId: string) {
|
||||
const roles = await this.CRole.getInstances({
|
||||
data: {
|
||||
organizationId: organizationId
|
||||
}
|
||||
});
|
||||
return roles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* UserInvitation represents an invitation to join one or more organizations.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Unique by email (multiple orgs can share the same invitation)
|
||||
* - Converts to real User on registration
|
||||
* - Can fold into existing user if they add the email as secondary
|
||||
* - Auto-expires after 90 days
|
||||
*/
|
||||
@plugins.smartdata.Manager()
|
||||
export class UserInvitation extends plugins.smartdata.SmartDataDbDoc<
|
||||
UserInvitation,
|
||||
plugins.idpInterfaces.data.IUserInvitation
|
||||
> {
|
||||
// STATIC
|
||||
public static readonly EXPIRY_DAYS = 90;
|
||||
|
||||
public static generateToken(): string {
|
||||
return plugins.smartunique.shortId() + '-' + plugins.smartunique.shortId();
|
||||
}
|
||||
|
||||
public static async createNewInvitation(
|
||||
email: string,
|
||||
organizationId: string,
|
||||
invitedByUserId: string,
|
||||
roles: string[]
|
||||
): Promise<UserInvitation> {
|
||||
const invitation = new UserInvitation();
|
||||
invitation.id = plugins.smartunique.shortId();
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
invitation.data = {
|
||||
email: email.toLowerCase().trim(),
|
||||
token: UserInvitation.generateToken(),
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
expiresAt: expiresAt,
|
||||
organizationRefs: [{
|
||||
organizationId,
|
||||
invitedByUserId,
|
||||
invitedAt: now,
|
||||
roles,
|
||||
}],
|
||||
};
|
||||
|
||||
await invitation.save();
|
||||
return invitation;
|
||||
}
|
||||
|
||||
// INSTANCE
|
||||
@plugins.smartdata.unI()
|
||||
id: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IUserInvitation['data'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add another organization to this invitation
|
||||
*/
|
||||
public async addOrganization(
|
||||
organizationId: string,
|
||||
invitedByUserId: string,
|
||||
roles: string[]
|
||||
): Promise<void> {
|
||||
// Check if org already exists
|
||||
const existingRef = this.data.organizationRefs.find(
|
||||
ref => ref.organizationId === organizationId
|
||||
);
|
||||
|
||||
if (existingRef) {
|
||||
// Update roles for existing org ref
|
||||
existingRef.roles = roles;
|
||||
existingRef.invitedAt = Date.now();
|
||||
existingRef.invitedByUserId = invitedByUserId;
|
||||
} else {
|
||||
// Add new org ref
|
||||
this.data.organizationRefs.push({
|
||||
organizationId,
|
||||
invitedByUserId,
|
||||
invitedAt: Date.now(),
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an organization from this invitation
|
||||
*/
|
||||
public async removeOrganization(organizationId: string): Promise<void> {
|
||||
this.data.organizationRefs = this.data.organizationRefs.filter(
|
||||
ref => ref.organizationId !== organizationId
|
||||
);
|
||||
|
||||
// If no more org refs, cancel the invitation
|
||||
if (this.data.organizationRefs.length === 0) {
|
||||
this.data.status = 'cancelled';
|
||||
}
|
||||
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if invitation is expired
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
return Date.now() > this.data.expiresAt || this.data.status === 'expired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark invitation as accepted and record the user ID
|
||||
*/
|
||||
public async accept(userId: string): Promise<void> {
|
||||
this.data.status = 'accepted';
|
||||
this.data.acceptedAt = Date.now();
|
||||
this.data.convertedToUserId = userId;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate token and extend expiry (for resend)
|
||||
*/
|
||||
public async regenerateToken(): Promise<void> {
|
||||
this.data.token = UserInvitation.generateToken();
|
||||
this.data.expiresAt = Date.now() + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Reception } from './classes.reception.js';
|
||||
import { UserInvitation } from './classes.userinvitation.js';
|
||||
import { Organization } from './classes.organization.js';
|
||||
import { User } from './classes.user.js';
|
||||
import { Role } from './classes.role.js';
|
||||
|
||||
export class UserInvitationManager {
|
||||
public receptionRef: Reception;
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public CUserInvitation = plugins.smartdata.setDefaultManagerForDoc(this, UserInvitation);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
// Create invitation
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||
'createInvitation',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const email = requestArg.email.toLowerCase().trim();
|
||||
|
||||
// Check if user with this email already exists
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
if (existingUser) {
|
||||
// User already exists - just add them to the org directly
|
||||
const existingRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (existingRole) {
|
||||
return {
|
||||
success: false,
|
||||
isNew: false,
|
||||
message: 'User is already a member of this organization.',
|
||||
};
|
||||
}
|
||||
// Add user to org with the specified roles
|
||||
await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
|
||||
action: 'create',
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
roles: requestArg.roles,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
isNew: false,
|
||||
message: 'Existing user has been added to the organization.',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if invitation already exists for this email
|
||||
let invitation = await this.CUserInvitation.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
|
||||
let isNew = false;
|
||||
if (invitation) {
|
||||
// Add org to existing invitation
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, requestArg.roles);
|
||||
} else {
|
||||
// Create new invitation
|
||||
invitation = await UserInvitation.createNewInvitation(
|
||||
email,
|
||||
requestArg.organizationId,
|
||||
user.id,
|
||||
requestArg.roles
|
||||
);
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invitation: await invitation.createSavableObject(),
|
||||
isNew,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get org invitations
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
|
||||
'getOrgInvitations',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const allInvitations = await this.CUserInvitation.getInstances({});
|
||||
const orgInvitations = allInvitations.filter(inv =>
|
||||
inv.data.status === 'pending' &&
|
||||
!inv.isExpired() &&
|
||||
inv.data.organizationRefs.some(ref => ref.organizationId === requestArg.organizationId)
|
||||
);
|
||||
|
||||
return {
|
||||
invitations: await Promise.all(orgInvitations.map(inv => inv.createSavableObject())),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get org members
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||
'getOrgMembers',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsMemberOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const roles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||
data: { organizationId: requestArg.organizationId },
|
||||
});
|
||||
|
||||
const members: Array<{
|
||||
user: plugins.idpInterfaces.data.IUser;
|
||||
role: plugins.idpInterfaces.data.IRole;
|
||||
}> = [];
|
||||
|
||||
for (const role of roles) {
|
||||
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: role.data.userId,
|
||||
});
|
||||
if (memberUser) {
|
||||
members.push({
|
||||
user: await memberUser.createSavableObject(),
|
||||
role: await role.createSavableObject(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { members };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Cancel invitation
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CancelInvitation>(
|
||||
'cancelInvitation',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const invitation = await this.CUserInvitation.getInstance({ id: requestArg.invitationId });
|
||||
if (!invitation) {
|
||||
return { success: false, message: 'Invitation not found.' };
|
||||
}
|
||||
|
||||
await invitation.removeOrganization(requestArg.organizationId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Resend invitation
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResendInvitation>(
|
||||
'resendInvitation',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const invitation = await this.CUserInvitation.getInstance({ id: requestArg.invitationId });
|
||||
if (!invitation) {
|
||||
return { success: false, message: 'Invitation not found.' };
|
||||
}
|
||||
|
||||
await invitation.regenerateToken();
|
||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||
|
||||
return { success: true, message: 'Invitation resent.' };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove member
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RemoveMember>(
|
||||
'removeMember',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
// Cannot remove yourself if you're the only owner
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: requestArg.userId,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
return { success: false, message: 'Member not found.' };
|
||||
}
|
||||
|
||||
// Check if trying to remove an owner
|
||||
if (role.data.roles.includes('owner')) {
|
||||
// Count owners
|
||||
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||
data: { organizationId: requestArg.organizationId },
|
||||
});
|
||||
const ownerCount = allRoles.filter(r => r.data.roles.includes('owner')).length;
|
||||
if (ownerCount <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cannot remove the last owner. Transfer ownership first.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await role.delete();
|
||||
|
||||
// Remove org from user's connectedOrgs
|
||||
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: requestArg.userId,
|
||||
});
|
||||
if (memberUser && memberUser.data.connectedOrgs) {
|
||||
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
||||
orgId => orgId !== requestArg.organizationId
|
||||
);
|
||||
await memberUser.save();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Update member roles
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateMemberRoles>(
|
||||
'updateMemberRoles',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: requestArg.userId,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
return { success: false, message: 'Member not found.' };
|
||||
}
|
||||
|
||||
// If removing owner role, check we're not removing the last owner
|
||||
if (role.data.roles.includes('owner') && !requestArg.roles.includes('owner')) {
|
||||
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||
data: { organizationId: requestArg.organizationId },
|
||||
});
|
||||
const ownerCount = allRoles.filter(r => r.data.roles.includes('owner')).length;
|
||||
if (ownerCount <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cannot remove owner role from the last owner.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
role.data.roles = requestArg.roles;
|
||||
await role.save();
|
||||
|
||||
return { success: true, role: await role.createSavableObject() };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Transfer ownership
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_TransferOwnership>(
|
||||
'transferOwnership',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
|
||||
// Verify current user is an owner
|
||||
const currentUserRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: user.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (!currentUserRole || !currentUserRole.data.roles.includes('owner')) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Only owners can transfer ownership.'
|
||||
);
|
||||
}
|
||||
|
||||
// Get new owner's role
|
||||
const newOwnerRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: requestArg.newOwnerId,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (!newOwnerRole) {
|
||||
return { success: false, message: 'New owner must be a member of the organization.' };
|
||||
}
|
||||
|
||||
// Add owner role to new owner
|
||||
if (!newOwnerRole.data.roles.includes('owner')) {
|
||||
newOwnerRole.data.roles.push('owner');
|
||||
await newOwnerRole.save();
|
||||
}
|
||||
|
||||
// Remove owner role from current user (but keep other roles)
|
||||
currentUserRole.data.roles = currentUserRole.data.roles.filter(r => r !== 'owner');
|
||||
if (currentUserRole.data.roles.length === 0) {
|
||||
currentUserRole.data.roles = ['admin']; // Demote to admin
|
||||
}
|
||||
await currentUserRole.save();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get invitation by token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetInvitationByToken>(
|
||||
'getInvitationByToken',
|
||||
async (requestArg) => {
|
||||
const invitation = await this.CUserInvitation.getInstance({
|
||||
data: { token: requestArg.token },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return { isExpired: true, requiresRegistration: false };
|
||||
}
|
||||
|
||||
if (invitation.isExpired()) {
|
||||
return { isExpired: true, requiresRegistration: false };
|
||||
}
|
||||
|
||||
// Get organization names
|
||||
const organizations: Array<{ id: string; name: string }> = [];
|
||||
for (const ref of invitation.data.organizationRefs) {
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: ref.organizationId,
|
||||
});
|
||||
if (org) {
|
||||
organizations.push({ id: org.id, name: org.data.name });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user with this email exists
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: { email: invitation.data.email },
|
||||
});
|
||||
|
||||
return {
|
||||
invitation: await invitation.createSavableObject(),
|
||||
organizations,
|
||||
isExpired: false,
|
||||
requiresRegistration: !existingUser,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Accept invitation
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AcceptInvitation>(
|
||||
'acceptInvitation',
|
||||
async (requestArg) => {
|
||||
const invitation = await this.CUserInvitation.getInstance({
|
||||
data: { token: requestArg.token },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return { success: false, message: 'Invalid invitation token.' };
|
||||
}
|
||||
|
||||
if (invitation.isExpired()) {
|
||||
return { success: false, message: 'This invitation has expired.' };
|
||||
}
|
||||
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: requestArg.userId,
|
||||
});
|
||||
if (!user) {
|
||||
return { success: false, message: 'User not found.' };
|
||||
}
|
||||
|
||||
// Create roles for each organization
|
||||
const organizations: plugins.idpInterfaces.data.IOrganization[] = [];
|
||||
const roles: plugins.idpInterfaces.data.IRole[] = [];
|
||||
|
||||
for (const ref of invitation.data.organizationRefs) {
|
||||
// Check if role already exists
|
||||
let role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: user.id,
|
||||
organizationId: ref.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
role = await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
|
||||
action: 'create',
|
||||
userId: user.id,
|
||||
organizationId: ref.organizationId,
|
||||
roles: ref.roles,
|
||||
});
|
||||
}
|
||||
|
||||
roles.push(await role.createSavableObject());
|
||||
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: ref.organizationId,
|
||||
});
|
||||
if (org) {
|
||||
// Add role to org's roleIds if not already there
|
||||
if (!org.data.roleIds.includes(role.id)) {
|
||||
org.data.roleIds.push(role.id);
|
||||
await org.save();
|
||||
}
|
||||
organizations.push(await org.createSavableObject());
|
||||
}
|
||||
|
||||
// Update user's connectedOrgs
|
||||
if (!user.data.connectedOrgs) {
|
||||
user.data.connectedOrgs = [];
|
||||
}
|
||||
if (!user.data.connectedOrgs.includes(ref.organizationId)) {
|
||||
user.data.connectedOrgs.push(ref.organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
await user.save();
|
||||
await invitation.accept(user.id);
|
||||
|
||||
return { success: true, organizations, roles };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Bulk create invitations
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
|
||||
'bulkCreateInvitations',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: requestArg.organizationId,
|
||||
});
|
||||
const orgName = org?.data.name || 'an organization';
|
||||
|
||||
const results: Array<{
|
||||
email: string;
|
||||
success: boolean;
|
||||
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
|
||||
message?: string;
|
||||
}> = [];
|
||||
const summary = {
|
||||
total: 0,
|
||||
invited: 0,
|
||||
alreadyMembers: 0,
|
||||
invalid: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// Deduplicate emails in the batch
|
||||
const processedEmails = new Set<string>();
|
||||
|
||||
for (const inv of requestArg.invitations) {
|
||||
summary.total++;
|
||||
const email = inv.email?.toLowerCase().trim();
|
||||
|
||||
// Validate email format
|
||||
if (!email || !this.isValidEmail(email)) {
|
||||
results.push({
|
||||
email: inv.email || '',
|
||||
success: false,
|
||||
status: 'invalid_email',
|
||||
message: 'Invalid email format',
|
||||
});
|
||||
summary.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip duplicates within batch
|
||||
if (processedEmails.has(email)) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'invalid_email',
|
||||
message: 'Duplicate email in batch',
|
||||
});
|
||||
summary.invalid++;
|
||||
continue;
|
||||
}
|
||||
processedEmails.add(email);
|
||||
|
||||
try {
|
||||
// Check if user with this email already exists
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Check if already a member
|
||||
const existingRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRole) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'already_member',
|
||||
message: 'Already a member of this organization',
|
||||
});
|
||||
summary.alreadyMembers++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add existing user to org
|
||||
const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles;
|
||||
await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
|
||||
action: 'create',
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
roles,
|
||||
});
|
||||
results.push({
|
||||
email,
|
||||
success: true,
|
||||
status: 'invited',
|
||||
message: 'Existing user added to organization',
|
||||
});
|
||||
summary.invited++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if invitation already exists
|
||||
let invitation = await this.CUserInvitation.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
|
||||
const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles;
|
||||
|
||||
if (invitation) {
|
||||
// Add org to existing invitation
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, roles);
|
||||
} else {
|
||||
// Create new invitation
|
||||
invitation = await UserInvitation.createNewInvitation(
|
||||
email,
|
||||
requestArg.organizationId,
|
||||
user.id,
|
||||
roles
|
||||
);
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
await this.receptionRef.receptionMailer.sendInvitationEmail(
|
||||
email,
|
||||
orgName,
|
||||
invitation.data.token,
|
||||
this.receptionRef.options.baseUrl
|
||||
);
|
||||
|
||||
results.push({
|
||||
email,
|
||||
success: true,
|
||||
status: 'invited',
|
||||
});
|
||||
summary.invited++;
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'error',
|
||||
message: error.message || 'Unknown error',
|
||||
});
|
||||
summary.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, results, summary };
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invitation by email
|
||||
*/
|
||||
public async getInvitationByEmail(email: string): Promise<UserInvitation | null> {
|
||||
return this.CUserInvitation.getInstance({
|
||||
data: { email: email.toLowerCase().trim() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending invitations for an email (for registration flow)
|
||||
*/
|
||||
public async getPendingInvitationsForEmail(email: string): Promise<UserInvitation | null> {
|
||||
const invitation = await this.getInvitationByEmail(email);
|
||||
if (invitation && invitation.data.status === 'pending' && !invitation.isExpired()) {
|
||||
return invitation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired invitations
|
||||
*/
|
||||
public async cleanupExpiredInvitations(): Promise<number> {
|
||||
const allInvitations = await this.CUserInvitation.getInstances({
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
|
||||
let cleanedCount = 0;
|
||||
for (const invitation of allInvitations) {
|
||||
if (invitation.isExpired()) {
|
||||
invitation.data.status = 'expired';
|
||||
await invitation.save();
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invitation email
|
||||
*/
|
||||
private async sendInvitationEmail(
|
||||
invitation: UserInvitation,
|
||||
organizationId: string
|
||||
): Promise<void> {
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: organizationId,
|
||||
});
|
||||
const orgName = org?.data.name || 'an organization';
|
||||
|
||||
await this.receptionRef.receptionMailer.sendInvitationEmail(
|
||||
invitation.data.email,
|
||||
orgName,
|
||||
invitation.data.token,
|
||||
this.receptionRef.options.baseUrl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is admin/owner of organization
|
||||
*/
|
||||
private async verifyUserIsAdminOfOrg(userId: string, organizationId: string): Promise<void> {
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: { userId, organizationId },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.');
|
||||
}
|
||||
|
||||
const hasAdminRole = role.data.roles.some(r =>
|
||||
['owner', 'admin'].includes(r)
|
||||
);
|
||||
|
||||
if (!hasAdminRole) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'You do not have permission to perform this action.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is member of organization
|
||||
*/
|
||||
private async verifyUserIsMemberOfOrg(userId: string, organizationId: string): Promise<void> {
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: { userId, organizationId },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
private isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export class UserManager {
|
||||
connectedOrgs: user.data.connectedOrgs,
|
||||
status: null,
|
||||
password: null,
|
||||
isGlobalAdmin: user.data.isGlobalAdmin,
|
||||
} as plugins.idpInterfaces.data.IUser['data']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 2
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IIdpCliConfig {
|
||||
idpBaseUrl: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
export interface IStoredCredentials {
|
||||
refreshToken?: string;
|
||||
jwt?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IdpCli - A Node.js CLI client for idp.global
|
||||
* Uses file-based storage instead of browser webstore
|
||||
*/
|
||||
export class IdpCli {
|
||||
public config: IIdpCliConfig;
|
||||
public configDir: string;
|
||||
public credentialsPath: string;
|
||||
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private typedsocketDeferred = plugins.smartpromise.defer<plugins.typedsocket.TypedSocket>();
|
||||
|
||||
constructor(configArg: IIdpCliConfig) {
|
||||
this.config = configArg;
|
||||
this.configDir = configArg.configDir || plugins.path.join(plugins.os.homedir(), '.idp-global');
|
||||
this.credentialsPath = plugins.path.join(this.configDir, 'credentials.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure config directory exists
|
||||
*/
|
||||
private ensureConfigDir(): void {
|
||||
if (!plugins.fs.existsSync(this.configDir)) {
|
||||
plugins.fs.mkdirSync(this.configDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store credentials to file
|
||||
*/
|
||||
public storeCredentials(credentials: IStoredCredentials): void {
|
||||
this.ensureConfigDir();
|
||||
plugins.fs.writeFileSync(this.credentialsPath, JSON.stringify(credentials, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stored credentials
|
||||
*/
|
||||
public loadCredentials(): IStoredCredentials | null {
|
||||
try {
|
||||
if (!plugins.fs.existsSync(this.credentialsPath)) {
|
||||
return null;
|
||||
}
|
||||
const content = plugins.fs.readFileSync(this.credentialsPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored credentials (logout)
|
||||
*/
|
||||
public deleteCredentials(): void {
|
||||
try {
|
||||
if (plugins.fs.existsSync(this.credentialsPath)) {
|
||||
plugins.fs.unlinkSync(this.credentialsPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to IDP server via WebSocket
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.typedsocketDeferred.status === 'fulfilled') {
|
||||
return;
|
||||
}
|
||||
|
||||
let baseUrl = this.config.idpBaseUrl;
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
if (!baseUrl.endsWith('/typedrequest')) {
|
||||
baseUrl = `${baseUrl}/typedrequest`;
|
||||
}
|
||||
|
||||
console.log(`Connecting to ${baseUrl}...`);
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
this.typedrouter,
|
||||
baseUrl
|
||||
);
|
||||
this.typedsocketDeferred.resolve(this.typedsocket);
|
||||
console.log('Connected!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from IDP server
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.typedsocket) {
|
||||
await this.typedsocket.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Authentication Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
public async loginWithPassword(email: string, password: string): Promise<boolean> {
|
||||
await this.connect();
|
||||
|
||||
const loginRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (response.refreshToken) {
|
||||
this.storeCredentials({
|
||||
refreshToken: response.refreshToken,
|
||||
});
|
||||
console.log('Login successful!');
|
||||
return true;
|
||||
} else if (response.twoFaNeeded) {
|
||||
console.log('Two-factor authentication required.');
|
||||
return false;
|
||||
} else {
|
||||
console.log('Login failed.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with API token
|
||||
*/
|
||||
public async loginWithApiToken(apiToken: string): Promise<boolean> {
|
||||
await this.connect();
|
||||
|
||||
const loginRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithApiToken>(
|
||||
'loginWithApiToken'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
apiToken,
|
||||
});
|
||||
|
||||
if (response.jwt) {
|
||||
this.storeCredentials({
|
||||
jwt: response.jwt,
|
||||
});
|
||||
console.log('Login successful!');
|
||||
return true;
|
||||
} else {
|
||||
console.log('Login failed.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh JWT from stored refresh token
|
||||
*/
|
||||
public async refreshJwt(): Promise<string | null> {
|
||||
const credentials = this.loadCredentials();
|
||||
if (!credentials?.refreshToken) {
|
||||
console.error('No refresh token stored. Please login first.');
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
|
||||
const refreshRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
|
||||
const response = await refreshRequest.fire({
|
||||
refreshToken: credentials.refreshToken,
|
||||
});
|
||||
|
||||
if (response.jwt) {
|
||||
this.storeCredentials({
|
||||
...credentials,
|
||||
jwt: response.jwt,
|
||||
});
|
||||
return response.jwt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - clear stored credentials
|
||||
*/
|
||||
public async logout(): Promise<void> {
|
||||
const credentials = this.loadCredentials();
|
||||
|
||||
if (credentials?.refreshToken) {
|
||||
try {
|
||||
await this.connect();
|
||||
const logoutRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.ILogoutRequest>(
|
||||
'logout'
|
||||
);
|
||||
await logoutRequest.fire({
|
||||
refreshToken: credentials.refreshToken,
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors during server-side logout
|
||||
}
|
||||
}
|
||||
|
||||
this.deleteCredentials();
|
||||
console.log('Logged out successfully.');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
*/
|
||||
public async whoami(): Promise<plugins.idpInterfaces.data.IUser | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const whoIsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_WhoIs>(
|
||||
'whoIs'
|
||||
);
|
||||
|
||||
const response = await whoIsRequest.fire({ jwt });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user sessions
|
||||
*/
|
||||
public async getSessions(): Promise<plugins.idpInterfaces.request.IReq_GetUserSessions['response']['sessions'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const sessionsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||
'getUserSessions'
|
||||
);
|
||||
|
||||
const response = await sessionsRequest.fire({ jwt });
|
||||
return response.sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a session
|
||||
*/
|
||||
public async revokeSession(sessionId: string): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const revokeRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||
'revokeSession'
|
||||
);
|
||||
|
||||
const response = await revokeRequest.fire({ jwt, sessionId });
|
||||
return response.success;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Organization Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get organizations for current user
|
||||
*/
|
||||
public async getOrganizations(): Promise<{
|
||||
roles: plugins.idpInterfaces.data.IRole[];
|
||||
organizations: plugins.idpInterfaces.data.IOrganization[];
|
||||
} | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
const user = await this.whoami();
|
||||
if (!user) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const orgsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetRolesAndOrganizationsForUserId>(
|
||||
'getRolesAndOrganizationsForUserId'
|
||||
);
|
||||
|
||||
const response = await orgsRequest.fire({
|
||||
jwt,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new organization
|
||||
*/
|
||||
public async createOrganization(
|
||||
name: string,
|
||||
slug: string,
|
||||
mode: 'checkAvailability' | 'manifest' = 'manifest'
|
||||
): Promise<plugins.idpInterfaces.request.IReq_CreateOrganization['response'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
const user = await this.whoami();
|
||||
if (!user) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const createRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateOrganization>(
|
||||
'createOrganization'
|
||||
);
|
||||
|
||||
const response = await createRequest.fire({
|
||||
jwt,
|
||||
userId: user.id,
|
||||
organizationName: name,
|
||||
organizationSlug: slug,
|
||||
action: mode,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization members
|
||||
*/
|
||||
public async getOrgMembers(
|
||||
organizationId: string
|
||||
): Promise<plugins.idpInterfaces.request.IReq_GetOrgMembers['response']['members'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const membersRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||
'getOrgMembers'
|
||||
);
|
||||
|
||||
const response = await membersRequest.fire({
|
||||
jwt,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return response.members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to organization
|
||||
*/
|
||||
public async inviteMember(
|
||||
organizationId: string,
|
||||
email: string,
|
||||
roles: string[] = ['member']
|
||||
): Promise<plugins.idpInterfaces.request.IReq_CreateInvitation['response'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const inviteRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||
'createInvitation'
|
||||
);
|
||||
|
||||
const response = await inviteRequest.fire({
|
||||
jwt,
|
||||
organizationId,
|
||||
email,
|
||||
roles,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Admin Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if current user is global admin
|
||||
*/
|
||||
public async checkGlobalAdmin(): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const adminRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
|
||||
'checkGlobalAdmin'
|
||||
);
|
||||
|
||||
const response = await adminRequest.fire({ jwt });
|
||||
return response.isGlobalAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global app statistics (admin only)
|
||||
*/
|
||||
public async getGlobalAppStats(): Promise<plugins.idpInterfaces.request.IReq_GetGlobalAppStats['response']['apps'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const statsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'getGlobalAppStats'
|
||||
);
|
||||
|
||||
const response = await statsRequest.fire({ jwt });
|
||||
return response.apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a user (admin only)
|
||||
*/
|
||||
public async suspendUser(userId: string): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const suspendRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SuspendUser>(
|
||||
'suspendUser'
|
||||
);
|
||||
|
||||
await suspendRequest.fire({ jwt, userId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Ensure user is authenticated, refresh JWT if needed
|
||||
*/
|
||||
private async ensureAuthenticated(): Promise<string | null> {
|
||||
let credentials = this.loadCredentials();
|
||||
|
||||
if (!credentials) {
|
||||
console.error('Not logged in. Please run: idp login');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we have a JWT, return it
|
||||
if (credentials.jwt) {
|
||||
return credentials.jwt;
|
||||
}
|
||||
|
||||
// Otherwise, try to get a new JWT from refresh token
|
||||
if (credentials.refreshToken) {
|
||||
const jwt = await this.refreshJwt();
|
||||
return jwt;
|
||||
}
|
||||
|
||||
console.error('No valid credentials. Please run: idp login');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { IdpCli } from './classes.idpcli.js';
|
||||
|
||||
export { IdpCli } from './classes.idpcli.js';
|
||||
|
||||
const DEFAULT_IDP_URL = 'https://idp.global';
|
||||
|
||||
/**
|
||||
* Run the CLI
|
||||
*/
|
||||
export const runCli = async () => {
|
||||
const smartcliInstance = new plugins.smartcli.Smartcli();
|
||||
smartcliInstance.addVersion('1.0.0');
|
||||
|
||||
const getIdpClient = () => {
|
||||
const idpUrl = process.env.IDP_URL || DEFAULT_IDP_URL;
|
||||
return new IdpCli({ idpBaseUrl: idpUrl });
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Help
|
||||
// ============================================
|
||||
smartcliInstance.addHelp({
|
||||
helpText: `
|
||||
idp - CLI for idp.global identity provider
|
||||
|
||||
USAGE:
|
||||
idp <command> [options]
|
||||
|
||||
COMMANDS:
|
||||
login Login with email and password
|
||||
login-token Login with API token
|
||||
logout Logout and clear credentials
|
||||
whoami Show current user information
|
||||
|
||||
orgs List organizations
|
||||
orgs-create Create a new organization
|
||||
|
||||
members List organization members
|
||||
invite Invite a user to organization
|
||||
|
||||
sessions List active sessions
|
||||
revoke Revoke a session
|
||||
|
||||
admin-check Check if current user is global admin
|
||||
admin-apps List global apps (admin only)
|
||||
admin-suspend Suspend a user (admin only)
|
||||
|
||||
help Show this help message
|
||||
|
||||
ENVIRONMENT:
|
||||
IDP_URL Override IDP server URL (default: https://idp.global)
|
||||
|
||||
EXAMPLES:
|
||||
idp login
|
||||
idp whoami
|
||||
idp orgs
|
||||
idp members --org <org-id>
|
||||
idp invite --org <org-id> --email user@example.com
|
||||
`,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Login Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('login').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
|
||||
const emailAnswer = await interact.askQuestion({
|
||||
type: 'input',
|
||||
name: 'email',
|
||||
message: 'Email:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
const passwordAnswer = await interact.askQuestion({
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Password:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
await client.loginWithPassword(emailAnswer.value as string, passwordAnswer.value as string);
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('login-token').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
|
||||
const tokenAnswer = await interact.askQuestion({
|
||||
type: 'password',
|
||||
name: 'token',
|
||||
message: 'API Token:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
await client.loginWithApiToken(tokenAnswer.value as string);
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('logout').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
await client.logout();
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// User Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('whoami').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const user = await client.whoami();
|
||||
|
||||
if (user) {
|
||||
console.log('\nUser Information:');
|
||||
console.log(` ID: ${user.id}`);
|
||||
console.log(` Name: ${user.data?.name || 'N/A'}`);
|
||||
console.log(` Username: ${user.data?.username || 'N/A'}`);
|
||||
console.log(` Email: ${user.data?.email || 'N/A'}`);
|
||||
console.log(` Status: ${user.data?.status || 'N/A'}`);
|
||||
console.log(` Global Admin: ${user.data?.isGlobalAdmin ? 'Yes' : 'No'}`);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('sessions').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const sessions = await client.getSessions();
|
||||
|
||||
if (sessions) {
|
||||
console.log('\nActive Sessions:');
|
||||
for (const session of sessions) {
|
||||
console.log(` - ${session.id}`);
|
||||
console.log(` Device: ${session.deviceName || 'Unknown'}`);
|
||||
console.log(` Browser: ${session.browser || 'Unknown'}`);
|
||||
console.log(` OS: ${session.os || 'Unknown'}`);
|
||||
console.log(` Last Active: ${new Date(session.lastActive).toLocaleString()}`);
|
||||
console.log(` Current: ${session.isCurrent ? 'Yes' : 'No'}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('revoke').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const sessionId = argv.session || argv.s || argv._[1];
|
||||
|
||||
if (!sessionId) {
|
||||
console.error('Please provide a session ID: idp revoke --session <session-id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await client.revokeSession(sessionId);
|
||||
if (success) {
|
||||
console.log('Session revoked successfully.');
|
||||
} else {
|
||||
console.error('Failed to revoke session.');
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Organization Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('orgs').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const result = await client.getOrganizations();
|
||||
|
||||
if (result) {
|
||||
console.log('\nOrganizations:');
|
||||
for (const org of result.organizations) {
|
||||
const role = result.roles.find((r) => r.data?.organizationId === org.id);
|
||||
console.log(` - ${org.data?.name} (${org.id})`);
|
||||
console.log(` Slug: ${org.data?.slug}`);
|
||||
console.log(` Roles: ${role?.data?.roles?.join(', ') || 'Unknown'}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('orgs-create').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
|
||||
const nameAnswer = await interact.askQuestion({
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Organization Name:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
const slugAnswer = await interact.askQuestion({
|
||||
type: 'input',
|
||||
name: 'slug',
|
||||
message: 'Organization Slug:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
// First check availability
|
||||
const checkResult = await client.createOrganization(
|
||||
nameAnswer.value as string,
|
||||
slugAnswer.value as string,
|
||||
'checkAvailability'
|
||||
);
|
||||
|
||||
if (!checkResult?.nameAvailable) {
|
||||
console.error('Organization name or slug is not available.');
|
||||
await client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Then create
|
||||
const result = await client.createOrganization(
|
||||
nameAnswer.value as string,
|
||||
slugAnswer.value as string,
|
||||
'manifest'
|
||||
);
|
||||
|
||||
if (result?.resultingOrganization) {
|
||||
console.log('\nOrganization created successfully!');
|
||||
console.log(` ID: ${result.resultingOrganization.id}`);
|
||||
console.log(` Name: ${result.resultingOrganization.data?.name}`);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Member Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('members').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const orgId = argv.org || argv.o || argv._[1];
|
||||
|
||||
if (!orgId) {
|
||||
console.error('Please provide an organization ID: idp members --org <org-id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await client.getOrgMembers(orgId);
|
||||
|
||||
if (members) {
|
||||
console.log('\nOrganization Members:');
|
||||
for (const member of members) {
|
||||
console.log(` - ${member.user.data?.name || 'Unknown'}`);
|
||||
console.log(` Email: ${member.user.data?.email || 'N/A'}`);
|
||||
console.log(` Roles: ${member.role.data?.roles?.join(', ') || 'Unknown'}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('invite').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const orgId = argv.org || argv.o;
|
||||
const email = argv.email || argv.e || argv._[1];
|
||||
|
||||
if (!orgId || !email) {
|
||||
console.error('Please provide organization ID and email:');
|
||||
console.error(' idp invite --org <org-id> --email user@example.com');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await client.inviteMember(orgId, email);
|
||||
|
||||
if (result?.success) {
|
||||
console.log(`Invitation sent to ${email}`);
|
||||
} else {
|
||||
console.error(`Failed to send invitation: ${result?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Admin Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('admin-check').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const isAdmin = await client.checkGlobalAdmin();
|
||||
|
||||
if (isAdmin) {
|
||||
console.log('You are a global admin.');
|
||||
} else {
|
||||
console.log('You are not a global admin.');
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('admin-apps').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const apps = await client.getGlobalAppStats();
|
||||
|
||||
if (apps) {
|
||||
console.log('\nGlobal Apps:');
|
||||
for (const appInfo of apps) {
|
||||
console.log(` - ${appInfo.app.data?.name}`);
|
||||
console.log(` ID: ${appInfo.app.id}`);
|
||||
console.log(` Connections: ${appInfo.connectionCount}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('admin-suspend').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const userId = argv.user || argv.u || argv._[1];
|
||||
|
||||
if (!userId) {
|
||||
console.error('Please provide a user ID: idp admin-suspend --user <user-id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
const confirmAnswer = await interact.askQuestion({
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Are you sure you want to suspend user ${userId}?`,
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (confirmAnswer.value) {
|
||||
const success = await client.suspendUser(userId);
|
||||
if (success) {
|
||||
console.log('User suspended successfully.');
|
||||
} else {
|
||||
console.error('Failed to suspend user.');
|
||||
}
|
||||
} else {
|
||||
console.log('Operation cancelled.');
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Default/Standard command
|
||||
// ============================================
|
||||
smartcliInstance.standardCommand().subscribe(async (argv) => {
|
||||
// If no command specified, show help
|
||||
smartcliInstance.triggerCommand('help', argv);
|
||||
});
|
||||
|
||||
// Start parsing
|
||||
smartcliInstance.startParse();
|
||||
};
|
||||
|
||||
// Auto-run if this is the main module
|
||||
runCli().catch(console.error);
|
||||
@@ -0,0 +1,25 @@
|
||||
// node built-ins
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
export { fs, path, os };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartcli from '@push.rocks/smartcli';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartinteract from '@push.rocks/smartinteract';
|
||||
|
||||
export { smartcli, smartpromise, smartrx, smartinteract };
|
||||
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export { typedrequest, typedsocket };
|
||||
|
||||
// local
|
||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
||||
|
||||
export { idpInterfaces };
|
||||
@@ -11,12 +11,12 @@ export class IdpClient {
|
||||
|
||||
// INSTANCE PUBLIC
|
||||
|
||||
public appData: plugins.idpInterfaces.data.IApp;
|
||||
public appData: plugins.idpInterfaces.data.IAppLegacy;
|
||||
public rolesReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1);
|
||||
public organizationsReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1);
|
||||
|
||||
public parsedReceptionUrl: plugins.smarturl.Smarturl;
|
||||
constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IApp) {
|
||||
constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IAppLegacy) {
|
||||
if (receptionBaseUrlArg.endsWith('/')) {
|
||||
receptionBaseUrlArg = receptionBaseUrlArg.slice(0, -1);
|
||||
}
|
||||
@@ -126,9 +126,9 @@ export class IdpClient {
|
||||
if (!refreshTokenArg) {
|
||||
extractedJwt = await this.helpers.extractDataFromJwtString(await this.getJwt());
|
||||
}
|
||||
await this.typedsocketDeferred.promise;
|
||||
const refreshJwtReq =
|
||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
this.parsedReceptionUrl.toString(),
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
const response = await refreshJwtReq.fire({
|
||||
@@ -146,12 +146,12 @@ export class IdpClient {
|
||||
/**
|
||||
* 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 extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
|
||||
await this.typedsocketDeferred.promise;
|
||||
const getTransferToken =
|
||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
this.parsedReceptionUrl.toString(),
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
const response = await getTransferToken.fire({
|
||||
@@ -187,9 +187,9 @@ export class IdpClient {
|
||||
const url = plugins.smarturl.Smarturl.createFromUrl(href);
|
||||
const transferToken = url.searchParams['transfertoken'];
|
||||
if (transferToken) {
|
||||
await this.typedsocketDeferred.promise;
|
||||
const getTransferToken =
|
||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
this.parsedReceptionUrl.toString(),
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
const response = await getTransferToken.fire({
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IdpClient } from "./classes.idpclient.js";
|
||||
|
||||
/**
|
||||
* this class bundles all the typed requests that are used by the idp
|
||||
* All requests use TypedSocket (WebSocket) transport
|
||||
*/
|
||||
export class IdpRequests {
|
||||
idpClientArg: IdpClient;
|
||||
@@ -11,52 +12,315 @@ export class IdpRequests {
|
||||
}
|
||||
|
||||
public get afterRegistrationEmailClicked () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||
'afterRegistrationEmailClicked'
|
||||
);
|
||||
}
|
||||
|
||||
public get setData() {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||
'setDataForRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
public get mobileNumberVerification () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||
'mobileVerificationForRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public get finishRegistration() {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||
'finishRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
public get loginWithUserNameAndPassword () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword'
|
||||
);
|
||||
}
|
||||
|
||||
public get obtainJwt () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
}
|
||||
|
||||
public get obtainOneTimeToken () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Login & Authentication
|
||||
// ============================================
|
||||
|
||||
public get loginWithEmail() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||
'loginWithEmail'
|
||||
);
|
||||
}
|
||||
|
||||
public get loginWithEmailAfterToken() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
||||
'loginWithEmailAfterEmailTokenAquired'
|
||||
);
|
||||
}
|
||||
|
||||
public get loginWithApiToken() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithApiToken>(
|
||||
'loginWithApiToken'
|
||||
);
|
||||
}
|
||||
|
||||
public get resetPassword() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||
'resetPassword'
|
||||
);
|
||||
}
|
||||
|
||||
public get setNewPassword() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
||||
'setNewPassword'
|
||||
);
|
||||
}
|
||||
|
||||
public get obtainDeviceId() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ObtainDeviceId>(
|
||||
'obtainDeviceId'
|
||||
);
|
||||
}
|
||||
|
||||
public get attachDeviceId() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AttachDeviceId>(
|
||||
'attachDeviceId'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Registration
|
||||
// ============================================
|
||||
|
||||
public get firstRegistration() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
|
||||
'firstRegistrationRequest'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Management
|
||||
// ============================================
|
||||
|
||||
public get getUserData() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserData>(
|
||||
'getUserData'
|
||||
);
|
||||
}
|
||||
|
||||
public get setUserData() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetUserData>(
|
||||
'setUserData'
|
||||
);
|
||||
}
|
||||
|
||||
public get getUserSessions() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||
'getUserSessions'
|
||||
);
|
||||
}
|
||||
|
||||
public get revokeSession() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||
'revokeSession'
|
||||
);
|
||||
}
|
||||
|
||||
public get getUserActivity() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
|
||||
'getUserActivity'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Organization Management
|
||||
// ============================================
|
||||
|
||||
public get getOrganizationById() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrganizationById>(
|
||||
'getOrganizationById'
|
||||
);
|
||||
}
|
||||
|
||||
public get updateOrganization() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateOrganization>(
|
||||
'updateOrganization'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Member & Invitation Management
|
||||
// ============================================
|
||||
|
||||
public get createInvitation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||
'createInvitation'
|
||||
);
|
||||
}
|
||||
|
||||
public get getOrgInvitations() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
|
||||
'getOrgInvitations'
|
||||
);
|
||||
}
|
||||
|
||||
public get getOrgMembers() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||
'getOrgMembers'
|
||||
);
|
||||
}
|
||||
|
||||
public get cancelInvitation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CancelInvitation>(
|
||||
'cancelInvitation'
|
||||
);
|
||||
}
|
||||
|
||||
public get resendInvitation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResendInvitation>(
|
||||
'resendInvitation'
|
||||
);
|
||||
}
|
||||
|
||||
public get removeMember() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RemoveMember>(
|
||||
'removeMember'
|
||||
);
|
||||
}
|
||||
|
||||
public get updateMemberRoles() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateMemberRoles>(
|
||||
'updateMemberRoles'
|
||||
);
|
||||
}
|
||||
|
||||
public get transferOwnership() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_TransferOwnership>(
|
||||
'transferOwnership'
|
||||
);
|
||||
}
|
||||
|
||||
public get getInvitationByToken() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetInvitationByToken>(
|
||||
'getInvitationByToken'
|
||||
);
|
||||
}
|
||||
|
||||
public get acceptInvitation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AcceptInvitation>(
|
||||
'acceptInvitation'
|
||||
);
|
||||
}
|
||||
|
||||
public get bulkCreateInvitations() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
|
||||
'bulkCreateInvitations'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Billing
|
||||
// ============================================
|
||||
|
||||
public get getBillingPlan() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetBillingPlan>(
|
||||
'getBillingPlan'
|
||||
);
|
||||
}
|
||||
|
||||
public get getPaddleConfig() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPaddleConfig>(
|
||||
'getPaddleConfig'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// JWT Verification & Management
|
||||
// ============================================
|
||||
|
||||
public get getPublicKeyForValidation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPublicKeyForValidation>(
|
||||
'getPublicKeyForValidation'
|
||||
);
|
||||
}
|
||||
|
||||
public get pushPublicKeyForValidation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PushPublicKeyForValidation>(
|
||||
'pushPublicKeyForValidation'
|
||||
);
|
||||
}
|
||||
|
||||
public get pushOrGetJwtIdBlocklist() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PushOrGetJwtIdBlocklist>(
|
||||
'pushOrGetJwtIdBlocklist'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Suspension (Admin)
|
||||
// ============================================
|
||||
|
||||
public get suspendUser() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SuspendUser>(
|
||||
'suspendUser'
|
||||
);
|
||||
}
|
||||
|
||||
public get deleteSuspendedUser() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IDeleteSuspendedUser>(
|
||||
'deleteSuspendedUser'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Admin (Global Admin Only)
|
||||
// ============================================
|
||||
|
||||
public get checkGlobalAdmin() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
|
||||
'checkGlobalAdmin'
|
||||
);
|
||||
}
|
||||
|
||||
public get getGlobalAppStats() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'getGlobalAppStats'
|
||||
);
|
||||
}
|
||||
|
||||
public get createGlobalApp() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
||||
'createGlobalApp'
|
||||
);
|
||||
}
|
||||
|
||||
public get updateGlobalApp() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
||||
'updateGlobalApp'
|
||||
);
|
||||
}
|
||||
|
||||
public get deleteGlobalApp() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
||||
'deleteGlobalApp'
|
||||
);
|
||||
}
|
||||
|
||||
public get regenerateAppCredentials() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||
'regenerateAppCredentials'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 3
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './loint-reception.activity.js';
|
||||
export * from './loint-reception.app.js';
|
||||
export * from './loint-reception.appconnection.js';
|
||||
export * from './loint-reception.billingplan.js';
|
||||
export * from './loint-reception.device.js';
|
||||
export * from './loint-reception.jwt.js';
|
||||
@@ -7,3 +9,4 @@ export * from './loint-reception.organization.js';
|
||||
export * from './loint-reception.paddlecheckoutdata.js';
|
||||
export * from './loint-reception.role.js';
|
||||
export * from './loint-reception.user.js';
|
||||
export * from './loint-reception.userinvitation.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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
@@ -11,3 +87,13 @@ export interface IApp {
|
||||
logoUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage interface for SmartData documents
|
||||
* Uses the discriminated union approach with a 'type' field
|
||||
*/
|
||||
export interface IAppDocument {
|
||||
id: string;
|
||||
type: TAppType;
|
||||
data: IGlobalApp['data'] | IPartnerApp['data'] | ICustomOidcApp['data'];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
/** Standard role types available in all organizations */
|
||||
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
||||
|
||||
/**
|
||||
* a role describes a
|
||||
* A role describes a user's permissions within an organization.
|
||||
* Users can have multiple roles (e.g., ['owner', 'billing-admin']).
|
||||
*/
|
||||
export interface IRole {
|
||||
id: string;
|
||||
data: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
role: 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
||||
/** Array of roles - supports standard roles and custom role names */
|
||||
roles: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,5 +26,11 @@ export interface IUser {
|
||||
* speeds up lookup
|
||||
*/
|
||||
connectedOrgs: string[];
|
||||
/**
|
||||
* Platform-level admin flag
|
||||
* Users with this flag can access the global admin panel
|
||||
* to manage global apps, view platform stats, etc.
|
||||
*/
|
||||
isGlobalAdmin?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
/**
|
||||
* A UserInvitation represents an invitation to join an organization.
|
||||
* Key characteristics:
|
||||
* - Unique by email (multiple orgs can share the same invitation)
|
||||
* - Converts to real User on registration or folds into existing user
|
||||
* - Auto-expires after 90 days
|
||||
*/
|
||||
export interface IUserInvitation {
|
||||
id: string;
|
||||
data: {
|
||||
/** The invited email address - unique key for sharing across orgs */
|
||||
email: string;
|
||||
|
||||
/** Secure token for invitation link validation */
|
||||
token: string;
|
||||
|
||||
/** Current status of the invitation */
|
||||
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
|
||||
|
||||
/** When the invitation was first created */
|
||||
createdAt: number;
|
||||
|
||||
/** When the invitation expires (createdAt + 90 days) */
|
||||
expiresAt: number;
|
||||
|
||||
/**
|
||||
* Organizations that have invited this email.
|
||||
* Multiple orgs can link to the same invitation.
|
||||
*/
|
||||
organizationRefs: IOrganizationInvitationRef[];
|
||||
|
||||
/** When the invitation was accepted (user registered/folded) */
|
||||
acceptedAt?: number;
|
||||
|
||||
/** The User ID after conversion (when accepted) */
|
||||
convertedToUserId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one organization's invitation to the user.
|
||||
* Stored as part of IUserInvitation.organizationRefs array.
|
||||
*/
|
||||
export interface IOrganizationInvitationRef {
|
||||
/** The organization that sent this invitation */
|
||||
organizationId: string;
|
||||
|
||||
/** The user who sent the invitation */
|
||||
invitedByUserId: string;
|
||||
|
||||
/** When this org invited the user */
|
||||
invitedAt: number;
|
||||
|
||||
/** Roles to assign when the invitation is accepted */
|
||||
roles: string[];
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './loint-reception.admin.js';
|
||||
export * from './loint-reception.apitoken.js';
|
||||
export * from './loint-reception.app.js';
|
||||
export * from './loint-reception.authorization.js';
|
||||
export * from './loint-reception.billingplan.js';
|
||||
export * from './loint-reception.jwt.js';
|
||||
@@ -7,3 +9,4 @@ export * from './loint-reception.organization.js';
|
||||
export * from './loint-reception.plan.js';
|
||||
export * from './loint-reception.registration.js';
|
||||
export * from './loint-reception.user.js';
|
||||
export * from './loint-reception.userinvitation.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;
|
||||
};
|
||||
}
|
||||
@@ -37,3 +37,19 @@ export interface IReq_GetBillingPlan
|
||||
billingPlan: data.IBillingPlan;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Paddle configuration from environment variables
|
||||
*/
|
||||
export interface IReq_GetPaddleConfig
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetPaddleConfig
|
||||
> {
|
||||
method: 'getPaddleConfig';
|
||||
request: {};
|
||||
response: {
|
||||
paddleToken: string;
|
||||
paddlePriceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import * as data from '../data/index.js';
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
/**
|
||||
* Request to get the public key for JWT validation.
|
||||
*
|
||||
* **Direction:** Client → idp.global
|
||||
* **Requester:** Backend services that need to verify JWTs
|
||||
* **Handler:** idp.global
|
||||
*
|
||||
* Use this to fetch the current public key for verifying JWT signatures.
|
||||
* The backend token authenticates the requesting service.
|
||||
*/
|
||||
export interface IReq_GetPublicKeyForValidation
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
@@ -15,6 +25,16 @@ export interface IReq_GetPublicKeyForValidation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push public key to connected backend services for JWT validation.
|
||||
*
|
||||
* **Direction:** idp.global → Client
|
||||
* **Requester:** idp.global (pushes when the JWT signing key rotates)
|
||||
* **Handler:** Backend services - must register a TypedHandler for this method
|
||||
*
|
||||
* Backend services should register a handler using `IdpClient.onPublicKeyPush()`
|
||||
* to receive key rotation updates and update their local key cache.
|
||||
*/
|
||||
export interface IReq_PushPublicKeyForValidation
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
@@ -28,7 +48,21 @@ export interface IReq_PushPublicKeyForValidation
|
||||
}
|
||||
|
||||
/**
|
||||
* allows getting or pushing a blocklist of jwt ids
|
||||
* Push or get JWT ID blocklist for revoked tokens.
|
||||
*
|
||||
* **Bidirectional:**
|
||||
* - **GET direction:** Client → idp.global - Client requests current blocklist
|
||||
* - **PUSH direction:** idp.global → Client - Server pushes new blocklisted IDs
|
||||
*
|
||||
* **For GET (client fires):**
|
||||
* - Fire with empty/undefined `blockedJwtIds` to request the full blocklist
|
||||
* - Response contains the complete list of blocked JWT IDs
|
||||
* - Use `IdpClient.requests.getJwtIdBlocklist` for this direction
|
||||
*
|
||||
* **For PUSH (idp.global fires):**
|
||||
* - idp.global sends newly blocklisted JWT IDs to connected clients
|
||||
* - Clients must register a handler using `IdpClient.onBlocklistPush()`
|
||||
* - Store received IDs locally to reject revoked tokens
|
||||
*/
|
||||
export interface IReq_PushOrGetJwtIdBlocklist
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
|
||||
@@ -103,7 +103,7 @@ export interface IReq_ExchangeRefreshTokenAndTransferToken
|
||||
request: {
|
||||
transferToken?: string;
|
||||
refreshToken?: string;
|
||||
appData: data.IApp;
|
||||
appData: data.IAppLegacy;
|
||||
};
|
||||
response: {
|
||||
refreshToken?: string;
|
||||
|
||||
@@ -84,3 +84,59 @@ export interface IReq_WhoIs {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import * as data from '../data/index.js';
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
/**
|
||||
* Create an invitation to join an organization
|
||||
*/
|
||||
export interface IReq_CreateInvitation
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_CreateInvitation
|
||||
> {
|
||||
method: 'createInvitation';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
invitation?: data.IUserInvitation;
|
||||
message?: string;
|
||||
/** True if a new invitation was created, false if email was added to existing */
|
||||
isNew: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending invitations for an organization
|
||||
*/
|
||||
export interface IReq_GetOrgInvitations
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetOrgInvitations
|
||||
> {
|
||||
method: 'getOrgInvitations';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
};
|
||||
response: {
|
||||
invitations: data.IUserInvitation[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members of an organization (users with roles)
|
||||
*/
|
||||
export interface IReq_GetOrgMembers
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetOrgMembers
|
||||
> {
|
||||
method: 'getOrgMembers';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
};
|
||||
response: {
|
||||
members: Array<{
|
||||
user: data.IUser;
|
||||
role: data.IRole;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending invitation
|
||||
*/
|
||||
export interface IReq_CancelInvitation
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_CancelInvitation
|
||||
> {
|
||||
method: 'cancelInvitation';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
invitationId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend invitation email
|
||||
*/
|
||||
export interface IReq_ResendInvitation
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_ResendInvitation
|
||||
> {
|
||||
method: 'resendInvitation';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
invitationId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from an organization
|
||||
*/
|
||||
export interface IReq_RemoveMember
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveMember
|
||||
> {
|
||||
method: 'removeMember';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's roles
|
||||
*/
|
||||
export interface IReq_UpdateMemberRoles
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateMemberRoles
|
||||
> {
|
||||
method: 'updateMemberRoles';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
roles: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
role?: data.IRole;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer organization ownership to another member
|
||||
*/
|
||||
export interface IReq_TransferOwnership
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_TransferOwnership
|
||||
> {
|
||||
method: 'transferOwnership';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
newOwnerId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation (called during registration or email verification)
|
||||
*/
|
||||
export interface IReq_AcceptInvitation
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_AcceptInvitation
|
||||
> {
|
||||
method: 'acceptInvitation';
|
||||
request: {
|
||||
token: string;
|
||||
userId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
organizations?: data.IOrganization[];
|
||||
roles?: data.IRole[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invitation by token (for invitation landing page)
|
||||
*/
|
||||
export interface IReq_GetInvitationByToken
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetInvitationByToken
|
||||
> {
|
||||
method: 'getInvitationByToken';
|
||||
request: {
|
||||
token: string;
|
||||
};
|
||||
response: {
|
||||
invitation?: data.IUserInvitation;
|
||||
organizations?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
isExpired: boolean;
|
||||
requiresRegistration: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create invitations from a list (typically from CSV import)
|
||||
*/
|
||||
export interface IReq_BulkCreateInvitations
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_BulkCreateInvitations
|
||||
> {
|
||||
method: 'bulkCreateInvitations';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
invitations: Array<{
|
||||
email: string;
|
||||
roles?: string[];
|
||||
}>;
|
||||
defaultRoles: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
email: string;
|
||||
success: boolean;
|
||||
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
|
||||
message?: string;
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
invited: number;
|
||||
alreadyMembers: number;
|
||||
invalid: number;
|
||||
errors: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 1
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.4.2',
|
||||
version: '1.12.1',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { accountDesignTokens } from './sharedstyles.js';
|
||||
import { IdpState } from '../../states/idp.state.js';
|
||||
|
||||
interface IParsedEmail {
|
||||
email: string;
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IBulkInviteResult {
|
||||
invitedCount: number;
|
||||
failedCount: number;
|
||||
alreadyMemberCount: number;
|
||||
}
|
||||
|
||||
// Internal form element for reactive state management
|
||||
@customElement('idp-bulk-invite-form')
|
||||
export class BulkInviteForm extends DeesElement {
|
||||
@state()
|
||||
accessor organizationId: string = '';
|
||||
|
||||
@state()
|
||||
accessor organizationName: string = '';
|
||||
|
||||
@state()
|
||||
accessor parsedEmails: IParsedEmail[] = [];
|
||||
|
||||
@state()
|
||||
accessor selectedRoles: string[] = ['viewer'];
|
||||
|
||||
@state()
|
||||
accessor submitting: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor error: string = '';
|
||||
|
||||
@state()
|
||||
accessor results: IBulkInviteResult | null = null;
|
||||
|
||||
private static readonly AVAILABLE_ROLES = ['admin', 'editor', 'viewer', 'guest'];
|
||||
|
||||
public resolveWith: ((result: IBulkInviteResult | null) => void) | null = null;
|
||||
public modal: plugins.deesCatalog.DeesModal | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
accountDesignTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.file-upload-area {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.file-upload-area:hover {
|
||||
border-color: var(--muted-foreground);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.file-upload-area.has-data {
|
||||
border-style: solid;
|
||||
border-color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 32px;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.sample-link {
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.preview-stats .valid {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.preview-stats .invalid {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preview-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.preview-item.invalid {
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.preview-email {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.preview-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-status.valid {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.preview-status.invalid {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.role-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-option {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.role-option:hover {
|
||||
border-color: var(--foreground);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-option.selected {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
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;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
padding: 16px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.results-section.has-failures {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border-color: rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.results-stats {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.results) {
|
||||
return this.renderResults();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
Upload a CSV file with email addresses to invite multiple people at once.
|
||||
</div>
|
||||
|
||||
${this.error ? html`
|
||||
<div class="error-message">${this.error}</div>
|
||||
` : ''}
|
||||
|
||||
${this.renderFileUpload()}
|
||||
${this.parsedEmails.length > 0 ? this.renderPreview() : ''}
|
||||
${this.parsedEmails.length > 0 ? this.renderRoleSelector() : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFileUpload(): TemplateResult {
|
||||
const validCount = this.parsedEmails.filter(e => e.valid).length;
|
||||
const hasData = this.parsedEmails.length > 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="file-upload-area ${hasData ? 'has-data' : ''}"
|
||||
@click=${() => this.triggerFileInput()}
|
||||
@dragover=${(e: DragEvent) => { e.preventDefault(); }}
|
||||
@drop=${(e: DragEvent) => this.handleFileDrop(e)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
@change=${(e: Event) => this.handleFileSelect(e)}
|
||||
/>
|
||||
${hasData ? html`
|
||||
<div class="upload-icon">
|
||||
<dees-icon .icon=${'lucide:check-circle'}></dees-icon>
|
||||
</div>
|
||||
<div class="upload-text">${validCount} valid email(s) loaded</div>
|
||||
<div class="upload-hint">Click to replace with a different file</div>
|
||||
` : html`
|
||||
<div class="upload-icon">
|
||||
<dees-icon .icon=${'lucide:upload'}></dees-icon>
|
||||
</div>
|
||||
<div class="upload-text">Drop CSV file here or click to browse</div>
|
||||
<div class="upload-hint">
|
||||
<span class="sample-link" @click=${(e: Event) => { e.stopPropagation(); this.downloadSampleCSV(); }}>Download sample CSV</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPreview(): TemplateResult {
|
||||
const validCount = this.parsedEmails.filter(e => e.valid).length;
|
||||
const invalidCount = this.parsedEmails.filter(e => !e.valid).length;
|
||||
|
||||
return html`
|
||||
<div class="preview-section">
|
||||
<div class="preview-header">
|
||||
<span class="preview-title">Email Preview</span>
|
||||
<span class="preview-stats">
|
||||
<span class="valid">${validCount} valid</span>
|
||||
${invalidCount > 0 ? html`, <span class="invalid">${invalidCount} invalid</span>` : ''}
|
||||
</span>
|
||||
<button class="clear-button" @click=${() => this.clearEmails()}>Clear</button>
|
||||
</div>
|
||||
<div class="preview-list">
|
||||
${this.parsedEmails.map(item => html`
|
||||
<div class="preview-item ${item.valid ? '' : 'invalid'}">
|
||||
<span class="preview-email">${item.email}</span>
|
||||
<span class="preview-status ${item.valid ? 'valid' : 'invalid'}">
|
||||
${item.valid ? 'Valid' : (item.error || 'Invalid')}
|
||||
</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRoleSelector(): TemplateResult {
|
||||
return html`
|
||||
<div class="role-section">
|
||||
<div class="section-label">Assign Role</div>
|
||||
<div class="role-selector">
|
||||
${BulkInviteForm.AVAILABLE_ROLES.map(role => html`
|
||||
<button
|
||||
class="role-option ${this.selectedRoles.includes(role) ? 'selected' : ''}"
|
||||
@click=${() => this.toggleRole(role)}
|
||||
?disabled=${this.submitting}
|
||||
>
|
||||
${role}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResults(): TemplateResult {
|
||||
const hasFailures = this.results!.failedCount > 0 || this.results!.alreadyMemberCount > 0;
|
||||
|
||||
return html`
|
||||
<div class="results-section ${hasFailures ? 'has-failures' : ''}">
|
||||
<div class="results-title">Bulk Invite Complete</div>
|
||||
<div class="results-stats">
|
||||
${this.results!.invitedCount} invitation(s) sent successfully.
|
||||
${this.results!.alreadyMemberCount > 0 ? html`<br>${this.results!.alreadyMemberCount} already member(s).` : ''}
|
||||
${this.results!.failedCount > 0 ? html`<br>${this.results!.failedCount} failed.` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private triggerFileInput(): void {
|
||||
const input = this.shadowRoot?.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
input?.click();
|
||||
}
|
||||
|
||||
private handleFileDrop(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) {
|
||||
this.parseCSVFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFileSelect(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
this.parseCSVFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
private async parseCSVFile(file: File): Promise<void> {
|
||||
const text = await file.text();
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim());
|
||||
|
||||
const parsed: IParsedEmail[] = [];
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Skip header row if it looks like "email" or similar
|
||||
if (i === 0 && (line.toLowerCase() === 'email' || line.toLowerCase() === 'emails' || line.toLowerCase() === 'e-mail')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email from line (handle quoted values, commas)
|
||||
const email = line.replace(/["']/g, '').split(',')[0].trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(email)) {
|
||||
parsed.push({ email, valid: false, error: 'Duplicate' });
|
||||
continue;
|
||||
}
|
||||
seen.add(email);
|
||||
|
||||
if (!emailRegex.test(email)) {
|
||||
parsed.push({ email, valid: false, error: 'Invalid format' });
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed.push({ email, valid: true });
|
||||
}
|
||||
|
||||
this.parsedEmails = parsed;
|
||||
this.error = '';
|
||||
}
|
||||
|
||||
private downloadSampleCSV(): void {
|
||||
const content = 'email\nuser1@example.com\nuser2@example.com\nuser3@example.com';
|
||||
const blob = new Blob([content], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sample-invite-list.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private clearEmails(): void {
|
||||
this.parsedEmails = [];
|
||||
this.error = '';
|
||||
}
|
||||
|
||||
private toggleRole(role: string): void {
|
||||
if (this.selectedRoles.includes(role)) {
|
||||
this.selectedRoles = this.selectedRoles.filter(r => r !== role);
|
||||
} else {
|
||||
this.selectedRoles = [...this.selectedRoles, role];
|
||||
}
|
||||
if (this.selectedRoles.length === 0) {
|
||||
this.selectedRoles = ['viewer'];
|
||||
}
|
||||
}
|
||||
|
||||
public canSubmit(): boolean {
|
||||
const validEmails = this.parsedEmails.filter(e => e.valid);
|
||||
return validEmails.length > 0 && this.selectedRoles.length > 0 && !this.submitting && !this.results;
|
||||
}
|
||||
|
||||
public async handleSubmit(): Promise<IBulkInviteResult | null> {
|
||||
if (!this.canSubmit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const validEmails = this.parsedEmails.filter(e => e.valid);
|
||||
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
|
||||
'bulkCreateInvitations'
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
invitations: validEmails.map(e => ({ email: e.email })),
|
||||
defaultRoles: this.selectedRoles,
|
||||
});
|
||||
|
||||
this.results = {
|
||||
invitedCount: response.summary.invited,
|
||||
failedCount: response.summary.errors + response.summary.invalid,
|
||||
alreadyMemberCount: response.summary.alreadyMembers,
|
||||
};
|
||||
|
||||
return this.results;
|
||||
} catch (error) {
|
||||
console.error('Error sending bulk invitations:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to send invitations. Please try again.';
|
||||
return null;
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
public handleCancel(): void {
|
||||
this.modal?.destroy();
|
||||
this.resolveWith?.(null);
|
||||
}
|
||||
|
||||
public handleClose(): void {
|
||||
this.modal?.destroy();
|
||||
this.resolveWith?.(this.results);
|
||||
}
|
||||
}
|
||||
|
||||
// Export the modal utility class
|
||||
export class BulkInviteModal {
|
||||
public static async show(options: {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
}): Promise<IBulkInviteResult | null> {
|
||||
return new Promise<IBulkInviteResult | null>((resolve) => {
|
||||
const formElement = new BulkInviteForm();
|
||||
formElement.organizationId = options.organizationId;
|
||||
formElement.organizationName = options.organizationName;
|
||||
formElement.resolveWith = resolve;
|
||||
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Bulk Invite Members',
|
||||
content: html`${formElement}`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async () => {
|
||||
formElement.handleCancel();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Send Invitations',
|
||||
action: async () => {
|
||||
const result = await formElement.handleSubmit();
|
||||
if (result) {
|
||||
// Wait a bit for user to see results, then close
|
||||
setTimeout(() => {
|
||||
formElement.handleClose();
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
width: 520,
|
||||
}).then((modal) => {
|
||||
formElement.modal = modal;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { LeleAccountNavigation } from './navigation.js';
|
||||
import { OrgSelectModal, type IOrgSelectResult } from './org-select-modal.js';
|
||||
import { CreateOrgModal } from './create-org-modal.js';
|
||||
import { accountDesignTokens } from './sharedstyles.js';
|
||||
|
||||
import * as views from './views/index.js';
|
||||
import * as accountstate from '../../states/accountstate.js';
|
||||
@@ -36,15 +39,13 @@ export class IdpAccountContent extends DeesElement {
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
accountDesignTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: #fff;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: ${cssManager.bdTheme('#eeeeeb', '#000000')}
|
||||
background: var(--background);
|
||||
}
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
@@ -72,7 +73,7 @@ export class IdpAccountContent extends DeesElement {
|
||||
height: 100vh;
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior: contain;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -101,6 +102,25 @@ export class IdpAccountContent extends DeesElement {
|
||||
this.subrouter = this.domtools.router.createSubRouter('/account');
|
||||
const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer');
|
||||
|
||||
// Setup event listeners for modals
|
||||
this.addEventListener('open-org-select-modal', (async (e: CustomEvent) => {
|
||||
const result = await OrgSelectModal.show({
|
||||
targetPath: e.detail.targetPath,
|
||||
title: e.detail.title,
|
||||
description: e.detail.description,
|
||||
});
|
||||
if (result) {
|
||||
this.subrouter.pushUrl(result.path);
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
this.addEventListener('open-create-org-modal', async () => {
|
||||
const org = await CreateOrgModal.show();
|
||||
if (org) {
|
||||
this.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
|
||||
}
|
||||
});
|
||||
|
||||
const cleanupViews = async () => {
|
||||
for (const child of Array.from(viewcontainer.children)) {
|
||||
viewcontainer.removeChild(child);
|
||||
@@ -130,6 +150,56 @@ export class IdpAccountContent extends DeesElement {
|
||||
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('/org/:orgName/users', async () => {
|
||||
viewcontainer.classList.add('changing');
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
console.log('We are viewing the users page');
|
||||
await cleanupViews();
|
||||
viewcontainer.append(new views.UsersView());
|
||||
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.registerGarbageFunction(async () => {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
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';
|
||||
|
||||
// Internal form element for reactive state management
|
||||
@customElement('idp-create-org-form')
|
||||
class CreateOrgForm extends DeesElement {
|
||||
@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 resolveWith: ((org: plugins.idpInterfaces.data.IOrganization | null) => void) | null = null;
|
||||
public modal: plugins.deesCatalog.DeesModal | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slug-preview {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--dees-color-background);
|
||||
border: 1px solid var(--dees-color-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.slug-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--dees-color-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.slug-value {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 14px;
|
||||
color: var(--dees-color-text);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--dees-color-muted);
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="description">Create a new organization to manage apps, users, and billing.</div>
|
||||
<dees-input-text
|
||||
.label=${'Organization Name'}
|
||||
.placeholder=${'e.g., Acme Inc.'}
|
||||
.value=${this.orgName}
|
||||
?disabled=${this.creating}
|
||||
></dees-input-text>
|
||||
|
||||
${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>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
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 async firstUpdated() {
|
||||
const inputElement = this.shadowRoot.querySelector('dees-input-text') as any;
|
||||
if (inputElement) {
|
||||
inputElement.changeSubject.subscribe((element: any) => {
|
||||
this.handleNameInput(element.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleNameInput(value: string) {
|
||||
this.orgName = 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;
|
||||
}
|
||||
}
|
||||
|
||||
public canCreate(): boolean {
|
||||
return this.orgName.length > 0 &&
|
||||
this.validationResult?.available === true &&
|
||||
!this.validating &&
|
||||
!this.creating;
|
||||
}
|
||||
|
||||
public async handleCreate(): Promise<void> {
|
||||
if (!this.canCreate()) {
|
||||
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 and role
|
||||
const currentState = accountStateModule.accountState.getState();
|
||||
currentState.organizations.push(result.resultingOrganization);
|
||||
if (result.role) {
|
||||
currentState.roles.push(result.role);
|
||||
}
|
||||
accountStateModule.accountState.dispatchAction(
|
||||
accountStateModule.setSelectedOrg,
|
||||
result.resultingOrganization
|
||||
);
|
||||
|
||||
this.modal?.destroy();
|
||||
this.resolveWith?.(result.resultingOrganization);
|
||||
} catch (error) {
|
||||
console.error('Error creating organization:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to create organization. Please try again.';
|
||||
this.creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
public handleCancel(): void {
|
||||
if (this.validationDebounceTimer) {
|
||||
clearTimeout(this.validationDebounceTimer);
|
||||
}
|
||||
this.modal?.destroy();
|
||||
this.resolveWith?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Export the modal utility class
|
||||
export class CreateOrgModal {
|
||||
public static async show(): Promise<plugins.idpInterfaces.data.IOrganization | null> {
|
||||
return new Promise<plugins.idpInterfaces.data.IOrganization | null>((resolve) => {
|
||||
const formElement = new CreateOrgForm();
|
||||
formElement.resolveWith = resolve;
|
||||
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Create Organization',
|
||||
content: html`${formElement}`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async () => {
|
||||
formElement.handleCancel();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Create Organization',
|
||||
action: async () => {
|
||||
await formElement.handleCreate();
|
||||
},
|
||||
},
|
||||
],
|
||||
width: 480,
|
||||
}).then((modal) => {
|
||||
formElement.modal = modal;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './content.js';
|
||||
export * from './navigation.js';
|
||||
export * from './org-select-modal.js';
|
||||
export * from './create-org-modal.js';
|
||||
|
||||
@@ -6,13 +6,16 @@ import {
|
||||
cssManager,
|
||||
unsafeCSS,
|
||||
css,
|
||||
state,
|
||||
type TemplateResult,
|
||||
subscribe,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as states from '../../states/accountstate.js';
|
||||
import { IdpState } from '../../states/idp.state.js';
|
||||
import { accountDesignTokens } from './sharedstyles.js';
|
||||
import { CreateOrgModal } from './create-org-modal.js';
|
||||
import { OrgSelectModal } from './org-select-modal.js';
|
||||
|
||||
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
|
||||
|
||||
@@ -24,108 +27,163 @@ declare global {
|
||||
|
||||
@customElement('lele-accountnavigation')
|
||||
export class LeleAccountNavigation extends DeesElement {
|
||||
@property()
|
||||
public options: { text: string; id: string }[] = [
|
||||
{
|
||||
id: '1',
|
||||
text: 'Apps',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
text: 'Users',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
text: 'Activity',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
text: 'Billing & Subscription',
|
||||
},
|
||||
];
|
||||
@state()
|
||||
accessor isGlobalAdmin: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor currentPath: string = window.location.pathname;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
private async navigateTo(path: string) {
|
||||
const subrouter = await this.getAccountRouter();
|
||||
subrouter.pushUrl(path);
|
||||
// Update state after navigation to trigger re-render
|
||||
this.currentPath = window.location.pathname;
|
||||
}
|
||||
|
||||
private async navigateToOrgPage(page: string) {
|
||||
const currentState = states.accountState.getState();
|
||||
if (currentState.selectedOrg) {
|
||||
const path = page ? `/org/${currentState.selectedOrg.data.slug}/${page}` : `/org/${currentState.selectedOrg.data.slug}`;
|
||||
await this.navigateTo(path);
|
||||
} else {
|
||||
const targetPath = page ? `/org/:orgName/${page}` : '/org/:orgName';
|
||||
const description = page ? `Choose an organization to view its ${page}.` : 'Choose an organization to view its overview.';
|
||||
const result = await OrgSelectModal.show({
|
||||
targetPath,
|
||||
title: 'Select Organization',
|
||||
description,
|
||||
});
|
||||
if (result) {
|
||||
await this.navigateTo(result.path.replace('/account', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
accountDesignTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
padding: 10px;
|
||||
padding-left: 0px;
|
||||
background: ${cssManager.bdTheme('#eeeeeb', '#000')};
|
||||
border-right: ${cssManager.bdTheme('1px solid #ccc', '1px solid #111')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
border-right: 1px solid var(--border);
|
||||
height: 100%;
|
||||
}
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logoArea {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Cal Sans';
|
||||
letter-spacing: 0.0125em;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
padding: 16px 0px 16px 0px;
|
||||
margin: -8px -8px -16px 0px;
|
||||
border-bottom: 1px solid #111111;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
font-family: 'Cal Sans', 'Geist Sans', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
background: ${unsafeCSS(plugins.deesCatalog.colors.dark.blue)};
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.logo dees-icon {
|
||||
font-size: 24px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.navContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.commitinfo {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
font-family: 'Intel One Mono';
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border-top: ${cssManager.bdTheme('1px solid #ccc', '1px solid #333')};
|
||||
color: ${cssManager.bdTheme('#666', '#ccc')};
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 10px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.6;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.navigationGroupLabel {
|
||||
width: min-content;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
border-bottom: 1px solid;
|
||||
border-image: linear-gradient(to right, orange, #44444400) 1;
|
||||
color: ${cssManager.bdTheme('#666', '#ccc')};
|
||||
margin-bottom: 5px;
|
||||
padding-top: 32px;
|
||||
padding-left: 10px;
|
||||
padding-bottom: 5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted-foreground);
|
||||
padding: 20px 16px 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.navigationGroupLabel:first-of-type {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.navigationOption {
|
||||
border-top-right-radius: 30px;
|
||||
border-bottom-right-radius: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px;
|
||||
padding-left: 10px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--muted-foreground);
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navigationOption:hover {
|
||||
cursor: default;
|
||||
background: ${cssManager.bdTheme('#bbb', plugins.deesCatalog.colors.dark.blue)};
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.navigationOption dees-icon {
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navigationOption:hover dees-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navigationOption.active {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.navigationOption.active dees-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 8px 16px;
|
||||
}
|
||||
|
||||
dees-input-dropdown {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
margin-left: 8px;
|
||||
margin: 8px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -137,18 +195,30 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<style></style>
|
||||
<div class="commitinfo">idp.global v${commitinfo.version}</div>
|
||||
<div class="logo">idp.global</div>
|
||||
<div class="navigationGroupLabel">Account Settings</div>
|
||||
<div class="logoArea">
|
||||
<div class="logo">
|
||||
<dees-icon .icon=${'lucide:fingerprint'}></dees-icon>
|
||||
idp.global
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navContent">
|
||||
<div class="navigationGroupLabel">Account</div>
|
||||
<div
|
||||
class="navigationOption ${this.isActive('') ? 'active' : ''}"
|
||||
@click=${() => this.navigateTo('')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
||||
Overview
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption"
|
||||
@click=${async () => {
|
||||
const subrouter = await this.getAccountRouter();
|
||||
subrouter.pushUrl('');
|
||||
|
||||
}}
|
||||
>
|
||||
overview
|
||||
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
||||
Manage Roles
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption"
|
||||
@@ -157,41 +227,134 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
idpState.domtools.router.pushUrl('/logout');
|
||||
}}
|
||||
>
|
||||
logout
|
||||
<dees-icon .icon=${'lucide:power'}></dees-icon>
|
||||
Log Out
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption"
|
||||
@click=${async () => {
|
||||
|
||||
}}
|
||||
>
|
||||
manage roles
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption"
|
||||
@click=${async () => {
|
||||
}}
|
||||
>
|
||||
create an org
|
||||
</div>
|
||||
<div class="navigationGroupLabel">Organization Settings</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="navigationGroupLabel">Organization</div>
|
||||
<dees-input-dropdown
|
||||
.label=${'choose org:'}
|
||||
@selectedOption=${(eventArg: CustomEvent) => {
|
||||
.label=${'Select organization'}
|
||||
@selectedOption=${async (eventArg: CustomEvent) => {
|
||||
// Handle "Create new..." option
|
||||
if (eventArg.detail.key === '__create_new__') {
|
||||
const org = await CreateOrgModal.show();
|
||||
if (org) {
|
||||
await this.navigateTo(`/org/${org.data.slug}/billing`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentState = states.accountState.getState();
|
||||
states.accountState.dispatchAction(
|
||||
states.setSelectedOrg,
|
||||
currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload)
|
||||
);
|
||||
const newOrg = currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload);
|
||||
states.accountState.dispatchAction(states.setSelectedOrg, newOrg);
|
||||
|
||||
// Auto-navigate to new org's current page type (reactivity)
|
||||
const currentPath = window.location.pathname;
|
||||
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) {
|
||||
await this.navigateTo(`/org/${newOrg.data.slug}/${pageType}`);
|
||||
} else {
|
||||
await this.navigateTo(`/org/${newOrg.data.slug}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
${this.options.map((option) => {
|
||||
return html` <div class="navigationOption">${option.text}</div> `;
|
||||
})}
|
||||
|
||||
<div
|
||||
class="navigationOption ${this.isActive('org-overview') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
||||
Overview
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption ${this.isActive('apps') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('apps')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:box'}></dees-icon>
|
||||
Apps
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption ${this.isActive('users') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('users')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:users'}></dees-icon>
|
||||
Users
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption"
|
||||
@click=${async () => {}}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:activity'}></dees-icon>
|
||||
Activity
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption ${this.isActive('billing') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('billing')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
|
||||
Billing
|
||||
</div>
|
||||
|
||||
${this.renderAdminLink()}
|
||||
</div>
|
||||
|
||||
<div class="commitinfo">v${commitinfo.version}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public firstUpdated() {
|
||||
private renderAdminLink(): TemplateResult | null {
|
||||
if (!this.isGlobalAdmin) {
|
||||
return null;
|
||||
}
|
||||
return html`
|
||||
<div class="divider"></div>
|
||||
<div class="navigationGroupLabel">Platform</div>
|
||||
<div
|
||||
class="navigationOption ${this.isActive('admin') ? 'active' : ''}"
|
||||
@click=${() => this.navigateTo('/admin')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
||||
Global Admin
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private isActive(page: string): boolean {
|
||||
const path = this.currentPath;
|
||||
if (page === '') {
|
||||
// Account overview - exact match
|
||||
return path === '/account' || path === '/account/';
|
||||
}
|
||||
if (page === 'org-overview') {
|
||||
// Org overview - /account/org/:slug without trailing page type
|
||||
return /^\/account\/org\/[^\/]+\/?$/.test(path);
|
||||
}
|
||||
// For other pages, check if the path contains the page segment
|
||||
return path.includes(`/${page}`);
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// Listen for popstate (browser back/forward)
|
||||
window.addEventListener('popstate', () => {
|
||||
this.currentPath = window.location.pathname;
|
||||
});
|
||||
|
||||
// Watch for URL changes from external navigation (e.g., modals)
|
||||
let lastPath = this.currentPath;
|
||||
const checkPath = () => {
|
||||
if (window.location.pathname !== lastPath) {
|
||||
lastPath = window.location.pathname;
|
||||
this.currentPath = lastPath;
|
||||
}
|
||||
requestAnimationFrame(checkPath);
|
||||
};
|
||||
requestAnimationFrame(checkPath);
|
||||
|
||||
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
|
||||
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
|
||||
if (!orgArg) {
|
||||
@@ -203,11 +366,21 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
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
|
||||
.select((stateArg) => stateArg.organizations)
|
||||
.pipe(
|
||||
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) => {
|
||||
@@ -219,5 +392,12 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
.subscribe((selectedOrgArg) => {
|
||||
deesInputDropdown.selectedOption = selectedOrgArg;
|
||||
});
|
||||
|
||||
// Check if user is global admin
|
||||
states.accountState
|
||||
.select((stateArg) => stateArg.user)
|
||||
.subscribe((user) => {
|
||||
this.isGlobalAdmin = user?.data?.isGlobalAdmin ?? false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { accountDesignTokens } from './sharedstyles.js';
|
||||
import * as accountStateModule from '../../states/accountstate.js';
|
||||
|
||||
export interface IOrgSelectResult {
|
||||
org: plugins.idpInterfaces.data.IOrganization;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const modalStyles = css`
|
||||
.org-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.org-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--dees-color-line);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.org-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.org-item:hover {
|
||||
background: var(--dees-color-softBackground);
|
||||
}
|
||||
|
||||
.org-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--dees-color-softBackground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-item:hover .org-icon {
|
||||
background: var(--dees-color-line);
|
||||
}
|
||||
|
||||
.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: var(--dees-color-text);
|
||||
}
|
||||
|
||||
.org-slug {
|
||||
font-size: 12px;
|
||||
color: var(--dees-color-muted);
|
||||
}
|
||||
|
||||
.org-arrow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
color: var(--dees-color-muted);
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--dees-color-muted);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
export class OrgSelectModal {
|
||||
public static async show(options: {
|
||||
targetPath: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}): Promise<IOrgSelectResult | null> {
|
||||
const title = options.title || 'Select Organization';
|
||||
const description = options.description || 'Choose an organization to continue.';
|
||||
|
||||
// Load organizations from state
|
||||
const state = accountStateModule.accountState.getState();
|
||||
const organizations = state.organizations;
|
||||
|
||||
return new Promise<IOrgSelectResult | null>((resolve) => {
|
||||
let modal: plugins.deesCatalog.DeesModal | null = null;
|
||||
let resolved = false;
|
||||
|
||||
const handleSelectOrg = (org: plugins.idpInterfaces.data.IOrganization) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
|
||||
const path = options.targetPath.replace(':orgName', org.data.slug);
|
||||
|
||||
modal?.destroy();
|
||||
resolve({ org, path });
|
||||
};
|
||||
|
||||
const handleCreateOrg = async () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
modal?.destroy();
|
||||
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { CreateOrgModal } = await import('./create-org-modal.js');
|
||||
const createdOrg = await CreateOrgModal.show();
|
||||
|
||||
if (createdOrg) {
|
||||
const path = options.targetPath.replace(':orgName', createdOrg.data.slug);
|
||||
resolve({ org: createdOrg, path });
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderOrgList = (): TemplateResult => {
|
||||
return html`
|
||||
<style>${modalStyles}</style>
|
||||
<div class="description">${description}</div>
|
||||
<div class="org-list">
|
||||
${organizations.map((org) => html`
|
||||
<div class="org-item" @click=${() => 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>
|
||||
`;
|
||||
};
|
||||
|
||||
const renderEmptyState = (): TemplateResult => {
|
||||
return html`
|
||||
<style>${modalStyles}</style>
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||
<p>You don't have any organizations yet.</p>
|
||||
<dees-button @clicked=${handleCreateOrg}>
|
||||
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
|
||||
Create Organization
|
||||
</dees-button>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const content = organizations.length === 0 ? renderEmptyState() : renderOrgList();
|
||||
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: title,
|
||||
content,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modalRef) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(null);
|
||||
}
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
width: 420,
|
||||
}).then((m) => {
|
||||
modal = m;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,117 @@
|
||||
import { css } from '@design.estate/dees-element';
|
||||
|
||||
export default css`
|
||||
/**
|
||||
* Design tokens matching the login page aesthetic (idp-centercontainer.ts)
|
||||
*/
|
||||
export const accountDesignTokens = css`
|
||||
:host {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--card: hsl(240 6% 6%);
|
||||
|
||||
font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: var(--foreground);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Card container styles
|
||||
*/
|
||||
export const cardStyles = css`
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Typography styles for consistent text hierarchy
|
||||
*/
|
||||
export const typographyStyles = css`
|
||||
h1 {
|
||||
margin-top: 50px;
|
||||
border-bottom: 1px solid;
|
||||
border-image: radial-gradient(rgba(136, 136, 136, 0.44), rgba(136, 136, 136, 0)) 1 / 1 / 0 stretch;
|
||||
padding-bottom: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-top: 1px dotted #666;
|
||||
padding-top: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
margin: 24px 0 8px 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5em;
|
||||
font-size: 14px;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
dees-button {
|
||||
margin-top: 16px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
dees-input-text {
|
||||
max-width: 400px;
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Navigation styles for the sidebar
|
||||
*/
|
||||
export const navigationStyles = css`
|
||||
.nav-item {
|
||||
padding: 10px 16px;
|
||||
margin: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted-foreground);
|
||||
padding: 24px 16px 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Legacy export for backwards compatibility
|
||||
*/
|
||||
export default css`
|
||||
${accountDesignTokens}
|
||||
${typographyStyles}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
||||
'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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
||||
'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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||
'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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
||||
'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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
||||
'getGlobalApps'
|
||||
);
|
||||
|
||||
const appsResponse = await typedRequest.fire({
|
||||
jwt,
|
||||
});
|
||||
|
||||
// Fetch connections for this organization
|
||||
const connectionsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
||||
'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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
|
||||
'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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
property,
|
||||
html,
|
||||
cssManager,
|
||||
unsafeCSS,
|
||||
css,
|
||||
render,
|
||||
subscribe,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import sharedStyles from '../sharedstyles.js';
|
||||
import { accountDesignTokens } from '../sharedstyles.js';
|
||||
import * as accountStateModule from '../../../states/accountstate.js';
|
||||
import { IdpState } from '../../../states/idp.state.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -19,175 +20,822 @@ 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')
|
||||
export class BaseView extends DeesElement {
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
subscriptions: any[] = [
|
||||
{
|
||||
organization: 'org1',
|
||||
'subscription type': 'workspace.global SaaS',
|
||||
price: '4€',
|
||||
userFactor: 4,
|
||||
total: '16.00€',
|
||||
},
|
||||
{
|
||||
organization: 'org1',
|
||||
'subscription type': 'workspace.global IaaS Base Access',
|
||||
price: '0€',
|
||||
userFactor: 4,
|
||||
total: '0€',
|
||||
},
|
||||
{
|
||||
organization: 'org1',
|
||||
'subscription type': 'workspace.global SLA Senior',
|
||||
price: '2000€',
|
||||
userFactor: 'none',
|
||||
total: '2000.00€',
|
||||
},
|
||||
];
|
||||
@state()
|
||||
accessor loading: boolean = true;
|
||||
|
||||
@state()
|
||||
accessor sessions: ISessionDisplay[] = [];
|
||||
|
||||
@state()
|
||||
accessor activities: IActivityDisplay[] = [];
|
||||
|
||||
@state()
|
||||
accessor user: plugins.idpInterfaces.data.IUser | null = null;
|
||||
|
||||
@state()
|
||||
accessor organizations: plugins.idpInterfaces.data.IOrganization[] = [];
|
||||
|
||||
@state()
|
||||
accessor roles: plugins.idpInterfaces.data.IRole[] = [];
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
sharedStyles,
|
||||
accountDesignTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 900px;
|
||||
margin: auto;
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
}
|
||||
.slug {
|
||||
color: orange;
|
||||
min-height: 100%;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.orgGrid {
|
||||
.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;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #71717a;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: ${cssManager.cssGridColumns(2, 16)};
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.org {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #444;
|
||||
background: ${cssManager.bdTheme('#ccc', '#222')};
|
||||
border-radius: 16px;
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.org:hover {
|
||||
cursor: default;
|
||||
background: ${cssManager.bdTheme('#CCC', '#333')};
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.org-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
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-icon dees-icon {
|
||||
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;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-out;
|
||||
opacity: 1;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.session-item.removing {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.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`
|
||||
<div class="viewHost">
|
||||
|
||||
</div> `;
|
||||
<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`
|
||||
<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 corresponds to the organization name:<br />
|
||||
<span class="slug"
|
||||
>${subscribe(
|
||||
state.accountState.select((stateArg) => stateArg.newOrg.chosenSlug)
|
||||
)}</span
|
||||
>
|
||||
</p>
|
||||
<span class="hint"></span>
|
||||
<dees-button .disabled=${true}>Create the Organization</dees-button>
|
||||
`,
|
||||
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 userInitial = this.user?.data?.username?.charAt(0).toUpperCase() ||
|
||||
this.user?.data?.email?.charAt(0).toUpperCase() || '?';
|
||||
|
||||
const formSubscription = form.changeSubject.subscribe(async (dataArg: any) => {
|
||||
await state.accountState.dispatchAction(state.setNewOrgName, dataArg.orgName);
|
||||
});
|
||||
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>
|
||||
<div class="orgGrid">
|
||||
${state.accountState.getState().organizations.map((orgArg) => {
|
||||
return html`
|
||||
<div
|
||||
class="org"
|
||||
@click=${() => {
|
||||
state.accountState.dispatchAction(state.setSelectedOrg, orgArg);
|
||||
const parentElement = (this.getRootNode() as any).host;
|
||||
parentElement.subrouter.pushUrl(`/org/${orgArg.data.slug}/billing`);
|
||||
}}
|
||||
>
|
||||
<dees-icon .iconFA=${"wallet"} style="display: inline-block; transform: translateY(3px); padding-right: 4px;"></dees-icon> ${orgArg.data.name}
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Account Overview</h1>
|
||||
<p class="subtitle">Manage your profile, organizations, and security settings</p>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Profile Card -->
|
||||
<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.roles?.[0] || '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>
|
||||
`,
|
||||
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" data-session-id=${session.id}>
|
||||
<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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||
'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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
|
||||
'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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||
'revokeSession'
|
||||
);
|
||||
|
||||
await typedRequest.fire({ jwt, sessionId });
|
||||
|
||||
// Animate the session item collapse before removing from DOM
|
||||
const sessionElement = this.shadowRoot?.querySelector(`[data-session-id="${sessionId}"]`) as HTMLElement;
|
||||
if (sessionElement) {
|
||||
sessionElement.classList.add('removing');
|
||||
await new Promise(resolve => setTimeout(resolve, 300)); // Wait for animation
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export * from './adminview.js';
|
||||
export * from './appsview.js';
|
||||
export * from './baseview.js';
|
||||
export * from './orgsetup.js';
|
||||
export * from './orgview.js';
|
||||
export * from './paddlesetup.js';
|
||||
export * from './subscriptions.js';
|
||||
export * from './usersview.js';
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
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.roles?.[0] || '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 = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
||||
'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');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import sharedStyles from '../sharedstyles.js';
|
||||
import * as state from '../../../states/accountstate.js';
|
||||
import { IdpState } from '../../../states/idp.state.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -54,41 +55,57 @@ export class PaddleSetupView extends DeesElement {
|
||||
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.
|
||||
</p>
|
||||
<dees-button>Let's do it!</dees-button>
|
||||
<dees-button @clicked=${() => this.openPaddle()}>Let's do it!</dees-button>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public async firstUpdated() {
|
||||
public async openPaddle() {
|
||||
await this.domtoolsPromise;
|
||||
const paddleButton = this.shadowRoot.querySelector('dees-button');
|
||||
const openPaddle = async () => {
|
||||
await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/paddle.js');
|
||||
globalThis.Paddle.Setup({
|
||||
vendor: 30954,
|
||||
eventCallback: async (dataArg) => {
|
||||
// The data.event will specify the event type
|
||||
if (dataArg.event === 'Checkout.Complete') {
|
||||
const data: plugins.idpInterfaces.data.IPaddleCheckoutData = dataArg.eventData;
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
|
||||
// Get user email - first try from state, then fetch directly
|
||||
let userEmail = state.accountState.getState().user?.data?.email;
|
||||
|
||||
if (!userEmail) {
|
||||
// State not loaded, fetch user directly
|
||||
const whoIsResponse = await idpState.idpClient.whoIs().catch(() => null);
|
||||
userEmail = whoIsResponse?.user?.data?.email;
|
||||
}
|
||||
|
||||
if (!userEmail) {
|
||||
console.error('Unable to get user email for Paddle checkout');
|
||||
paddleButton.status = 'error';
|
||||
paddleButton.text = 'Error: Not logged in';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch Paddle config from backend
|
||||
const configRequest = idpState.idpClient.typedsocket
|
||||
.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPaddleConfig>('getPaddleConfig');
|
||||
const { paddleToken, paddlePriceId } = await configRequest.fire({});
|
||||
|
||||
await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/v2/paddle.js');
|
||||
globalThis.Paddle.Initialize({
|
||||
token: paddleToken,
|
||||
eventCallback: async (dataArg: any) => {
|
||||
// Paddle Billing v2 event handling
|
||||
if (dataArg.name === 'checkout.completed') {
|
||||
const paddleIframe = document.body.querySelector('iframe');
|
||||
if (paddleIframe) {
|
||||
document.body.removeChild(paddleIframe);
|
||||
}
|
||||
paddleButton.status = 'pending';
|
||||
paddleButton.text = 'Processing...';
|
||||
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, data.checkout.id);
|
||||
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, dataArg.data.transaction_id);
|
||||
paddleButton.status = 'success';
|
||||
paddleButton.text = 'Paddle connected!'
|
||||
}
|
||||
},
|
||||
});
|
||||
globalThis.Paddle.Checkout.open({
|
||||
product: 561076,
|
||||
email: 'phil@kunz.io',
|
||||
});
|
||||
};
|
||||
paddleButton.addEventListener('clicked', async () => {
|
||||
openPaddle();
|
||||
items: [{ priceId: paddlePriceId, quantity: 1 }],
|
||||
customer: { email: userEmail },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import sharedStyles from '../sharedstyles.js';
|
||||
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
|
||||
|
||||
import * as state from '../../../states/accountstate.js';
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SubscriptionView extends DeesElement {
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
subscriptions: any[] = [{
|
||||
accessor subscriptions: any[] = [{
|
||||
organization: 'org1',
|
||||
'subscription type': 'workspace.global SaaS',
|
||||
price: '4€',
|
||||
@@ -46,48 +46,110 @@ export class SubscriptionView extends DeesElement {
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
sharedStyles,
|
||||
accountDesignTokens,
|
||||
cardStyles,
|
||||
typographyStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 48px;
|
||||
max-width: 900px;
|
||||
margin: auto;
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
margin: 24px 0 8px 0;
|
||||
}
|
||||
|
||||
dees-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
dees-button {
|
||||
margin-top: 16px;
|
||||
}
|
||||
`
|
||||
]
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<h1>-> Billing & Subscription</h1>
|
||||
This page allows you to setup how you are billed for any workspace.global charges.
|
||||
<h2>PaymentMethod</h2>
|
||||
<h1>Billing & Subscription</h1>
|
||||
<p>Manage your billing settings and subscriptions for your organization.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>Payment Method</h2>
|
||||
<div class="card">
|
||||
<p>Our customer-side billing is handled by paddle.com. You subscribe to a free plan there,
|
||||
and we will bill any occurring charges as an extra on the monthly date of your choosing.
|
||||
Paddle.com will take care of proper VAT invoices that will allow for VAT reduction according to the law.</p>
|
||||
|
||||
<h3>Paddle</h3>
|
||||
<dees-button @click=${async () => {
|
||||
await this.domtoolsPromise;
|
||||
this.domtools.router.pushUrl(`/org/${state.accountState.getState().selectedOrg.data.slug}/paddlesetup`)
|
||||
}}>set up paddle.com</dees-button>
|
||||
<h3>Enterprise billing</h3>
|
||||
Once you have 100 or more Pro Plan users, you can request custom Enterprise billing for your organization here. Note: You are currently not eligible.
|
||||
// Extract org slug from current URL: /account/org/{orgSlug}/billing
|
||||
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>
|
||||
|
||||
<h3>Enterprise Billing</h3>
|
||||
<p>Once you have 100 or more Pro Plan users, you can request custom Enterprise billing for your organization here.</p>
|
||||
<p><em>Note: You are currently not eligible.</em></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Subscriptions</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
The total price of a subscription already includes all taxes. If you are a VAT registered business,
|
||||
the actual price might be cheaper in case you can claim VAT exemption from the purchase.
|
||||
</p>
|
||||
<p>
|
||||
Note: Subscriptions are tied to prganizations. You are only seeing subcriptions regarding ${'org1'} right now.
|
||||
To see other organization, select the respective organization at the top left of this page.
|
||||
<em>Note: Subscriptions are tied to organizations. Select the respective organization from the sidebar to view its subscriptions.</em>
|
||||
</p>
|
||||
<dees-table .heading1=${'Subscriptions'} .heading2=${`for organization ${'org1'}`} .data=${this.subscriptions}></dees-table>
|
||||
<dees-button>Add subscription</dees-button>
|
||||
<dees-table .heading1=${'Subscriptions'} .heading2=${`for organization`} .data=${this.subscriptions}></dees-table>
|
||||
<dees-button>Add Subscription</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Accrued IaaS Usage</h2>
|
||||
<p>Note: The accrued IaaS Usage will be charged by adjusting the workspsace.gobal IaaS Postpaid Access price prior the renewal date.</p>
|
||||
<dees-table .heading1=${'Subscriptions'} .heading2=${`for organization ${'org1'}`} .data=${this.subscriptions}></dees-table>
|
||||
<div class="card">
|
||||
<p>The accrued IaaS Usage will be charged by adjusting the workspace.global IaaS Postpaid Access price prior to the renewal date.</p>
|
||||
<dees-table .heading1=${'Usage'} .heading2=${`for organization`} .data=${this.subscriptions}></dees-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Upcoming Billable Items</h2>
|
||||
<div class="card">
|
||||
<p>No upcoming billable items.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Past Invoices</h2>
|
||||
<div class="card">
|
||||
<p>No past invoices available.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user