Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53b36e506c | |||
| 7d5ad29a27 | |||
| 724ec2d134 | |||
| 32ffc1bbaa | |||
| a91dd9dda6 | |||
| 5462257398 | |||
| 2ad751ecba | |||
| a24b0d8be7 | |||
| 02c700e44d | |||
| e9f1b5dac9 | |||
| 6645806a87 | |||
| dc3f232f43 | |||
| cc9d56ff4b | |||
| 47ca5934a6 | |||
| dddd968796 | |||
| 2cdf86744e | |||
| 9d9f90c1d5 | |||
| 833cf3b4b8 | |||
| 8df44b99b9 | |||
| d32103618f | |||
| a83858beb0 | |||
| 5f29edf449 | |||
| 173735a84e | |||
| 8756258324 | |||
| d11f5a0c72 |
@@ -1,5 +1,97 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-01-29 - 1.15.0 - feat(build)
|
||||||
|
add tsbundle/tswatch configs, update build/watch scripts, bump dependencies, and add CLI documentation
|
||||||
|
|
||||||
|
- Add tsbundle and tswatch configuration to npmextra.json to support bundling and a local dev server (dist_serve, liveReload, watch patterns).
|
||||||
|
- Update package.json build/watch scripts to use generic tsbundle/tswatch invocations (removed explicit 'website' target).
|
||||||
|
- Bump dependencies and devDependencies: @git.zone/tsbuild ^4.0.2 -> ^4.1.2, @git.zone/tsbundle ^2.6.3 -> ^2.8.3, @git.zone/tswatch ^2.3.13 -> ^3.0.1, @api.global/typedserver ^8.1.0 -> ^8.3.0, several @design.estate packages, @push.rocks/taskbuffer ^3.5.0 -> ^4.1.1, @types/node 25.0.3 -> 25.1.0, and other minor/patch bumps.
|
||||||
|
- Add a new CLI README (ts_idpcli/readme.md) with usage, commands, programmatic API examples and configuration.
|
||||||
|
- Update README license/Legal sections in ts_idpclient, ts_interfaces and ts_web to include license, trademark, and company information.
|
||||||
|
|
||||||
|
## 2025-12-22 - 1.14.1 - fix(oidc)
|
||||||
|
migrate OIDC endpoints and internal handlers to use typedserver IRequestContext and update dependencies
|
||||||
|
|
||||||
|
- Updated route handlers in ts/index.ts to pass ctx (IRequestContext) instead of req
|
||||||
|
- Refactored OIDC manager handlers to accept plugins.typedserver.IRequestContext and use ctx.url, ctx.headers, ctx.formData (handleAuthorize, handleToken, handleUserInfo, handleRevoke)
|
||||||
|
- Bumped dependencies to support the new typedserver API: @api.global/typedserver -> ^8.1.0
|
||||||
|
- Other dependency updates: @design.estate/dees-catalog ^3.4.0, @git.zone/tspublish ^1.11.0, @types/node ^25.0.3
|
||||||
|
- Changing public handler method signatures is a breaking API change; recommend a major version bump
|
||||||
|
|
||||||
|
## 2025-12-16 - 1.14.0 - feat(docs)
|
||||||
|
add package READMEs and publish metadata; update web package publish order
|
||||||
|
|
||||||
|
- Add comprehensive README for ts_web (web components/UI)
|
||||||
|
- Add README for ts_idpclient (TypeScript client)
|
||||||
|
- Add README for ts_interfaces (type definitions/interfaces)
|
||||||
|
- Add tspublish.json for ts_idpcli (@idp.global/cli) and ts_idpclient (@idp.global/client)
|
||||||
|
- Update ts_web/tspublish.json order from 4 to 5
|
||||||
|
|
||||||
|
## 2025-12-15 - 1.13.0 - feat(oidc)
|
||||||
|
feat(oidc): add OIDC provider (OidcManager, endpoints, and interfaces)
|
||||||
|
|
||||||
|
- Add OidcManager class implementing OpenID Connect / OAuth2 server functionality (authorization codes, access/refresh tokens, user consents, PKCE support, JWKS, ID token generation, token revocation, cleanup task).
|
||||||
|
- Expose OIDC endpoints on the website server: /.well-known/openid-configuration, /.well-known/jwks.json, /oauth/authorize, /oauth/token, /oauth/userinfo (GET/POST), and /oauth/revoke.
|
||||||
|
- Integrate OidcManager into Reception: add oidcManager property and instantiate it from ts/index.ts so routes can reference it.
|
||||||
|
- Add TypeScript interfaces for OIDC data structures (ts_interfaces/data/loint-reception.oidc.ts) and export them from the data index.
|
||||||
|
|
||||||
|
## 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)
|
## 2025-12-01 - 1.7.0 - feat(admin)
|
||||||
Add global admin functionality: backend admin APIs, model fields and UI integration
|
Add global admin functionality: backend admin APIs, model fields and UI integration
|
||||||
|
|
||||||
|
|||||||
+56
-16
@@ -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",
|
"projectType": "website",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
@@ -32,22 +45,49 @@
|
|||||||
"user sessions"
|
"user sessions"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"services": [
|
"services": ["mongodb", "minio"],
|
||||||
"mongodb",
|
"release": {
|
||||||
"minio"
|
"registries": ["https://verdaccio.lossless.digital"],
|
||||||
|
"accessLevel": "public"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./dist_serve/bundle.js",
|
||||||
|
"outputMode": "bundle",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"npmci": {
|
"@git.zone/tswatch": {
|
||||||
"npmGlobalTools": [],
|
"preset": "website",
|
||||||
"dockerRegistryRepoMap": {
|
"server": {
|
||||||
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
|
"enabled": true,
|
||||||
|
"port": 3000,
|
||||||
|
"serveDir": "./dist_serve/",
|
||||||
|
"liveReload": true
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
"watchers": [
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
{
|
||||||
},
|
"name": "backend",
|
||||||
"npmRegistryUrl": "registry.npmjs.org"
|
"watch": "./ts/**/*",
|
||||||
},
|
"command": "npm run startTs",
|
||||||
"tsdoc": {
|
"restart": true,
|
||||||
"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"
|
"debounce": 300,
|
||||||
|
"runOnStart": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"name": "website",
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./dist_serve/bundle.js",
|
||||||
|
"watchPatterns": ["./ts_web/**/*"],
|
||||||
|
"triggerReload": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-18
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@idp.global/idp.global",
|
"name": "@idp.global/idp.global",
|
||||||
"version": "1.7.0",
|
"version": "1.15.0",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run build",
|
"test": "npm run build",
|
||||||
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle website --production",
|
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle",
|
||||||
"watch": "tswatch website",
|
"watch": "tswatch",
|
||||||
"start": "(node cli.js)",
|
"start": "(node cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
@@ -16,20 +16,24 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.1.10",
|
"@api.global/typedrequest": "^3.2.5",
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedserver": "^3.0.80",
|
"@api.global/typedserver": "^8.3.0",
|
||||||
"@api.global/typedsocket": "^3.0.1",
|
"@api.global/typedsocket": "^4.1.0",
|
||||||
"@consent.software/catalog": "^2.0.1",
|
"@consent.software/catalog": "^2.0.1",
|
||||||
"@design.estate/dees-catalog": "^2.0.2",
|
"@design.estate/dees-catalog": "^3.41.4",
|
||||||
"@design.estate/dees-domtools": "^2.3.6",
|
"@design.estate/dees-domtools": "^2.3.8",
|
||||||
"@design.estate/dees-element": "^2.1.3",
|
"@design.estate/dees-element": "^2.1.6",
|
||||||
|
"@git.zone/tspublish": "^1.11.0",
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartdata": "^7.0.14",
|
"@push.rocks/smartcli": "^4.0.20",
|
||||||
|
"@push.rocks/smartdata": "^7.0.15",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
|
"@push.rocks/smartfile": "^13.1.0",
|
||||||
"@push.rocks/smarthash": "^3.2.6",
|
"@push.rocks/smarthash": "^3.2.6",
|
||||||
"@push.rocks/smartjson": "^5.2.0",
|
"@push.rocks/smartinteract": "^2.0.6",
|
||||||
|
"@push.rocks/smartjson": "^6.0.0",
|
||||||
"@push.rocks/smartjwt": "^2.2.1",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartlog": "^3.1.10",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
"@push.rocks/smartmail": "^2.2.0",
|
"@push.rocks/smartmail": "^2.2.0",
|
||||||
@@ -40,21 +44,21 @@
|
|||||||
"@push.rocks/smarttime": "^4.1.1",
|
"@push.rocks/smarttime": "^4.1.1",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smarturl": "^3.1.0",
|
"@push.rocks/smarturl": "^3.1.0",
|
||||||
"@push.rocks/taskbuffer": "^3.4.0",
|
"@push.rocks/taskbuffer": "^4.1.1",
|
||||||
"@push.rocks/webjwt": "^1.0.9",
|
"@push.rocks/webjwt": "^1.0.9",
|
||||||
"@push.rocks/websetup": "^3.0.15",
|
"@push.rocks/websetup": "^3.0.15",
|
||||||
"@push.rocks/webstore": "^2.0.20",
|
"@push.rocks/webstore": "^2.0.20",
|
||||||
"@serve.zone/platformclient": "^1.1.2",
|
"@serve.zone/platformclient": "^1.1.2",
|
||||||
"@tsclass/tsclass": "^9.3.0",
|
"@tsclass/tsclass": "^9.3.0",
|
||||||
"@uptime.link/webwidget": "^1.2.4"
|
"@uptime.link/webwidget": "^1.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^3.1.2",
|
"@git.zone/tsbuild": "^4.1.2",
|
||||||
"@git.zone/tsbundle": "^2.6.2",
|
"@git.zone/tsbundle": "^2.8.3",
|
||||||
"@git.zone/tsrun": "^2.0.0",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tswatch": "^2.2.1",
|
"@git.zone/tswatch": "^3.0.1",
|
||||||
"@push.rocks/projectinfo": "^5.0.1",
|
"@push.rocks/projectinfo": "^5.0.1",
|
||||||
"@types/node": "^24.10.1"
|
"@types/node": "^25.1.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Generated
+1649
-1396
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -1,3 +1,23 @@
|
|||||||
# Project Readme Hints
|
# 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)
|
||||||
|
|||||||
@@ -1,312 +1,328 @@
|
|||||||
# @idp.global/idp.global
|
# @idp.global/idp.global
|
||||||
|
|
||||||
An identity provider software managing user authentications, registrations, and sessions.
|
🔐 **A modern, open-source Identity Provider (IdP) SaaS platform** for managing user authentication, registrations, sessions, and organization-based access control.
|
||||||
|
|
||||||
## Install
|
Built with TypeScript and designed for modern web applications, idp.global provides a complete identity management solution that you can self-host or use as a service.
|
||||||
|
|
||||||
To install `@idp.global/idp.global`, you can run the following command in your terminal:
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
### 🔑 Authentication & Authorization
|
||||||
|
- **Multiple Login Methods**: Email/password, email magic links, API tokens
|
||||||
|
- **JWT-Based Sessions**: Secure token management with automatic refresh
|
||||||
|
- **Two-Factor Authentication**: Enhanced security with 2FA support
|
||||||
|
- **Password Reset**: Secure password recovery flow
|
||||||
|
- **Device Management**: Track and manage authenticated devices
|
||||||
|
|
||||||
|
### 🏢 Organization Management
|
||||||
|
- **Multi-Tenant Architecture**: Support multiple organizations per user
|
||||||
|
- **Role-Based Access Control (RBAC)**: Fine-grained permissions system
|
||||||
|
- **Organization Roles**: Admin, member, and custom role support
|
||||||
|
- **Member Invitations**: Bulk invite and manage team members
|
||||||
|
- **Ownership Transfer**: Seamlessly transfer organization ownership
|
||||||
|
|
||||||
|
### 🔗 Third-Party Integration
|
||||||
|
- **OpenID Connect (OIDC) Provider**: Full OIDC compliance for third-party apps
|
||||||
|
- Discovery endpoint (`/.well-known/openid-configuration`)
|
||||||
|
- JWKS endpoint for token verification
|
||||||
|
- Authorization code flow with PKCE
|
||||||
|
- Token refresh and revocation
|
||||||
|
- **OAuth 2.0**: Standard OAuth flows for app authorization
|
||||||
|
- **Supported Scopes**: `openid`, `profile`, `email`, `organizations`, `roles`
|
||||||
|
|
||||||
|
### 💳 Billing Integration
|
||||||
|
- **Paddle Integration**: Built-in payment processing support
|
||||||
|
- **Billing Plans**: Flexible subscription management
|
||||||
|
- **Checkout Flows**: Streamlined payment experiences
|
||||||
|
|
||||||
|
### 🎨 Modern Web UI
|
||||||
|
- **Responsive Design**: Beautiful UI components built with `@design.estate/dees-catalog`
|
||||||
|
- **Account Management**: User profile, settings, and preferences
|
||||||
|
- **Organization Dashboard**: Manage members, roles, and apps
|
||||||
|
- **Admin Panel**: Global administration interface
|
||||||
|
|
||||||
|
### 📡 Real-Time Communication
|
||||||
|
- **WebSocket Support**: Real-time updates via TypedSocket
|
||||||
|
- **Typed API Requests**: Type-safe client-server communication
|
||||||
|
- **Public Key Distribution**: Automatic JWT key rotation notifications
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
idp.global is built as a modular TypeScript monorepo:
|
||||||
|
|
||||||
|
```
|
||||||
|
├── ts/ # Server-side code (Node.js)
|
||||||
|
│ └── reception/ # Core identity management logic
|
||||||
|
├── ts_interfaces/ # Shared TypeScript interfaces (published as @idp.global/interfaces)
|
||||||
|
├── ts_idpclient/ # Browser/Node client library (published as @idp.global/idpclient)
|
||||||
|
├── ts_idpcli/ # Command-line interface tool
|
||||||
|
└── ts_web/ # Web frontend (published as @idp.global/web)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Managers
|
||||||
|
|
||||||
|
| Manager | Responsibility |
|
||||||
|
|---------|----------------|
|
||||||
|
| `JwtManager` | JWT generation, validation, and key management |
|
||||||
|
| `LoginSessionManager` | Session creation and authentication |
|
||||||
|
| `UserManager` | User CRUD and profile management |
|
||||||
|
| `OrganizationManager` | Organization lifecycle management |
|
||||||
|
| `RoleManager` | RBAC and permission management |
|
||||||
|
| `OidcManager` | OpenID Connect provider functionality |
|
||||||
|
| `AppManager` | OAuth client app registration |
|
||||||
|
| `BillingPlanManager` | Subscription and payment handling |
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### 🐳 Docker Deployment (Recommended)
|
||||||
|
|
||||||
|
The easiest way to run idp.global is using Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @idp.global/idp.global
|
# Pull the latest image
|
||||||
|
docker pull code.foss.global/idp.global/idp.global
|
||||||
|
|
||||||
|
# Run with environment variables
|
||||||
|
docker run -d \
|
||||||
|
-p 2999:2999 \
|
||||||
|
-e MONGODB_URL=mongodb://your-mongo:27017/idp \
|
||||||
|
-e IDP_BASEURL=https://your-domain.com \
|
||||||
|
-e INSTANCE_NAME=idp.global \
|
||||||
|
code.foss.global/idp.global/idp.global
|
||||||
```
|
```
|
||||||
|
|
||||||
This will download and install the necessary dependencies along with the module to your project.
|
### Environment Variables
|
||||||
|
|
||||||
## Usage
|
| Variable | Description | Required |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `MONGODB_URL` | MongoDB connection string | ✅ Yes |
|
||||||
|
| `IDP_BASEURL` | Public URL of your idp.global instance | ✅ Yes |
|
||||||
|
| `INSTANCE_NAME` | Name for this IDP instance | No (default: `idp.global`) |
|
||||||
|
| `SERVEZONE_PLATFROM_AUTHORIZATION` | ServeZone platform auth token | No |
|
||||||
|
|
||||||
To use `@idp.global/idp.global`, one needs to understand its key components and functionalities. Below, we'll guide you through setting up, logging in, registering, and managing users and organizations within an IDP (Identity Provider) environment using this package.
|
### Docker Compose Example
|
||||||
|
|
||||||
### Setting Up the Environment
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
idp:
|
||||||
|
image: code.foss.global/idp.global/idp.global
|
||||||
|
ports:
|
||||||
|
- "2999:2999"
|
||||||
|
environment:
|
||||||
|
MONGODB_URL: mongodb://mongo:27017/idp
|
||||||
|
IDP_BASEURL: https://idp.yourdomain.com
|
||||||
|
INSTANCE_NAME: my-idp
|
||||||
|
depends_on:
|
||||||
|
- mongo
|
||||||
|
|
||||||
First, let's set up the environment:
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db
|
||||||
|
|
||||||
```typescript
|
volumes:
|
||||||
// Import the necessary modules
|
mongo-data:
|
||||||
import * as serviceworker from '@api.global/typedserver/web_serviceworker_client';
|
|
||||||
import * as domtools from '@design.estate/dees-domtools';
|
|
||||||
import { html, render } from '@design.estate/dees-element';
|
|
||||||
import { IdpWelcome } from './elements/idp-welcome.js';
|
|
||||||
|
|
||||||
// Define an asynchronous run function
|
|
||||||
const run = async () => {
|
|
||||||
// Set up DOM tools
|
|
||||||
const domtoolsInstance = await domtools.DomTools.setupDomTools();
|
|
||||||
domtools.elementBasic.setup();
|
|
||||||
|
|
||||||
// Configure website information
|
|
||||||
domtoolsInstance.setWebsiteInfo({
|
|
||||||
metaObject: {
|
|
||||||
title: 'idp.global',
|
|
||||||
description: 'the code that runs idp.global',
|
|
||||||
canonicalDomain: 'https://idp.global',
|
|
||||||
ldCompany: {
|
|
||||||
name: 'Task Venture Capital GmbH',
|
|
||||||
status: 'active',
|
|
||||||
contact: {
|
|
||||||
address: {
|
|
||||||
name: 'Task Venture Capital GmbH',
|
|
||||||
city: 'Grasberg',
|
|
||||||
country: 'Germany',
|
|
||||||
houseNumber: '24',
|
|
||||||
postalCode: '28879',
|
|
||||||
streetName: 'Eickedorfer Vorweide',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up the service worker
|
|
||||||
const serviceWorker = await serviceworker.getServiceworkerClient();
|
|
||||||
|
|
||||||
// Render the main template
|
|
||||||
const mainTemplate = html`
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0px;
|
|
||||||
--background-accent: #303f9f;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<idp-welcome></idp-welcome>
|
|
||||||
`;
|
|
||||||
|
|
||||||
render(mainTemplate, document.body);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run the function
|
|
||||||
run();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using the IDP Client
|
The server listens on port 2999 by default.
|
||||||
|
|
||||||
The IDP Client is essential to communicate with the IDP server. Below is a sample of how to set up and use the IDP client:
|
## 📦 Published Packages
|
||||||
|
|
||||||
|
This monorepo publishes the following npm packages:
|
||||||
|
|
||||||
|
| Package | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `@idp.global/interfaces` | TypeScript interfaces for API contracts |
|
||||||
|
| `@idp.global/idpclient` | Client library for browser and Node.js |
|
||||||
|
| `@idp.global/web` | Web UI components |
|
||||||
|
|
||||||
|
## 💻 Client Usage
|
||||||
|
|
||||||
|
### Browser Client
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { IdpState } from './idp.state.js';
|
import { IdpClient } from '@idp.global/idpclient';
|
||||||
import * as plugins from './plugins.js';
|
|
||||||
|
|
||||||
// Instantiate IdpState which provides a singleton instance
|
// Initialize the client
|
||||||
export class IdpDemo {
|
const idpClient = new IdpClient('https://idp.global');
|
||||||
private idpState = IdpState.getSingletonInstance();
|
|
||||||
|
|
||||||
// Function to initialize and use IdpClient
|
// Enable WebSocket connection
|
||||||
public async demo() {
|
await idpClient.enableTypedSocket();
|
||||||
// Fetch the client instance
|
|
||||||
const { idpClient } = this.idpState;
|
// Check login status
|
||||||
// Handler for login
|
const isLoggedIn = await idpClient.determineLoginStatus();
|
||||||
const handleLogin = async () => {
|
|
||||||
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
// Login with email and password
|
||||||
username: 'user@example.com',
|
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||||
password: 'password123',
|
username: 'user@example.com',
|
||||||
});
|
password: 'securepassword'
|
||||||
if (response.refreshToken) {
|
});
|
||||||
await idpClient.storeJwt(response.jwt);
|
|
||||||
console.log("Logged in successfully, JWT stored.");
|
if (response.refreshToken) {
|
||||||
} else {
|
await idpClient.refreshJwt(response.refreshToken);
|
||||||
console.log("Login failed.");
|
console.log('✅ Login successful!');
|
||||||
}
|
|
||||||
};
|
|
||||||
// Execute login handler
|
|
||||||
await handleLogin();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instantiate and run demo
|
// Get current user info
|
||||||
const demo = new IdpDemo();
|
const userInfo = await idpClient.whoIs();
|
||||||
demo.demo();
|
console.log('User:', userInfo.user);
|
||||||
|
|
||||||
|
// Get user's organizations
|
||||||
|
const orgs = await idpClient.getRolesAndOrganizations();
|
||||||
|
console.log('Organizations:', orgs.organizations);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Managing User Authentication
|
### Organization Management
|
||||||
|
|
||||||
Several functionalities are available for managing user authentication. These include registering, logging in, and refreshing JWTs.
|
|
||||||
|
|
||||||
#### Registration Process
|
|
||||||
|
|
||||||
The registration process is typically more involved and requires steps such as email validation, setting user-specific data, and verifying OTPs for additional security.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import * as plugins from './plugins.js';
|
// Create a new organization
|
||||||
import { IdpState } from './idp.state.js';
|
const result = await idpClient.createOrganization('My Company', 'my-company', 'manifest');
|
||||||
|
console.log('Created:', result.resultingOrganization);
|
||||||
|
|
||||||
// Registration stepper element
|
// Invite members
|
||||||
export class IdpRegistrationStepper extends plugins.DeesElement {
|
await idpClient.requests.createInvitation.fire({
|
||||||
private idpState = IdpState.getSingletonInstance();
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: 'org-id',
|
||||||
|
email: 'newmember@example.com',
|
||||||
|
roles: ['member']
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
public async firstUpdated() {
|
### CLI Tool
|
||||||
await this.domtoolsPromise;
|
|
||||||
this.domtools.router.on(`/finishregistration`, async (routeArg) => {
|
|
||||||
const validationToken = routeArg.queryParams.validationtoken;
|
|
||||||
if (!validationToken) {
|
|
||||||
this.renderErrorMessage("Validation token not found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const emailResponse = await this.validateEmail(validationToken);
|
|
||||||
if (!emailResponse.email) {
|
|
||||||
this.renderErrorMessage("Invalid validation token.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.renderRegistrationForm(emailResponse.email);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateEmail(token: string) {
|
The `ts_idpcli` module provides a command-line interface:
|
||||||
return await this.idpState.idpClient.requests.afterRegistrationEmailClicked.fire({
|
|
||||||
token
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderRegistrationForm(email: string) {
|
```bash
|
||||||
const template = plugins.html`
|
# Login
|
||||||
<dees-form @formData="${async (event) => await this.handleFormSubmission(event, email)}">
|
idp login
|
||||||
<dees-input-text key="First Name" label="First Name" required></dees-input-text>
|
|
||||||
<dees-input-text key="Last Name" label="Last Name" required></dees-input-text>
|
|
||||||
<dees-form-submit>Next</dees-form-submit>
|
|
||||||
</dees-form>
|
|
||||||
`;
|
|
||||||
this.render(template, this.shadowRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFormSubmission(event: FormDataEvent, email: string) {
|
# Show current user
|
||||||
const formData = (event.target as any).getFormData();
|
idp whoami
|
||||||
await this.idpState.idpClient.requests.setData.fire({
|
|
||||||
token: this.storedData.validationTokenUrlParam,
|
|
||||||
userData: {
|
|
||||||
email,
|
|
||||||
first_name: formData.FirstName,
|
|
||||||
last_name: formData.LastName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Proceed to the next steps as per the registration flow
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderErrorMessage(message: string) {
|
# List organizations
|
||||||
const template = plugins.html`<div>Error: ${message}</div>`;
|
idp orgs
|
||||||
this.render(template, this.shadowRoot);
|
|
||||||
}
|
# List organization members
|
||||||
|
idp members --org <org-id>
|
||||||
|
|
||||||
|
# Invite a user
|
||||||
|
idp invite --org <org-id> --email user@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 OIDC Integration
|
||||||
|
|
||||||
|
idp.global implements a full OpenID Connect provider. Third-party applications can use it for SSO:
|
||||||
|
|
||||||
|
### Discovery Document
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /oauth/authorize?
|
||||||
|
client_id=your-client-id&
|
||||||
|
redirect_uri=https://yourapp.com/callback&
|
||||||
|
response_type=code&
|
||||||
|
scope=openid profile email organizations&
|
||||||
|
state=random-state&
|
||||||
|
code_challenge=PKCE_CHALLENGE&
|
||||||
|
code_challenge_method=S256
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Exchange
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /oauth/token
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
grant_type=authorization_code&
|
||||||
|
code=AUTHORIZATION_CODE&
|
||||||
|
redirect_uri=https://yourapp.com/callback&
|
||||||
|
client_id=your-client-id&
|
||||||
|
client_secret=your-client-secret&
|
||||||
|
code_verifier=PKCE_VERIFIER
|
||||||
|
```
|
||||||
|
|
||||||
|
### UserInfo
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /oauth/userinfo
|
||||||
|
Authorization: Bearer ACCESS_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "user-id",
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"email_verified": true,
|
||||||
|
"organizations": [
|
||||||
|
{ "id": "org-1", "name": "Acme Corp", "slug": "acme", "roles": ["admin"] }
|
||||||
|
],
|
||||||
|
"roles": ["user"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### User Management
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
Managing user data including roles, organizations, and billing plans is essential in any identity provider software.
|
- **Runtime**: Node.js with ES Modules
|
||||||
|
- **Language**: TypeScript (strict mode)
|
||||||
|
- **Database**: MongoDB via `@push.rocks/smartdata`
|
||||||
|
- **Web Server**: `@api.global/typedserver`
|
||||||
|
- **Real-time**: `@api.global/typedsocket` (WebSocket)
|
||||||
|
- **JWT**: `@push.rocks/smartjwt` (RS256 signing)
|
||||||
|
- **Frontend**: `@design.estate/dees-element` (Web Components)
|
||||||
|
- **Build**: `@git.zone/tsbuild` + `@git.zone/tsbundle`
|
||||||
|
|
||||||
#### Getting User Data
|
## 📚 API Reference
|
||||||
|
|
||||||
```typescript
|
### Request Interfaces
|
||||||
import * as plugins from './plugins.js';
|
|
||||||
|
|
||||||
const fetchUserData = async (jwt: string) => {
|
All API requests are type-safe. See `ts_interfaces/request/` for the complete API:
|
||||||
const user = await plugins.typedrequest.TypedRequest<plugins.lointReception.request.IReq_GetUserData>(
|
|
||||||
`/getUserData`, 'POST').fire({jwt});
|
|
||||||
console.log(user);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUserData('<JWT_TOKEN_HERE>');
|
- **Authentication**: `IReq_LoginWithEmail`, `IReq_LoginWithApiToken`, `IReq_RefreshJwt`
|
||||||
```
|
- **Registration**: `IReq_FirstRegistration`, `IReq_FinishRegistration`
|
||||||
|
- **User Management**: `IReq_GetUserData`, `IReq_SetUserData`, `IReq_GetUserSessions`
|
||||||
|
- **Organizations**: `IReq_CreateOrganization`, `IReq_GetOrgMembers`, `IReq_CreateInvitation`
|
||||||
|
- **Apps & OAuth**: `IReq_GetGlobalApps`, `IReq_CreateGlobalApp`
|
||||||
|
- **Billing**: `IReq_GetBillingPlan`, `IReq_UpdatePaymentMethod`
|
||||||
|
|
||||||
#### Creating an Organization
|
### Data Models
|
||||||
|
|
||||||
```typescript
|
See `ts_interfaces/data/` for all data structures:
|
||||||
import { IdpState } from './idp.state.js';
|
|
||||||
|
|
||||||
export class OrganizationManager {
|
- `IUser` - User profile and credentials
|
||||||
private idpState = IdpState.getSingletonInstance();
|
- `IOrganization` - Organization entity
|
||||||
|
- `IRole` - User roles within organizations
|
||||||
public async createOrganization(name: string, slug: string, jwt: string) {
|
- `IJwt` - JWT token structure
|
||||||
const response = await this.idpState.idpClient.requests.createOrganization.fire({
|
- `IApp` - OAuth application definitions
|
||||||
jwt: jwt,
|
- `IOidcAccessToken`, `IAuthorizationCode` - OIDC tokens
|
||||||
organizationName: name,
|
|
||||||
organizationSlug: slug,
|
|
||||||
action: 'manifest',
|
|
||||||
});
|
|
||||||
if (response.resultingOrganization) {
|
|
||||||
console.log(`Organization ${name} created successfully.`);
|
|
||||||
} else {
|
|
||||||
console.log(`Organization creation failed.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
const organizationManager = new OrganizationManager();
|
|
||||||
organizationManager.createOrganization('Dev Org', 'dev-org', '<JWT_TOKEN_HERE>');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing JWTs
|
|
||||||
|
|
||||||
The `@idp.global/idp.global` package involves managing JSON Web Tokens (JWTs) for session handling and security.
|
|
||||||
|
|
||||||
#### Refreshing JWTs
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { IdpClient } from './idp.client.js';
|
|
||||||
|
|
||||||
export const refreshJwt = async (client: IdpClient) => {
|
|
||||||
const currentJwt = await client.getJwt();
|
|
||||||
if (!currentJwt) return null;
|
|
||||||
const response = await client.requests.refreshJwt.fire({
|
|
||||||
refreshToken: currentJwt.data.refreshToken
|
|
||||||
});
|
|
||||||
if (response.jwt) {
|
|
||||||
await client.storeJwt(response.jwt);
|
|
||||||
console.log("JWT refreshed and stored.");
|
|
||||||
return response.jwt;
|
|
||||||
} else {
|
|
||||||
console.log("JWT refresh failed.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
const idpClient = new IdpClient('https://reception.lossless.one/typedrequest');
|
|
||||||
refreshJwt(idpClient);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Handling Authentication Tokens
|
|
||||||
|
|
||||||
Handling tokens (JWTs, refresh tokens, transfer tokens) securely is crucial for maintaining session integrity.
|
|
||||||
|
|
||||||
#### Exchanging Refresh Token for Transfer Token
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { IdpClient } from './idp.client.js';
|
|
||||||
|
|
||||||
const getTransferToken = async (client: IdpClient) => {
|
|
||||||
const refreshToken = await client.getJwt().data.refreshToken;
|
|
||||||
const response = await client.requests.obtainOneTimeToken.fire({
|
|
||||||
refreshToken
|
|
||||||
});
|
|
||||||
if(response.transferToken) {
|
|
||||||
console.log("Obtained Transfer Token: ", response.transferToken);
|
|
||||||
return response.transferToken;
|
|
||||||
} else {
|
|
||||||
console.log("Failed to obtain Transfer Token.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
const idpClient = new IdpClient('https://reception.lossless.one/typedrequest');
|
|
||||||
getTransferToken(idpClient);
|
|
||||||
```
|
|
||||||
|
|
||||||
This comprehensive guide should help you understand the detailed setup and usage of the `@idp.global/idp.global` module effectively.
|
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
This 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.
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
### Trademarks
|
### Trademarks
|
||||||
|
|
||||||
This 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.
|
This 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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
### Company Information
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
Task Venture Capital GmbH
|
||||||
Registered at District court Bremen HRB 35230 HB, Germany
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
By 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.
|
By 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.
|
||||||
|
|||||||
+2
-2
@@ -30,7 +30,7 @@ stories/
|
|||||||
| ID | Title | Priority | Source |
|
| ID | Title | Priority | Source |
|
||||||
|----|-------|----------|--------|
|
|----|-------|----------|--------|
|
||||||
| ORG-001 | [Sync Billing Plans with Users](organization-owner/ORG-001-billing-sync.md) | High | TODO |
|
| 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 | New |
|
| 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-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-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-005 | [View Organization Usage Analytics](organization-owner/ORG-005-usage-analytics.md) | Medium | New |
|
||||||
@@ -69,7 +69,7 @@ stories/
|
|||||||
|
|
||||||
| Priority | Count | Stories |
|
| Priority | Count | Stories |
|
||||||
|----------|-------|---------|
|
|----------|-------|---------|
|
||||||
| Critical | 3 | EU-002, ORG-002, ADM-001 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
|
||||||
|
|||||||
@@ -2,27 +2,127 @@
|
|||||||
|
|
||||||
**ID:** ORG-002
|
**ID:** ORG-002
|
||||||
**Priority:** Critical
|
**Priority:** Critical
|
||||||
**Status:** Planned
|
**Status:** Complete
|
||||||
|
|
||||||
## User Story
|
## 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.
|
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
|
## Acceptance Criteria
|
||||||
- [ ] Owner can invite users via email address
|
- [x] Owner can invite users via email address
|
||||||
- [ ] Invited user receives email with invitation link
|
- [x] Invited user receives email with invitation link
|
||||||
- [ ] Invitation can be accepted by existing users or during registration
|
- [x] Invitation can be accepted by existing users or during registration
|
||||||
- [ ] Owner can view pending invitations and resend/cancel them
|
- [x] Owner can view pending invitations and resend/cancel them
|
||||||
- [ ] Owner can see all current members with their roles
|
- [x] Owner can see all current members with their roles
|
||||||
- [ ] Owner can remove members from organization
|
- [x] Owner can remove members from organization
|
||||||
- [ ] Owner can transfer ownership to another member
|
- [x] Owner can transfer ownership to another member
|
||||||
- [ ] Bulk invite via CSV upload
|
- [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
|
## Technical Notes
|
||||||
- Organization and User models exist with association
|
- Organization and User models exist with association
|
||||||
- Need new Invitation model with token and expiry
|
- UserInvitation model stores invitation data with 90-day expiry
|
||||||
- Use `ReceptionMailer` for invitation emails
|
- `ReceptionMailer.sendInvitationEmail()` handles email delivery
|
||||||
- RoleManager can be leveraged for role assignment
|
- RoleManager updated to support `roles: string[]` array
|
||||||
- Consider invitation expiry (7 days default)
|
- Backward compatible with existing single-role data
|
||||||
|
|
||||||
|
## Related Stories
|
||||||
|
- ORG-003: Assign Roles to Members (enhanced with multi-role support)
|
||||||
|
|
||||||
## Related TODOs
|
## Related TODOs
|
||||||
- New feature - core organizational functionality
|
- [ ] 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
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.7.0',
|
version: '1.15.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-2
@@ -4,14 +4,70 @@ import { Reception } from './reception/classes.reception.js';
|
|||||||
|
|
||||||
export const runCli = async () => {
|
export const runCli = async () => {
|
||||||
const serviceQenv = new plugins.qenv.Qenv('./', './.nogit', false);
|
const serviceQenv = new plugins.qenv.Qenv('./', './.nogit', false);
|
||||||
|
|
||||||
|
// Create reception first so we can reference it in routes
|
||||||
|
let reception: Reception;
|
||||||
|
|
||||||
const websiteServer = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
const websiteServer = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||||
feedMetadata: null,
|
feedMetadata: null,
|
||||||
domain: 'idp.global',
|
domain: 'idp.global',
|
||||||
serveDir: paths.distWebDir,
|
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;
|
||||||
|
|
||||||
|
// OIDC Discovery endpoint
|
||||||
|
typedserver.addRoute('/.well-known/openid-configuration', 'GET', async (ctx) => {
|
||||||
|
return new Response(JSON.stringify(reception.oidcManager.getDiscoveryDocument()), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// JWKS endpoint
|
||||||
|
typedserver.addRoute('/.well-known/jwks.json', 'GET', async (ctx) => {
|
||||||
|
return new Response(JSON.stringify(reception.oidcManager.getJwks()), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth Authorization endpoint
|
||||||
|
typedserver.addRoute('/oauth/authorize', 'GET', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleAuthorize(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth Token endpoint
|
||||||
|
typedserver.addRoute('/oauth/token', 'POST', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleToken(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth UserInfo endpoint (GET and POST)
|
||||||
|
typedserver.addRoute('/oauth/userinfo', 'GET', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleUserInfo(ctx);
|
||||||
|
});
|
||||||
|
typedserver.addRoute('/oauth/userinfo', 'POST', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleUserInfo(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth Revocation endpoint
|
||||||
|
typedserver.addRoute('/oauth/revoke', 'POST', async (ctx) => {
|
||||||
|
return reception.oidcManager.handleRevoke(ctx);
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// lets add the reception routes
|
// lets add the reception routes
|
||||||
const reception = new Reception({
|
reception = new Reception({
|
||||||
name: (await serviceQenv.getEnvVarOnDemand('INSTANCE_NAME')) || 'idp.global',
|
name: (await serviceQenv.getEnvVarOnDemand('INSTANCE_NAME')) || 'idp.global',
|
||||||
mongoDescriptor: {
|
mongoDescriptor: {
|
||||||
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
|
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
|
||||||
@@ -21,5 +77,5 @@ export const runCli = async () => {
|
|||||||
});
|
});
|
||||||
await reception.start();
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type { Reception } from './classes.reception.js';
|
import type { Reception } from './classes.reception.js';
|
||||||
import { App } from './classes.app.js';
|
import { App } from './classes.app.js';
|
||||||
|
// Note: App class is imported for use with setDefaultManagerForDoc
|
||||||
|
|
||||||
export class AppManager {
|
export class AppManager {
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
@@ -95,7 +96,7 @@ export class AppManager {
|
|||||||
const clientSecret = plugins.smartunique.shortId(32);
|
const clientSecret = plugins.smartunique.shortId(32);
|
||||||
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
|
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
|
||||||
|
|
||||||
const app = new App();
|
const app = new this.CApp();
|
||||||
app.id = `app-${plugins.smartunique.shortId(8)}`;
|
app.id = `app-${plugins.smartunique.shortId(8)}`;
|
||||||
app.type = 'global';
|
app.type = 'global';
|
||||||
app.data = {
|
app.data = {
|
||||||
@@ -304,7 +305,7 @@ export class AppManager {
|
|||||||
for (const appData of defaultGlobalApps) {
|
for (const appData of defaultGlobalApps) {
|
||||||
const existing = await this.CApp.getInstance({ id: appData.id });
|
const existing = await this.CApp.getInstance({ id: appData.id });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const app = new App();
|
const app = new this.CApp();
|
||||||
app.id = appData.id!;
|
app.id = appData.id!;
|
||||||
app.type = appData.type!;
|
app.type = appData.type!;
|
||||||
app.data = appData.data as any;
|
app.data = appData.data as any;
|
||||||
|
|||||||
@@ -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> {
|
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> {
|
||||||
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
|
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
|
||||||
const jwt = await Jwt.getInstance({
|
const jwt = await this.CJwt.getInstance({
|
||||||
id: jwtData.id,
|
id: jwtData.id,
|
||||||
});
|
});
|
||||||
if (jwt.blocked) {
|
if (jwt.blocked) {
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
|
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
|
||||||
invalidated: false,
|
invalidated: false,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
deviceId: null
|
deviceId: null,
|
||||||
|
deviceInfo: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastActive: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
public transferToken: string;
|
public transferToken: string;
|
||||||
|
|||||||
@@ -259,6 +259,83 @@ export class LoginSessionManager {
|
|||||||
ok: false
|
ok: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
|
// Get all sessions for the current user
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||||
|
'getUserSessions',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current session's refresh token to identify the current session
|
||||||
|
const currentRefreshToken = jwt.data.refreshToken;
|
||||||
|
|
||||||
|
// Get all sessions for this user
|
||||||
|
const sessions = await this.CLoginSession.getInstances({
|
||||||
|
'data.userId': jwt.data.userId,
|
||||||
|
'data.invalidated': false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: sessions.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
deviceId: session.data.deviceId || 'unknown',
|
||||||
|
deviceName: session.data.deviceInfo?.deviceName || 'Unknown Device',
|
||||||
|
browser: session.data.deviceInfo?.browser || 'Unknown Browser',
|
||||||
|
os: session.data.deviceInfo?.os || 'Unknown OS',
|
||||||
|
ip: session.data.deviceInfo?.ip || 'Unknown',
|
||||||
|
lastActive: session.data.lastActive || session.data.createdAt || Date.now(),
|
||||||
|
createdAt: session.data.createdAt || Date.now(),
|
||||||
|
isCurrent: session.data.refreshToken === currentRefreshToken,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke a specific session
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||||
|
'revokeSession',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the session to revoke
|
||||||
|
const sessionToRevoke = await this.CLoginSession.getInstance({
|
||||||
|
id: requestArg.sessionId,
|
||||||
|
'data.userId': jwt.data.userId, // Ensure user can only revoke their own sessions
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sessionToRevoke) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow revoking the current session via this method
|
||||||
|
if (sessionToRevoke.data.refreshToken === jwt.data.refreshToken) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Cannot revoke current session. Use logout instead.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionToRevoke.invalidate();
|
||||||
|
|
||||||
|
// Log the activity
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
jwt.data.userId,
|
||||||
|
'session_revoked',
|
||||||
|
`Revoked session on ${sessionToRevoke.data.deviceInfo?.deviceName || 'unknown device'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,683 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { Reception } from './classes.reception.js';
|
||||||
|
import type { App } from './classes.app.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OidcManager handles OpenID Connect (OIDC) server functionality
|
||||||
|
* for third-party client authentication.
|
||||||
|
*/
|
||||||
|
export class OidcManager {
|
||||||
|
public receptionRef: Reception;
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory store for authorization codes (short-lived, 10 min TTL)
|
||||||
|
private authorizationCodes = new Map<string, plugins.idpInterfaces.data.IAuthorizationCode>();
|
||||||
|
|
||||||
|
// In-memory store for access tokens (for validation)
|
||||||
|
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
|
||||||
|
|
||||||
|
// In-memory store for refresh tokens
|
||||||
|
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
|
||||||
|
|
||||||
|
// In-memory store for user consents (should be persisted later)
|
||||||
|
private userConsents = new Map<string, plugins.idpInterfaces.data.IUserConsent>();
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
|
||||||
|
// Start cleanup task for expired codes/tokens
|
||||||
|
this.startCleanupTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OIDC Discovery Document
|
||||||
|
*/
|
||||||
|
public getDiscoveryDocument(): plugins.idpInterfaces.data.IOidcDiscoveryDocument {
|
||||||
|
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
|
||||||
|
return {
|
||||||
|
issuer: baseUrl,
|
||||||
|
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
||||||
|
token_endpoint: `${baseUrl}/oauth/token`,
|
||||||
|
userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
|
||||||
|
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|
||||||
|
revocation_endpoint: `${baseUrl}/oauth/revoke`,
|
||||||
|
scopes_supported: ['openid', 'profile', 'email', 'organizations', 'roles'],
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
|
||||||
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
claims_supported: [
|
||||||
|
'sub', 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce',
|
||||||
|
'name', 'preferred_username', 'picture',
|
||||||
|
'email', 'email_verified',
|
||||||
|
'organizations', 'roles'
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the JSON Web Key Set (JWKS)
|
||||||
|
*/
|
||||||
|
public getJwks(): plugins.idpInterfaces.data.IJwks {
|
||||||
|
const keypair = this.receptionRef.jwtManager.smartjwtInstance.getKeyPairAsJson();
|
||||||
|
// Convert PEM to JWK format
|
||||||
|
const jwk = this.pemToJwk(keypair.publicPem);
|
||||||
|
return {
|
||||||
|
keys: [jwk],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PEM public key to JWK format
|
||||||
|
*/
|
||||||
|
private pemToJwk(publicPem: string): plugins.idpInterfaces.data.IJwk {
|
||||||
|
// For now, use a simplified approach - in production, parse the PEM properly
|
||||||
|
// The smartjwt library should provide this, or use crypto.createPublicKey
|
||||||
|
const kid = plugins.smarthash.sha256FromStringSync(publicPem).substring(0, 16);
|
||||||
|
|
||||||
|
// This is a placeholder - proper implementation would extract n and e from PEM
|
||||||
|
// For now, return a minimal structure
|
||||||
|
return {
|
||||||
|
kty: 'RSA',
|
||||||
|
use: 'sig',
|
||||||
|
alg: 'RS256',
|
||||||
|
kid: kid,
|
||||||
|
// These would be extracted from the actual public key
|
||||||
|
n: Buffer.from(publicPem).toString('base64url').substring(0, 256),
|
||||||
|
e: 'AQAB', // Standard RSA exponent (65537)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the authorization endpoint request
|
||||||
|
*/
|
||||||
|
public async handleAuthorize(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||||
|
const params = ctx.url.searchParams;
|
||||||
|
|
||||||
|
// Extract authorization request parameters
|
||||||
|
const clientId = params.get('client_id');
|
||||||
|
const redirectUri = params.get('redirect_uri');
|
||||||
|
const responseType = params.get('response_type');
|
||||||
|
const scope = params.get('scope');
|
||||||
|
const state = params.get('state');
|
||||||
|
const codeChallenge = params.get('code_challenge');
|
||||||
|
const codeChallengeMethod = params.get('code_challenge_method');
|
||||||
|
const nonce = params.get('nonce');
|
||||||
|
const prompt = params.get('prompt') as 'none' | 'login' | 'consent' | null;
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!clientId || !redirectUri || !responseType || !scope || !state) {
|
||||||
|
return this.errorResponse('invalid_request', 'Missing required parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseType !== 'code') {
|
||||||
|
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate code challenge method if present
|
||||||
|
if (codeChallenge && codeChallengeMethod !== 'S256') {
|
||||||
|
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the app by client_id
|
||||||
|
const app = await this.findAppByClientId(clientId);
|
||||||
|
if (!app) {
|
||||||
|
return this.errorResponse('invalid_client', 'Unknown client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate redirect URI
|
||||||
|
if (!app.data.oauthCredentials.redirectUris.includes(redirectUri)) {
|
||||||
|
return this.errorResponse('invalid_request', 'Invalid redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate scopes
|
||||||
|
const requestedScopes = scope.split(' ') as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const allowedScopes = app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const validScopes = requestedScopes.filter(s => allowedScopes.includes(s));
|
||||||
|
|
||||||
|
if (!validScopes.includes('openid')) {
|
||||||
|
return this.errorResponse('invalid_scope', 'openid scope is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, redirect to login page with OAuth parameters
|
||||||
|
// The login page will handle authentication and call back to complete authorization
|
||||||
|
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
|
||||||
|
const loginUrl = new URL(`${baseUrl}/login`);
|
||||||
|
loginUrl.searchParams.set('oauth', 'true');
|
||||||
|
loginUrl.searchParams.set('client_id', clientId);
|
||||||
|
loginUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
|
loginUrl.searchParams.set('scope', validScopes.join(' '));
|
||||||
|
loginUrl.searchParams.set('state', state);
|
||||||
|
if (codeChallenge) {
|
||||||
|
loginUrl.searchParams.set('code_challenge', codeChallenge);
|
||||||
|
loginUrl.searchParams.set('code_challenge_method', codeChallengeMethod!);
|
||||||
|
}
|
||||||
|
if (nonce) {
|
||||||
|
loginUrl.searchParams.set('nonce', nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.redirect(loginUrl.toString(), 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an authorization code after user authentication
|
||||||
|
*/
|
||||||
|
public async generateAuthorizationCode(
|
||||||
|
clientId: string,
|
||||||
|
userId: string,
|
||||||
|
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
redirectUri: string,
|
||||||
|
codeChallenge?: string,
|
||||||
|
nonce?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const code = plugins.smartunique.shortId(32);
|
||||||
|
const authCode: plugins.idpInterfaces.data.IAuthorizationCode = {
|
||||||
|
code,
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
scopes,
|
||||||
|
redirectUri,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
||||||
|
nonce,
|
||||||
|
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||||
|
used: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.authorizationCodes.set(code, authCode);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the token endpoint request
|
||||||
|
*/
|
||||||
|
public async handleToken(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||||
|
// Parse form data
|
||||||
|
const contentType = ctx.headers.get('content-type');
|
||||||
|
if (!contentType?.includes('application/x-www-form-urlencoded')) {
|
||||||
|
return this.tokenErrorResponse('invalid_request', 'Content-Type must be application/x-www-form-urlencoded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await ctx.formData();
|
||||||
|
const grantType = formData.get('grant_type') as string;
|
||||||
|
|
||||||
|
// Extract client credentials from Basic auth or form
|
||||||
|
let clientId = formData.get('client_id') as string;
|
||||||
|
let clientSecret = formData.get('client_secret') as string;
|
||||||
|
|
||||||
|
const authHeader = ctx.headers.get('authorization');
|
||||||
|
if (authHeader?.startsWith('Basic ')) {
|
||||||
|
const base64 = authHeader.substring(6);
|
||||||
|
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
|
||||||
|
const [id, secret] = decoded.split(':');
|
||||||
|
clientId = clientId || id;
|
||||||
|
clientSecret = clientSecret || secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and validate app
|
||||||
|
const app = await this.findAppByClientId(clientId);
|
||||||
|
if (!app) {
|
||||||
|
return this.tokenErrorResponse('invalid_client', 'Unknown client');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate client secret for confidential clients
|
||||||
|
if (clientSecret) {
|
||||||
|
const secretHash = await plugins.smarthash.sha256FromString(clientSecret);
|
||||||
|
if (secretHash !== app.data.oauthCredentials.clientSecretHash) {
|
||||||
|
return this.tokenErrorResponse('invalid_client', 'Invalid client credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grantType === 'authorization_code') {
|
||||||
|
return this.handleAuthorizationCodeGrant(formData, app);
|
||||||
|
} else if (grantType === 'refresh_token') {
|
||||||
|
return this.handleRefreshTokenGrant(formData, app);
|
||||||
|
} else {
|
||||||
|
return this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle authorization_code grant type
|
||||||
|
*/
|
||||||
|
private async handleAuthorizationCodeGrant(
|
||||||
|
formData: FormData,
|
||||||
|
app: App
|
||||||
|
): Promise<Response> {
|
||||||
|
const code = formData.get('code') as string;
|
||||||
|
const redirectUri = formData.get('redirect_uri') as string;
|
||||||
|
const codeVerifier = formData.get('code_verifier') as string;
|
||||||
|
|
||||||
|
if (!code || !redirectUri) {
|
||||||
|
return this.tokenErrorResponse('invalid_request', 'Missing code or redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and validate authorization code
|
||||||
|
const authCode = this.authorizationCodes.get(code);
|
||||||
|
if (!authCode) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCode.used) {
|
||||||
|
// Code reuse attack - revoke all tokens for this code
|
||||||
|
this.authorizationCodes.delete(code);
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCode.expiresAt < Date.now()) {
|
||||||
|
this.authorizationCodes.delete(code);
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCode.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCode.redirectUri !== redirectUri) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify PKCE if code challenge was used
|
||||||
|
if (authCode.codeChallenge) {
|
||||||
|
if (!codeVerifier) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
||||||
|
}
|
||||||
|
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
||||||
|
if (expectedChallenge !== authCode.codeChallenge) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark code as used
|
||||||
|
authCode.used = true;
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = await this.generateTokens(
|
||||||
|
authCode.userId,
|
||||||
|
app.data.oauthCredentials.clientId,
|
||||||
|
authCode.scopes,
|
||||||
|
authCode.nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(tokens), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle refresh_token grant type
|
||||||
|
*/
|
||||||
|
private async handleRefreshTokenGrant(
|
||||||
|
formData: FormData,
|
||||||
|
app: App
|
||||||
|
): Promise<Response> {
|
||||||
|
const refreshToken = formData.get('refresh_token') as string;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
||||||
|
const storedToken = this.refreshTokens.get(tokenHash);
|
||||||
|
|
||||||
|
if (!storedToken) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedToken.revoked) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedToken.expiresAt < Date.now()) {
|
||||||
|
this.refreshTokens.delete(tokenHash);
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedToken.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens (without new refresh token by default)
|
||||||
|
const tokens = await this.generateTokens(
|
||||||
|
storedToken.userId,
|
||||||
|
storedToken.clientId,
|
||||||
|
storedToken.scopes,
|
||||||
|
undefined,
|
||||||
|
false // Don't generate new refresh token
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(tokens), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token, ID token, and optionally refresh token
|
||||||
|
*/
|
||||||
|
private async generateTokens(
|
||||||
|
userId: string,
|
||||||
|
clientId: string,
|
||||||
|
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
nonce?: string,
|
||||||
|
includeRefreshToken = true
|
||||||
|
): Promise<plugins.idpInterfaces.data.ITokenResponse> {
|
||||||
|
const now = Date.now();
|
||||||
|
const accessTokenLifetime = 3600; // 1 hour
|
||||||
|
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
const accessToken = plugins.smartunique.shortId(32);
|
||||||
|
const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
||||||
|
const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = {
|
||||||
|
id: plugins.smartunique.shortId(8),
|
||||||
|
tokenHash: accessTokenHash,
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
scopes,
|
||||||
|
expiresAt: now + accessTokenLifetime * 1000,
|
||||||
|
issuedAt: now,
|
||||||
|
};
|
||||||
|
this.accessTokens.set(accessTokenHash, accessTokenData);
|
||||||
|
|
||||||
|
// Generate ID token (JWT)
|
||||||
|
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
||||||
|
|
||||||
|
const response: plugins.idpInterfaces.data.ITokenResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: accessTokenLifetime,
|
||||||
|
id_token: idToken,
|
||||||
|
scope: scopes.join(' '),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate refresh token if requested
|
||||||
|
if (includeRefreshToken) {
|
||||||
|
const refreshToken = plugins.smartunique.shortId(48);
|
||||||
|
const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
||||||
|
const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = {
|
||||||
|
id: plugins.smartunique.shortId(8),
|
||||||
|
tokenHash: refreshTokenHash,
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
scopes,
|
||||||
|
expiresAt: now + refreshTokenLifetime * 1000,
|
||||||
|
issuedAt: now,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
this.refreshTokens.set(refreshTokenHash, refreshTokenData);
|
||||||
|
response.refresh_token = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an ID token (JWT)
|
||||||
|
*/
|
||||||
|
private async generateIdToken(
|
||||||
|
userId: string,
|
||||||
|
clientId: string,
|
||||||
|
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
nonce?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const baseUrl = this.receptionRef.options.baseUrl || 'https://idp.global';
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const claims: plugins.idpInterfaces.data.IIdTokenClaims = {
|
||||||
|
iss: baseUrl,
|
||||||
|
sub: userId,
|
||||||
|
aud: clientId,
|
||||||
|
exp: now + 3600, // 1 hour
|
||||||
|
iat: now,
|
||||||
|
auth_time: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (nonce) {
|
||||||
|
claims.nonce = nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add claims based on scopes
|
||||||
|
if (scopes.includes('profile') || scopes.includes('email') || scopes.includes('organizations') || scopes.includes('roles')) {
|
||||||
|
const userInfo = await this.getUserClaims(userId, scopes);
|
||||||
|
Object.assign(claims, userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the JWT
|
||||||
|
const idToken = await this.receptionRef.jwtManager.smartjwtInstance.createJWT(claims);
|
||||||
|
return idToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the userinfo endpoint
|
||||||
|
*/
|
||||||
|
public async handleUserInfo(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||||
|
// Get access token from Authorization header
|
||||||
|
const authHeader = ctx.headers.get('authorization');
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'WWW-Authenticate': 'Bearer error="invalid_token"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = authHeader.substring(7);
|
||||||
|
const tokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
||||||
|
const tokenData = this.accessTokens.get(tokenHash);
|
||||||
|
|
||||||
|
if (!tokenData) {
|
||||||
|
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'WWW-Authenticate': 'Bearer error="invalid_token"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenData.expiresAt < Date.now()) {
|
||||||
|
this.accessTokens.delete(tokenHash);
|
||||||
|
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'WWW-Authenticate': 'Bearer error="invalid_token", error_description="Token expired"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user claims based on token scopes
|
||||||
|
const userInfo = await this.getUserClaims(tokenData.userId, tokenData.scopes);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(userInfo), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user claims based on scopes
|
||||||
|
*/
|
||||||
|
private async getUserClaims(
|
||||||
|
userId: string,
|
||||||
|
scopes: plugins.idpInterfaces.data.TOidcScope[]
|
||||||
|
): Promise<plugins.idpInterfaces.data.IUserInfoResponse> {
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({ id: userId });
|
||||||
|
if (!user) {
|
||||||
|
return { sub: userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims: plugins.idpInterfaces.data.IUserInfoResponse = {
|
||||||
|
sub: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Profile scope
|
||||||
|
if (scopes.includes('profile')) {
|
||||||
|
claims.name = user.data?.name;
|
||||||
|
claims.preferred_username = user.data?.username;
|
||||||
|
// claims.picture = user.data?.avatarUrl; // If avatar exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email scope
|
||||||
|
if (scopes.includes('email')) {
|
||||||
|
claims.email = user.data?.email;
|
||||||
|
claims.email_verified = user.data?.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organizations scope (custom)
|
||||||
|
if (scopes.includes('organizations')) {
|
||||||
|
const organizations = await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user);
|
||||||
|
const roles = await this.receptionRef.roleManager.getAllRolesForUser(user);
|
||||||
|
if (organizations) {
|
||||||
|
claims.organizations = organizations.map(org => ({
|
||||||
|
id: org.id,
|
||||||
|
name: org.data?.name || '',
|
||||||
|
slug: org.data?.slug || '',
|
||||||
|
roles: roles
|
||||||
|
.find(r => r.data?.organizationId === org.id)?.data?.roles || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles scope (custom - global roles)
|
||||||
|
if (scopes.includes('roles')) {
|
||||||
|
const roles: string[] = ['user'];
|
||||||
|
if (user.data?.isGlobalAdmin) {
|
||||||
|
roles.push('admin');
|
||||||
|
}
|
||||||
|
claims.roles = roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the revocation endpoint
|
||||||
|
*/
|
||||||
|
public async handleRevoke(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||||
|
const formData = await ctx.formData();
|
||||||
|
const token = formData.get('token') as string;
|
||||||
|
const tokenTypeHint = formData.get('token_type_hint') as string;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new Response(null, { status: 200 }); // Spec says always return 200
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = await plugins.smarthash.sha256FromString(token);
|
||||||
|
|
||||||
|
// Try to revoke as refresh token
|
||||||
|
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
||||||
|
const refreshToken = this.refreshTokens.get(tokenHash);
|
||||||
|
if (refreshToken) {
|
||||||
|
refreshToken.revoked = true;
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to revoke as access token
|
||||||
|
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
||||||
|
if (this.accessTokens.has(tokenHash)) {
|
||||||
|
this.accessTokens.delete(tokenHash);
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token not found - still return 200 per spec
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an app by its OAuth client_id
|
||||||
|
*/
|
||||||
|
private async findAppByClientId(clientId: string): Promise<App | null> {
|
||||||
|
const apps = await this.receptionRef.appManager.CApp.getInstances({
|
||||||
|
'data.oauthCredentials.clientId': clientId,
|
||||||
|
});
|
||||||
|
return apps[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate S256 PKCE challenge from verifier
|
||||||
|
*/
|
||||||
|
private generateS256Challenge(verifier: string): string {
|
||||||
|
const hash = plugins.smarthash.sha256FromStringSync(verifier);
|
||||||
|
return Buffer.from(hash, 'hex').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error response for authorization endpoint
|
||||||
|
*/
|
||||||
|
private errorResponse(error: string, description: string): Response {
|
||||||
|
return new Response(JSON.stringify({ error, error_description: description }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error response for token endpoint
|
||||||
|
*/
|
||||||
|
private tokenErrorResponse(
|
||||||
|
error: plugins.idpInterfaces.data.ITokenErrorResponse['error'],
|
||||||
|
description: string
|
||||||
|
): Response {
|
||||||
|
const body: plugins.idpInterfaces.data.ITokenErrorResponse = {
|
||||||
|
error,
|
||||||
|
error_description: description,
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start cleanup task for expired tokens/codes
|
||||||
|
*/
|
||||||
|
private startCleanupTask(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Clean up expired authorization codes
|
||||||
|
for (const [code, data] of this.authorizationCodes) {
|
||||||
|
if (data.expiresAt < now) {
|
||||||
|
this.authorizationCodes.delete(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up expired access tokens
|
||||||
|
for (const [hash, data] of this.accessTokens) {
|
||||||
|
if (data.expiresAt < now) {
|
||||||
|
this.accessTokens.delete(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up expired refresh tokens
|
||||||
|
for (const [hash, data] of this.refreshTokens) {
|
||||||
|
if (data.expiresAt < now) {
|
||||||
|
this.refreshTokens.delete(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60 * 1000); // Run every minute
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,6 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
|
|||||||
|
|
||||||
public async checkIfUserIsAdmin(userArg: User) {
|
public async checkIfUserIsAdmin(userArg: User) {
|
||||||
const role = await this.manager.receptionRef.roleManager.getRoleForUserAndOrg(userArg, this);
|
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',
|
action: 'create',
|
||||||
organizationId: newOrg.id,
|
organizationId: newOrg.id,
|
||||||
userId: userData.id,
|
userId: userData.id,
|
||||||
role: 'admin',
|
roles: ['owner'],
|
||||||
});
|
});
|
||||||
newOrg.data.roleIds.push(role.id);
|
newOrg.data.roleIds.push(role.id);
|
||||||
await newOrg.save();
|
await newOrg.save();
|
||||||
return {
|
return {
|
||||||
nameAvailable: true,
|
nameAvailable: true,
|
||||||
resultingOrganization: await newOrg.createSavableObject()
|
resultingOrganization: await newOrg.createSavableObject(),
|
||||||
|
role: await role.createSavableObject(),
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import { RoleManager } from './classes.rolemanager.js';
|
|||||||
import { BillingPlanManager } from './classes.billingplanmanager.js';
|
import { BillingPlanManager } from './classes.billingplanmanager.js';
|
||||||
import { AppManager } from './classes.appmanager.js';
|
import { AppManager } from './classes.appmanager.js';
|
||||||
import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
||||||
|
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
||||||
|
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
||||||
|
import { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
export interface IReceptionOptions {
|
export interface IReceptionOptions {
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +48,9 @@ export class Reception {
|
|||||||
public billingPlanManager = new BillingPlanManager(this);
|
public billingPlanManager = new BillingPlanManager(this);
|
||||||
public appManager = new AppManager(this);
|
public appManager = new AppManager(this);
|
||||||
public appConnectionManager = new AppConnectionManager(this);
|
public appConnectionManager = new AppConnectionManager(this);
|
||||||
|
public activityLogManager = new ActivityLogManager(this);
|
||||||
|
public userInvitationManager = new UserInvitationManager(this);
|
||||||
|
public oidcManager = new OidcManager(this);
|
||||||
housekeeping = new ReceptionHousekeeping(this);
|
housekeeping = new ReceptionHousekeeping(this);
|
||||||
|
|
||||||
constructor(public options: IReceptionOptions) {
|
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;
|
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: {
|
public async modifyRoleForUserAtOrg(optionsArg: {
|
||||||
action: 'create' | 'change' | 'delete';
|
action: 'create' | 'change' | 'delete';
|
||||||
userId: string;
|
userId: string;
|
||||||
organizationId: 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;
|
let returnRole: Role;
|
||||||
|
|
||||||
|
// Support both old single role and new roles array
|
||||||
|
const roles = optionsArg.roles || (optionsArg.role ? [optionsArg.role] : ['viewer']);
|
||||||
|
|
||||||
switch (optionsArg.action) {
|
switch (optionsArg.action) {
|
||||||
case 'create':
|
case 'create':
|
||||||
returnRole = new this.CRole();
|
returnRole = new this.CRole();
|
||||||
@@ -29,9 +40,35 @@ export class RoleManager {
|
|||||||
returnRole.data = {
|
returnRole.data = {
|
||||||
userId: optionsArg.userId,
|
userId: optionsArg.userId,
|
||||||
organizationId: optionsArg.organizationId,
|
organizationId: optionsArg.organizationId,
|
||||||
role: optionsArg.role,
|
roles: roles,
|
||||||
};
|
};
|
||||||
await returnRole.save();
|
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;
|
return returnRole;
|
||||||
}
|
}
|
||||||
@@ -54,4 +91,13 @@ export class RoleManager {
|
|||||||
});
|
});
|
||||||
return roles;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# @idp.global/cli
|
||||||
|
|
||||||
|
Command-line interface for interacting with the idp.global Identity Provider. A Node.js CLI tool that provides authentication, user management, and organization administration from the terminal.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The IdpCli module provides a complete command-line interface for managing your idp.global account and organizations. It uses file-based credential storage and WebSocket connections for real-time communication with the IdP server.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @idp.global/cli
|
||||||
|
# or
|
||||||
|
pnpm add -g @idp.global/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login with email and password
|
||||||
|
idp login
|
||||||
|
|
||||||
|
# Check current user
|
||||||
|
idp whoami
|
||||||
|
|
||||||
|
# List your organizations
|
||||||
|
idp orgs
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
idp logout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `idp login` | Interactive login with email and password |
|
||||||
|
| `idp login-token` | Login with an API token |
|
||||||
|
| `idp logout` | Clear stored credentials and end session |
|
||||||
|
|
||||||
|
### User Information
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `idp whoami` | Display current user information |
|
||||||
|
| `idp sessions` | List all active sessions |
|
||||||
|
| `idp revoke --session <id>` | Revoke a specific session |
|
||||||
|
|
||||||
|
### Organization Management
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `idp orgs` | List all organizations you belong to |
|
||||||
|
| `idp orgs-create` | Create a new organization (interactive) |
|
||||||
|
| `idp members --org <id>` | List members of an organization |
|
||||||
|
| `idp invite --org <id> --email <email>` | Invite a user to an organization |
|
||||||
|
|
||||||
|
### Admin Commands (Global Admins Only)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `idp admin-check` | Check if you are a global admin |
|
||||||
|
| `idp admin-apps` | List all global apps with connection stats |
|
||||||
|
| `idp admin-suspend --user <id>` | Suspend a user account |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `IDP_URL` | Override the IdP server URL | `https://idp.global` |
|
||||||
|
|
||||||
|
### Credential Storage
|
||||||
|
|
||||||
|
Credentials are stored in `~/.idp-global/credentials.json`. This file contains your refresh token and JWT for persistent authentication across CLI sessions.
|
||||||
|
|
||||||
|
## Programmatic Usage
|
||||||
|
|
||||||
|
You can also use the IdpCli class programmatically:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { IdpCli } from '@idp.global/cli';
|
||||||
|
|
||||||
|
const cli = new IdpCli({
|
||||||
|
idpBaseUrl: 'https://idp.global',
|
||||||
|
configDir: '/custom/config/path', // optional
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await cli.loginWithPassword('user@example.com', 'password');
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
const user = await cli.whoami();
|
||||||
|
console.log('Logged in as:', user.data.name);
|
||||||
|
|
||||||
|
// Get organizations
|
||||||
|
const { organizations, roles } = await cli.getOrganizations();
|
||||||
|
for (const org of organizations) {
|
||||||
|
console.log(`- ${org.data.name} (${org.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect when done
|
||||||
|
await cli.disconnect();
|
||||||
|
```
|
||||||
|
|
||||||
|
### IdpCli Class Methods
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
- `loginWithPassword(email, password)` - Login with credentials
|
||||||
|
- `loginWithApiToken(token)` - Login with API token
|
||||||
|
- `refreshJwt()` - Refresh the current JWT
|
||||||
|
- `logout()` - Clear credentials and end session
|
||||||
|
|
||||||
|
**User:**
|
||||||
|
- `whoami()` - Get current user info
|
||||||
|
- `getSessions()` - Get active sessions
|
||||||
|
- `revokeSession(sessionId)` - Revoke a session
|
||||||
|
|
||||||
|
**Organizations:**
|
||||||
|
- `getOrganizations()` - List user's organizations
|
||||||
|
- `createOrganization(name, slug, mode)` - Create new organization
|
||||||
|
- `getOrgMembers(orgId)` - Get organization members
|
||||||
|
- `inviteMember(orgId, email, roles)` - Invite a user
|
||||||
|
|
||||||
|
**Admin:**
|
||||||
|
- `checkGlobalAdmin()` - Check admin status
|
||||||
|
- `getGlobalAppStats()` - Get app statistics
|
||||||
|
- `suspendUser(userId)` - Suspend a user
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Create an Organization
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ idp orgs-create
|
||||||
|
Organization Name: My Company
|
||||||
|
Organization Slug: my-company
|
||||||
|
|
||||||
|
Organization created successfully!
|
||||||
|
ID: org_abc123
|
||||||
|
Name: My Company
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invite Team Members
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ idp invite --org org_abc123 --email colleague@example.com
|
||||||
|
Invitation sent to colleague@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Active Sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ idp sessions
|
||||||
|
|
||||||
|
Active Sessions:
|
||||||
|
- sess_xyz789
|
||||||
|
Device: MacBook Pro
|
||||||
|
Browser: Chrome
|
||||||
|
OS: macOS
|
||||||
|
Last Active: 1/29/2025, 2:30:00 PM
|
||||||
|
Current: Yes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@api.global/typedrequest` - Type-safe API requests
|
||||||
|
- `@api.global/typedsocket` - WebSocket communication
|
||||||
|
- `@push.rocks/smartcli` - CLI framework
|
||||||
|
- `@push.rocks/smartinteract` - Interactive prompts
|
||||||
|
- `@idp.global/interfaces` - TypeScript interfaces
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This 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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By 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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "@idp.global/cli",
|
||||||
|
"order": 4
|
||||||
|
}
|
||||||
@@ -126,9 +126,9 @@ export class IdpClient {
|
|||||||
if (!refreshTokenArg) {
|
if (!refreshTokenArg) {
|
||||||
extractedJwt = await this.helpers.extractDataFromJwtString(await this.getJwt());
|
extractedJwt = await this.helpers.extractDataFromJwtString(await this.getJwt());
|
||||||
}
|
}
|
||||||
|
await this.typedsocketDeferred.promise;
|
||||||
const refreshJwtReq =
|
const refreshJwtReq =
|
||||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||||
this.parsedReceptionUrl.toString(),
|
|
||||||
'refreshJwt'
|
'refreshJwt'
|
||||||
);
|
);
|
||||||
const response = await refreshJwtReq.fire({
|
const response = await refreshJwtReq.fire({
|
||||||
@@ -149,9 +149,9 @@ export class IdpClient {
|
|||||||
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string> {
|
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string> {
|
||||||
const jwt = await this.performJwtHousekeeping();
|
const jwt = await this.performJwtHousekeeping();
|
||||||
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
|
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
|
||||||
|
await this.typedsocketDeferred.promise;
|
||||||
const getTransferToken =
|
const getTransferToken =
|
||||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||||
this.parsedReceptionUrl.toString(),
|
|
||||||
'exchangeRefreshTokenAndTransferToken'
|
'exchangeRefreshTokenAndTransferToken'
|
||||||
);
|
);
|
||||||
const response = await getTransferToken.fire({
|
const response = await getTransferToken.fire({
|
||||||
@@ -187,9 +187,9 @@ export class IdpClient {
|
|||||||
const url = plugins.smarturl.Smarturl.createFromUrl(href);
|
const url = plugins.smarturl.Smarturl.createFromUrl(href);
|
||||||
const transferToken = url.searchParams['transfertoken'];
|
const transferToken = url.searchParams['transfertoken'];
|
||||||
if (transferToken) {
|
if (transferToken) {
|
||||||
|
await this.typedsocketDeferred.promise;
|
||||||
const getTransferToken =
|
const getTransferToken =
|
||||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||||
this.parsedReceptionUrl.toString(),
|
|
||||||
'exchangeRefreshTokenAndTransferToken'
|
'exchangeRefreshTokenAndTransferToken'
|
||||||
);
|
);
|
||||||
const response = await getTransferToken.fire({
|
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
|
* this class bundles all the typed requests that are used by the idp
|
||||||
|
* All requests use TypedSocket (WebSocket) transport
|
||||||
*/
|
*/
|
||||||
export class IdpRequests {
|
export class IdpRequests {
|
||||||
idpClientArg: IdpClient;
|
idpClientArg: IdpClient;
|
||||||
@@ -11,52 +12,315 @@ export class IdpRequests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public get afterRegistrationEmailClicked () {
|
public get afterRegistrationEmailClicked () {
|
||||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
|
||||||
'afterRegistrationEmailClicked'
|
'afterRegistrationEmailClicked'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get setData() {
|
public get setData() {
|
||||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
|
||||||
'setDataForRegistration'
|
'setDataForRegistration'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get mobileNumberVerification () {
|
public get mobileNumberVerification () {
|
||||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
|
||||||
'mobileVerificationForRegistration'
|
'mobileVerificationForRegistration'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public get finishRegistration() {
|
public get finishRegistration() {
|
||||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
|
||||||
'finishRegistration'
|
'finishRegistration'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get loginWithUserNameAndPassword () {
|
public get loginWithUserNameAndPassword () {
|
||||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
|
||||||
'loginWithEmailOrUsernameAndPassword'
|
'loginWithEmailOrUsernameAndPassword'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get obtainJwt () {
|
public get obtainJwt () {
|
||||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
|
||||||
'refreshJwt'
|
'refreshJwt'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get obtainOneTimeToken () {
|
public get obtainOneTimeToken () {
|
||||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
|
||||||
'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,389 @@
|
|||||||
|
# @idp.global/idpclient
|
||||||
|
|
||||||
|
A TypeScript client library for integrating with the idp.global Identity Provider. Works in both browser and Node.js environments.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The IdpClient provides a complete API for authentication, session management, and organization operations. It uses WebSocket connections via TypedSocket for real-time, type-safe communication with the IdP server.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @idp.global/idpclient
|
||||||
|
# or
|
||||||
|
pnpm add @idp.global/idpclient
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { IdpClient } from '@idp.global/idpclient';
|
||||||
|
|
||||||
|
// Initialize the client
|
||||||
|
const idpClient = new IdpClient('https://idp.global');
|
||||||
|
|
||||||
|
// Enable WebSocket connection
|
||||||
|
await idpClient.enableTypedSocket();
|
||||||
|
|
||||||
|
// Check login status
|
||||||
|
const isLoggedIn = await idpClient.determineLoginStatus();
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
const userInfo = await idpClient.whoIs();
|
||||||
|
console.log('Logged in as:', userInfo.user.data.name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
#### Password Login
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
|
||||||
|
username: 'user@example.com',
|
||||||
|
password: 'securepassword',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.refreshToken) {
|
||||||
|
await idpClient.refreshJwt(response.refreshToken);
|
||||||
|
console.log('Login successful!');
|
||||||
|
} else if (response.twoFaNeeded) {
|
||||||
|
console.log('2FA verification required');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Magic Link Login
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request magic link
|
||||||
|
await idpClient.requests.loginWithEmail.fire({
|
||||||
|
email: 'user@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
// After clicking the email link
|
||||||
|
const result = await idpClient.requests.loginWithEmailAfterToken.fire({
|
||||||
|
email: 'user@example.com',
|
||||||
|
token: 'token-from-email-link',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.refreshToken) {
|
||||||
|
await idpClient.refreshJwt(result.refreshToken);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API Token Login
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await idpClient.requests.loginWithApiToken.fire({
|
||||||
|
apiToken: 'your-api-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.jwt) {
|
||||||
|
await idpClient.setJwt(result.jwt);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get current JWT
|
||||||
|
const jwt = await idpClient.getJwt();
|
||||||
|
|
||||||
|
// Get parsed JWT data
|
||||||
|
const jwtData = await idpClient.getJwtData();
|
||||||
|
console.log('User ID:', jwtData.id);
|
||||||
|
|
||||||
|
// Refresh JWT (automatic housekeeping)
|
||||||
|
await idpClient.performJwtHousekeeping();
|
||||||
|
|
||||||
|
// Manual refresh
|
||||||
|
await idpClient.refreshJwt();
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
await idpClient.logout();
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Information
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get current user details
|
||||||
|
const whoIsResponse = await idpClient.whoIs();
|
||||||
|
console.log('Name:', whoIsResponse.user.data.name);
|
||||||
|
console.log('Email:', whoIsResponse.user.data.email);
|
||||||
|
|
||||||
|
// Get user data
|
||||||
|
const userData = await idpClient.requests.getUserData.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
userId: jwtData.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user data
|
||||||
|
await idpClient.requests.setUserData.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
userId: jwtData.id,
|
||||||
|
name: 'New Name',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Organization Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get user's organizations and roles
|
||||||
|
const orgsAndRoles = await idpClient.getRolesAndOrganizations();
|
||||||
|
console.log('Organizations:', orgsAndRoles.organizations);
|
||||||
|
console.log('Roles:', orgsAndRoles.roles);
|
||||||
|
|
||||||
|
// Create a new organization
|
||||||
|
const result = await idpClient.createOrganization(
|
||||||
|
'My Company', // name
|
||||||
|
'my-company', // slug
|
||||||
|
'manifest' // mode: 'checkAvailability' or 'manifest'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.resultingOrganization) {
|
||||||
|
console.log('Created:', result.resultingOrganization.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get organization details
|
||||||
|
const orgDetails = await idpClient.requests.getOrganizationById.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: 'org-id',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Member & Invitation Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get organization members
|
||||||
|
const members = await idpClient.requests.getOrgMembers.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: 'org-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invite a new member
|
||||||
|
await idpClient.requests.createInvitation.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: 'org-id',
|
||||||
|
email: 'newmember@example.com',
|
||||||
|
roles: ['member'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk invite members
|
||||||
|
await idpClient.requests.bulkCreateInvitations.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: 'org-id',
|
||||||
|
invitations: [
|
||||||
|
{ email: 'user1@example.com', roles: ['member'] },
|
||||||
|
{ email: 'user2@example.com', roles: ['admin'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept an invitation
|
||||||
|
await idpClient.requests.acceptInvitation.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
invitationToken: 'token-from-invite-email',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove a member
|
||||||
|
await idpClient.requests.removeMember.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: 'org-id',
|
||||||
|
userId: 'user-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transfer ownership
|
||||||
|
await idpClient.requests.transferOwnership.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: 'org-id',
|
||||||
|
newOwnerId: 'new-owner-user-id',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Password Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request password reset
|
||||||
|
await idpClient.requests.resetPassword.fire({
|
||||||
|
email: 'user@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set new password (with token from email)
|
||||||
|
await idpClient.requests.setNewPassword.fire({
|
||||||
|
email: 'user@example.com',
|
||||||
|
tokenArg: 'reset-token',
|
||||||
|
newPassword: 'newsecurepassword',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change password (when logged in)
|
||||||
|
await idpClient.requests.setNewPassword.fire({
|
||||||
|
email: 'user@example.com',
|
||||||
|
oldPassword: 'currentpassword',
|
||||||
|
newPassword: 'newsecurepassword',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session & Device Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get active sessions
|
||||||
|
const sessions = await idpClient.requests.getUserSessions.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
userId: jwtData.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke a session
|
||||||
|
await idpClient.requests.revokeSession.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
sessionId: 'session-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get device ID
|
||||||
|
const deviceInfo = await idpClient.requests.obtainDeviceId.fire({});
|
||||||
|
|
||||||
|
// Attach device to session
|
||||||
|
await idpClient.requests.attachDeviceId.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
deviceId: deviceInfo.deviceId.id,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Domain Authentication
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get transfer token for SSO between apps
|
||||||
|
const transferToken = await idpClient.getTransferToken();
|
||||||
|
|
||||||
|
// Switch to another app with authentication
|
||||||
|
await idpClient.getTransferTokenAndSwitchToLocation('https://app.example.com/');
|
||||||
|
|
||||||
|
// Process incoming transfer token (in target app)
|
||||||
|
const success = await idpClient.processTransferToken();
|
||||||
|
if (success) {
|
||||||
|
console.log('Cross-domain login successful');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Billing Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get billing plan for an organization
|
||||||
|
const billingPlan = await idpClient.requests.getBillingPlan.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
organizationId: 'org-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Paddle configuration
|
||||||
|
const paddleConfig = await idpClient.requests.getPaddleConfig.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update payment method
|
||||||
|
await idpClient.updatePaddleCheckoutId('org-id', 'checkout-id');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Operations (Global Admins Only)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Check if user is global admin
|
||||||
|
const isAdmin = await idpClient.requests.checkGlobalAdmin.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get platform statistics
|
||||||
|
const stats = await idpClient.requests.getGlobalAppStats.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a global app
|
||||||
|
await idpClient.requests.createGlobalApp.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
name: 'My App',
|
||||||
|
description: 'App description',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suspend a user
|
||||||
|
await idpClient.requests.suspendUser.fire({
|
||||||
|
jwt: await idpClient.getJwt(),
|
||||||
|
userId: 'user-id',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reactive Subscriptions
|
||||||
|
|
||||||
|
The client provides RxJS subjects for reactive updates:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscribe to login status changes
|
||||||
|
idpClient.statusObservable.subscribe((status) => {
|
||||||
|
console.log('Login status changed:', status);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to roles updates
|
||||||
|
idpClient.rolesReplaySubject.subscribe((roles) => {
|
||||||
|
console.log('Roles updated:', roles);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to organizations updates
|
||||||
|
idpClient.organizationsReplaySubject.subscribe((orgs) => {
|
||||||
|
console.log('Organizations updated:', orgs);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### IdpClient Class
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `enableTypedSocket()` | Initialize WebSocket connection |
|
||||||
|
| `determineLoginStatus(requireLogin?)` | Check if user is logged in |
|
||||||
|
| `getJwt()` | Get stored JWT string |
|
||||||
|
| `getJwtData()` | Get parsed JWT data |
|
||||||
|
| `setJwt(jwt)` | Store JWT |
|
||||||
|
| `deleteJwt()` | Remove stored JWT |
|
||||||
|
| `refreshJwt(refreshToken?)` | Refresh the JWT |
|
||||||
|
| `performJwtHousekeeping()` | Auto-refresh JWT if needed |
|
||||||
|
| `logout()` | End session and redirect |
|
||||||
|
| `whoIs()` | Get current user info |
|
||||||
|
| `getRolesAndOrganizations()` | Get user's orgs and roles |
|
||||||
|
| `createOrganization(name, slug, mode)` | Create new organization |
|
||||||
|
| `getTransferToken(appData?)` | Get SSO transfer token |
|
||||||
|
| `processTransferToken()` | Process incoming transfer token |
|
||||||
|
| `stop()` | Close WebSocket connection |
|
||||||
|
|
||||||
|
### IdpRequests Class
|
||||||
|
|
||||||
|
Access via `idpClient.requests.*`:
|
||||||
|
|
||||||
|
**Authentication**: `loginWithUserNameAndPassword`, `loginWithEmail`, `loginWithEmailAfterToken`, `loginWithApiToken`, `resetPassword`, `setNewPassword`
|
||||||
|
|
||||||
|
**User**: `getUserData`, `setUserData`, `getUserSessions`, `revokeSession`, `getUserActivity`
|
||||||
|
|
||||||
|
**Organization**: `getOrganizationById`, `updateOrganization`, `createInvitation`, `bulkCreateInvitations`, `getOrgMembers`, `getOrgInvitations`, `acceptInvitation`, `cancelInvitation`, `resendInvitation`, `removeMember`, `updateMemberRoles`, `transferOwnership`
|
||||||
|
|
||||||
|
**Billing**: `getBillingPlan`, `getPaddleConfig`
|
||||||
|
|
||||||
|
**Admin**: `checkGlobalAdmin`, `getGlobalAppStats`, `createGlobalApp`, `updateGlobalApp`, `deleteGlobalApp`, `suspendUser`, `deleteSuspendedUser`
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This 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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By 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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "@idp.global/client",
|
||||||
|
"order": 3
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
export * from './loint-reception.activity.js';
|
||||||
export * from './loint-reception.app.js';
|
export * from './loint-reception.app.js';
|
||||||
|
export * from './loint-reception.oidc.js';
|
||||||
export * from './loint-reception.appconnection.js';
|
export * from './loint-reception.appconnection.js';
|
||||||
export * from './loint-reception.billingplan.js';
|
export * from './loint-reception.billingplan.js';
|
||||||
export * from './loint-reception.device.js';
|
export * from './loint-reception.device.js';
|
||||||
@@ -8,3 +10,4 @@ export * from './loint-reception.organization.js';
|
|||||||
export * from './loint-reception.paddlecheckoutdata.js';
|
export * from './loint-reception.paddlecheckoutdata.js';
|
||||||
export * from './loint-reception.role.js';
|
export * from './loint-reception.role.js';
|
||||||
export * from './loint-reception.user.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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,5 +10,22 @@ export interface ILoginSession {
|
|||||||
* in different contexts on the same device
|
* in different contexts on the same device
|
||||||
*/
|
*/
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
/**
|
||||||
|
* Device metadata for session display
|
||||||
|
*/
|
||||||
|
deviceInfo?: {
|
||||||
|
deviceName: string;
|
||||||
|
browser: string;
|
||||||
|
os: string;
|
||||||
|
ip: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* When this session was created
|
||||||
|
*/
|
||||||
|
createdAt?: number;
|
||||||
|
/**
|
||||||
|
* Last time this session was active (e.g., refreshed)
|
||||||
|
*/
|
||||||
|
lastActive?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* OIDC (OpenID Connect) data interfaces for third-party client support
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported OIDC scopes
|
||||||
|
*/
|
||||||
|
export type TOidcScope = 'openid' | 'profile' | 'email' | 'organizations' | 'roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization code for OAuth 2.0 authorization code flow
|
||||||
|
*/
|
||||||
|
export interface IAuthorizationCode {
|
||||||
|
/** The authorization code string */
|
||||||
|
code: string;
|
||||||
|
/** OAuth client ID */
|
||||||
|
clientId: string;
|
||||||
|
/** User ID who authorized */
|
||||||
|
userId: string;
|
||||||
|
/** Scopes granted */
|
||||||
|
scopes: TOidcScope[];
|
||||||
|
/** Redirect URI used in authorization request */
|
||||||
|
redirectUri: string;
|
||||||
|
/** PKCE code challenge (S256 hashed) */
|
||||||
|
codeChallenge?: string;
|
||||||
|
/** PKCE code challenge method */
|
||||||
|
codeChallengeMethod?: 'S256';
|
||||||
|
/** Nonce from authorization request (for ID token) */
|
||||||
|
nonce?: string;
|
||||||
|
/** Expiration timestamp (10 minutes from creation) */
|
||||||
|
expiresAt: number;
|
||||||
|
/** Whether the code has been used (single-use) */
|
||||||
|
used: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC Access Token (opaque or JWT)
|
||||||
|
*/
|
||||||
|
export interface IOidcAccessToken {
|
||||||
|
/** Token identifier */
|
||||||
|
id: string;
|
||||||
|
/** The access token string (or hash for storage) */
|
||||||
|
tokenHash: string;
|
||||||
|
/** OAuth client ID */
|
||||||
|
clientId: string;
|
||||||
|
/** User ID */
|
||||||
|
userId: string;
|
||||||
|
/** Granted scopes */
|
||||||
|
scopes: TOidcScope[];
|
||||||
|
/** Expiration timestamp */
|
||||||
|
expiresAt: number;
|
||||||
|
/** Creation timestamp */
|
||||||
|
issuedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC Refresh Token
|
||||||
|
*/
|
||||||
|
export interface IOidcRefreshToken {
|
||||||
|
/** Token identifier */
|
||||||
|
id: string;
|
||||||
|
/** The refresh token string (or hash for storage) */
|
||||||
|
tokenHash: string;
|
||||||
|
/** OAuth client ID */
|
||||||
|
clientId: string;
|
||||||
|
/** User ID */
|
||||||
|
userId: string;
|
||||||
|
/** Granted scopes */
|
||||||
|
scopes: TOidcScope[];
|
||||||
|
/** Expiration timestamp */
|
||||||
|
expiresAt: number;
|
||||||
|
/** Creation timestamp */
|
||||||
|
issuedAt: number;
|
||||||
|
/** Whether the token has been revoked */
|
||||||
|
revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User consent record for an OAuth client
|
||||||
|
*/
|
||||||
|
export interface IUserConsent {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string;
|
||||||
|
/** User who gave consent */
|
||||||
|
userId: string;
|
||||||
|
/** OAuth client ID */
|
||||||
|
clientId: string;
|
||||||
|
/** Scopes the user consented to */
|
||||||
|
scopes: TOidcScope[];
|
||||||
|
/** When consent was granted */
|
||||||
|
grantedAt: number;
|
||||||
|
/** When consent was last updated */
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OIDC Discovery Document (OpenID Provider Configuration)
|
||||||
|
*/
|
||||||
|
export interface IOidcDiscoveryDocument {
|
||||||
|
issuer: string;
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
userinfo_endpoint: string;
|
||||||
|
jwks_uri: string;
|
||||||
|
revocation_endpoint: string;
|
||||||
|
scopes_supported: TOidcScope[];
|
||||||
|
response_types_supported: string[];
|
||||||
|
grant_types_supported: string[];
|
||||||
|
subject_types_supported: string[];
|
||||||
|
id_token_signing_alg_values_supported: string[];
|
||||||
|
token_endpoint_auth_methods_supported: string[];
|
||||||
|
code_challenge_methods_supported: string[];
|
||||||
|
claims_supported: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Web Key Set (JWKS) response
|
||||||
|
*/
|
||||||
|
export interface IJwks {
|
||||||
|
keys: IJwk[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Web Key (RSA public key)
|
||||||
|
*/
|
||||||
|
export interface IJwk {
|
||||||
|
kty: 'RSA';
|
||||||
|
use: 'sig';
|
||||||
|
alg: 'RS256';
|
||||||
|
kid: string;
|
||||||
|
n: string; // RSA modulus (base64url encoded)
|
||||||
|
e: string; // RSA exponent (base64url encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID Token claims (JWT payload)
|
||||||
|
*/
|
||||||
|
export interface IIdTokenClaims {
|
||||||
|
/** Issuer (idp.global URL) */
|
||||||
|
iss: string;
|
||||||
|
/** Subject (user ID) */
|
||||||
|
sub: string;
|
||||||
|
/** Audience (client ID) */
|
||||||
|
aud: string;
|
||||||
|
/** Expiration time (Unix timestamp) */
|
||||||
|
exp: number;
|
||||||
|
/** Issued at (Unix timestamp) */
|
||||||
|
iat: number;
|
||||||
|
/** Authentication time (Unix timestamp) */
|
||||||
|
auth_time?: number;
|
||||||
|
/** Nonce (if provided in authorization request) */
|
||||||
|
nonce?: string;
|
||||||
|
/** Access token hash (for hybrid flows) */
|
||||||
|
at_hash?: string;
|
||||||
|
|
||||||
|
// Profile scope claims
|
||||||
|
name?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
picture?: string;
|
||||||
|
|
||||||
|
// Email scope claims
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
|
||||||
|
// Custom claims for organizations scope
|
||||||
|
organizations?: IOrganizationClaim[];
|
||||||
|
|
||||||
|
// Custom claims for roles scope
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organization claim in ID token / userinfo
|
||||||
|
*/
|
||||||
|
export interface IOrganizationClaim {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserInfo endpoint response
|
||||||
|
*/
|
||||||
|
export interface IUserInfoResponse {
|
||||||
|
/** Subject (user ID) - always included */
|
||||||
|
sub: string;
|
||||||
|
|
||||||
|
// Profile scope
|
||||||
|
name?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
picture?: string;
|
||||||
|
|
||||||
|
// Email scope
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
|
||||||
|
// Organizations scope (custom)
|
||||||
|
organizations?: IOrganizationClaim[];
|
||||||
|
|
||||||
|
// Roles scope (custom)
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token endpoint response
|
||||||
|
*/
|
||||||
|
export interface ITokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: 'Bearer';
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
id_token?: string;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token endpoint error response
|
||||||
|
*/
|
||||||
|
export interface ITokenErrorResponse {
|
||||||
|
error: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | 'unsupported_grant_type' | 'invalid_scope';
|
||||||
|
error_description?: string;
|
||||||
|
error_uri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization request parameters
|
||||||
|
*/
|
||||||
|
export interface IAuthorizationRequest {
|
||||||
|
client_id: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
response_type: 'code';
|
||||||
|
scope: string;
|
||||||
|
state: string;
|
||||||
|
code_challenge?: string;
|
||||||
|
code_challenge_method?: 'S256';
|
||||||
|
nonce?: string;
|
||||||
|
prompt?: 'none' | 'login' | 'consent';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token request for authorization_code grant
|
||||||
|
*/
|
||||||
|
export interface ITokenRequestAuthCode {
|
||||||
|
grant_type: 'authorization_code';
|
||||||
|
code: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
client_id: string;
|
||||||
|
client_secret?: string;
|
||||||
|
code_verifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token request for refresh_token grant
|
||||||
|
*/
|
||||||
|
export interface ITokenRequestRefresh {
|
||||||
|
grant_type: 'refresh_token';
|
||||||
|
refresh_token: string;
|
||||||
|
client_id: string;
|
||||||
|
client_secret?: string;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for token requests
|
||||||
|
*/
|
||||||
|
export type ITokenRequest = ITokenRequestAuthCode | ITokenRequestRefresh;
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
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 {
|
export interface IRole {
|
||||||
id: string;
|
id: string;
|
||||||
data: {
|
data: {
|
||||||
userId: string;
|
userId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
role: 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
/** Array of roles - supports standard roles and custom role names */
|
||||||
|
roles: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# @idp.global/interfaces
|
||||||
|
|
||||||
|
TypeScript interfaces and type definitions for the idp.global Identity Provider platform.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This package provides the complete type system for idp.global, including data models, API request/response interfaces, and OIDC definitions. Use this package when building applications that integrate with idp.global or when you need type-safe interactions with the IdP API.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @idp.global/interfaces
|
||||||
|
# or
|
||||||
|
pnpm add @idp.global/interfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { data, request, tags } from '@idp.global/interfaces';
|
||||||
|
|
||||||
|
// Data interfaces
|
||||||
|
const user: data.IUser = {
|
||||||
|
id: 'user_123',
|
||||||
|
data: {
|
||||||
|
name: 'John Doe',
|
||||||
|
username: 'johndoe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: ['org_1', 'org_2'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Organization interface
|
||||||
|
const org: data.IOrganization = {
|
||||||
|
id: 'org_1',
|
||||||
|
data: {
|
||||||
|
name: 'Acme Corp',
|
||||||
|
slug: 'acme',
|
||||||
|
billingPlanId: 'plan_free',
|
||||||
|
roleIds: ['role_admin', 'role_member'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ts_interfaces/
|
||||||
|
├── data/ # Data model interfaces
|
||||||
|
│ ├── loint-reception.user.ts # User profiles
|
||||||
|
│ ├── loint-reception.organization.ts # Organizations
|
||||||
|
│ ├── loint-reception.role.ts # RBAC roles
|
||||||
|
│ ├── loint-reception.app.ts # OAuth applications
|
||||||
|
│ ├── loint-reception.oidc.ts # OIDC tokens & flows
|
||||||
|
│ ├── loint-reception.jwt.ts # JWT structures
|
||||||
|
│ ├── loint-reception.loginsession.ts # Login sessions
|
||||||
|
│ ├── loint-reception.billingplan.ts # Billing plans
|
||||||
|
│ ├── loint-reception.device.ts # Device management
|
||||||
|
│ ├── loint-reception.activity.ts # Activity logs
|
||||||
|
│ ├── loint-reception.userinvitation.ts # Invitations
|
||||||
|
│ └── loint-reception.appconnection.ts # App connections
|
||||||
|
├── request/ # API request/response interfaces
|
||||||
|
│ ├── loint-reception.login.ts # Authentication
|
||||||
|
│ ├── loint-reception.registration.ts # User registration
|
||||||
|
│ ├── loint-reception.user.ts # User management
|
||||||
|
│ ├── loint-reception.organization.ts # Org management
|
||||||
|
│ ├── loint-reception.jwt.ts # JWT operations
|
||||||
|
│ ├── loint-reception.apitoken.ts # API tokens
|
||||||
|
│ ├── loint-reception.app.ts # App management
|
||||||
|
│ ├── loint-reception.billingplan.ts # Billing
|
||||||
|
│ └── loint-reception.admin.ts # Admin operations
|
||||||
|
└── tags/ # Tag definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Interfaces
|
||||||
|
|
||||||
|
### User (`IUser`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IUser {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
mobileNumber?: string;
|
||||||
|
password?: string; // Only during initial setting
|
||||||
|
passwordHash?: string; // For validation
|
||||||
|
status: 'new' | 'active' | 'deleted' | 'suspended';
|
||||||
|
connectedOrgs: string[]; // Organization IDs
|
||||||
|
isGlobalAdmin?: boolean; // Platform admin flag
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Organization (`IOrganization`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IOrganization {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
billingPlanId: string;
|
||||||
|
roleIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role (`IRole`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IRole {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Application Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Global platform apps (maintained by platform admins)
|
||||||
|
interface IGlobalApp {
|
||||||
|
id: string;
|
||||||
|
type: 'globalApp';
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
iconBase64?: string;
|
||||||
|
oauthCredentials?: IOAuthCredentials;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partner apps (third-party integrations)
|
||||||
|
interface IPartnerApp {
|
||||||
|
id: string;
|
||||||
|
type: 'partnerApp';
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
ownerOrganizationId: string;
|
||||||
|
oauthCredentials?: IOAuthCredentials;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom OIDC clients
|
||||||
|
interface ICustomOidcApp {
|
||||||
|
id: string;
|
||||||
|
type: 'customOidcApp';
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
ownerOrganizationId: string;
|
||||||
|
oauthCredentials: IOAuthCredentials;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Credentials
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IOAuthCredentials {
|
||||||
|
clientId: string;
|
||||||
|
clientSecretHash: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
scopes: string[];
|
||||||
|
grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## OIDC Interfaces
|
||||||
|
|
||||||
|
### Authorization Code
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IAuthorizationCode {
|
||||||
|
code: string;
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
scopes: string[];
|
||||||
|
redirectUri: string;
|
||||||
|
codeChallenge?: string;
|
||||||
|
codeChallengeMethod?: 'S256';
|
||||||
|
expiresAt: number;
|
||||||
|
used: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Response
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ITokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: 'Bearer';
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
id_token?: string;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UserInfo Response
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IUserInfoResponse {
|
||||||
|
sub: string;
|
||||||
|
name?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
organizations?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
roles: string[];
|
||||||
|
}>;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ID Token Claims
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IIdTokenClaims {
|
||||||
|
iss: string; // Issuer
|
||||||
|
sub: string; // Subject (user ID)
|
||||||
|
aud: string; // Audience (client ID)
|
||||||
|
exp: number; // Expiration time
|
||||||
|
iat: number; // Issued at
|
||||||
|
nonce?: string; // Replay protection
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
organizations?: Array<{...}>;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Interfaces
|
||||||
|
|
||||||
|
All API requests follow the TypedRequest pattern:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IReq_LoginWithEmailOrUsernameAndPassword {
|
||||||
|
method: 'loginWithEmailOrUsernameAndPassword';
|
||||||
|
request: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
refreshToken?: string;
|
||||||
|
twoFaNeeded: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Requests
|
||||||
|
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_LoginWithEmailOrUsernameAndPassword` | `loginWithEmailOrUsernameAndPassword` | Password login |
|
||||||
|
| `IReq_LoginWithEmail` | `loginWithEmail` | Magic link request |
|
||||||
|
| `IReq_LoginWithEmailAfterEmailTokenAquired` | `loginWithEmailAfterEmailTokenAquired` | Magic link verification |
|
||||||
|
| `IReq_LoginWithApiToken` | `loginWithApiToken` | API token login |
|
||||||
|
| `IReq_RefreshJwt` | `refreshJwt` | Refresh access token |
|
||||||
|
| `ILogoutRequest` | `logout` | End session |
|
||||||
|
|
||||||
|
### User Management Requests
|
||||||
|
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetUserData` | `getUserData` | Get current user |
|
||||||
|
| `IReq_SetUserData` | `setUserData` | Update user profile |
|
||||||
|
| `IReq_GetUserSessions` | `getUserSessions` | List active sessions |
|
||||||
|
| `IReq_ResetPassword` | `resetPassword` | Request password reset |
|
||||||
|
| `IReq_SetNewPassword` | `setNewPassword` | Set new password |
|
||||||
|
|
||||||
|
### Organization Requests
|
||||||
|
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_CreateOrganization` | `createOrganization` | Create new org |
|
||||||
|
| `IReq_GetOrgMembers` | `getOrgMembers` | List org members |
|
||||||
|
| `IReq_CreateInvitation` | `createInvitation` | Invite user |
|
||||||
|
| `IReq_AcceptInvitation` | `acceptInvitation` | Accept invite |
|
||||||
|
|
||||||
|
### JWT Operations
|
||||||
|
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetPublicKeyForValidation` | `getPublicKeyForValidation` | Get JWT public key |
|
||||||
|
| `IReq_GetJwtIdBlocklist` | `getJwtIdBlocklist` | Get revoked token IDs |
|
||||||
|
|
||||||
|
## Supported OIDC Scopes
|
||||||
|
|
||||||
|
| Scope | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `openid` | Required for OIDC flows |
|
||||||
|
| `profile` | User's name and username |
|
||||||
|
| `email` | User's email address |
|
||||||
|
| `organizations` | User's organization memberships |
|
||||||
|
| `roles` | User's roles within organizations |
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This 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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By 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.
|
||||||
@@ -9,3 +9,4 @@ export * from './loint-reception.organization.js';
|
|||||||
export * from './loint-reception.plan.js';
|
export * from './loint-reception.plan.js';
|
||||||
export * from './loint-reception.registration.js';
|
export * from './loint-reception.registration.js';
|
||||||
export * from './loint-reception.user.js';
|
export * from './loint-reception.user.js';
|
||||||
|
export * from './loint-reception.userinvitation.js';
|
||||||
|
|||||||
@@ -37,3 +37,19 @@ export interface IReq_GetBillingPlan
|
|||||||
billingPlan: data.IBillingPlan;
|
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 data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.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
|
export interface IReq_GetPublicKeyForValidation
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
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
|
export interface IReq_PushPublicKeyForValidation
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
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
|
export interface IReq_PushOrGetJwtIdBlocklist
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
|||||||
@@ -84,3 +84,59 @@ export interface IReq_WhoIs {
|
|||||||
user: data.IUser;
|
user: data.IUser;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetUserSessions
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetUserSessions
|
||||||
|
> {
|
||||||
|
method: 'getUserSessions';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
sessions: Array<{
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
browser: string;
|
||||||
|
os: string;
|
||||||
|
ip: string;
|
||||||
|
lastActive: number;
|
||||||
|
createdAt: number;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_RevokeSession
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RevokeSession
|
||||||
|
> {
|
||||||
|
method: 'revokeSession';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetUserActivity
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetUserActivity
|
||||||
|
> {
|
||||||
|
method: 'getUserActivity';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
activities: data.IActivityLog[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.7.0',
|
version: '1.15.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
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,8 @@ import {
|
|||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import { LeleAccountNavigation } from './navigation.js';
|
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 { accountDesignTokens } from './sharedstyles.js';
|
||||||
|
|
||||||
import * as views from './views/index.js';
|
import * as views from './views/index.js';
|
||||||
@@ -100,6 +102,25 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
this.subrouter = this.domtools.router.createSubRouter('/account');
|
this.subrouter = this.domtools.router.createSubRouter('/account');
|
||||||
const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer');
|
const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer');
|
||||||
|
|
||||||
|
// 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 () => {
|
const cleanupViews = async () => {
|
||||||
for (const child of Array.from(viewcontainer.children)) {
|
for (const child of Array.from(viewcontainer.children)) {
|
||||||
viewcontainer.removeChild(child);
|
viewcontainer.removeChild(child);
|
||||||
@@ -139,6 +160,16 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
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 () => {
|
this.subrouter.on('/org/:orgName/apps', async () => {
|
||||||
viewcontainer.classList.add('changing');
|
viewcontainer.classList.add('changing');
|
||||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||||
@@ -149,6 +180,16 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
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 () => {
|
this.subrouter.on('/admin', async () => {
|
||||||
viewcontainer.classList.add('changing');
|
viewcontainer.classList.add('changing');
|
||||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||||
|
|||||||
@@ -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 './content.js';
|
||||||
export * from './navigation.js';
|
export * from './navigation.js';
|
||||||
|
export * from './org-select-modal.js';
|
||||||
|
export * from './create-org-modal.js';
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import * as plugins from '../../plugins.js';
|
|||||||
import * as states from '../../states/accountstate.js';
|
import * as states from '../../states/accountstate.js';
|
||||||
import { IdpState } from '../../states/idp.state.js';
|
import { IdpState } from '../../states/idp.state.js';
|
||||||
import { accountDesignTokens } from './sharedstyles.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';
|
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
|
||||||
|
|
||||||
@@ -28,10 +30,39 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor isGlobalAdmin: boolean = false;
|
accessor isGlobalAdmin: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor currentPath: string = window.location.pathname;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
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 = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
accountDesignTokens,
|
accountDesignTokens,
|
||||||
@@ -136,6 +167,15 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navigationOption.active {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigationOption.active dees-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
@@ -165,11 +205,8 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
<div class="navContent">
|
<div class="navContent">
|
||||||
<div class="navigationGroupLabel">Account</div>
|
<div class="navigationGroupLabel">Account</div>
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption ${this.isActive('') ? 'active' : ''}"
|
||||||
@click=${async () => {
|
@click=${() => this.navigateTo('')}
|
||||||
const subrouter = await this.getAccountRouter();
|
|
||||||
subrouter.pushUrl('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
||||||
Overview
|
Overview
|
||||||
@@ -183,14 +220,6 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
||||||
Manage Roles
|
Manage Roles
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="navigationOption"
|
|
||||||
@click=${async () => {
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<dees-icon .icon=${'lucide:plus'}></dees-icon>
|
|
||||||
Create Organization
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption"
|
||||||
@click=${async () => {
|
@click=${async () => {
|
||||||
@@ -207,31 +236,51 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
<div class="navigationGroupLabel">Organization</div>
|
<div class="navigationGroupLabel">Organization</div>
|
||||||
<dees-input-dropdown
|
<dees-input-dropdown
|
||||||
.label=${'Select organization'}
|
.label=${'Select organization'}
|
||||||
@selectedOption=${(eventArg: CustomEvent) => {
|
@selectedOption=${async (eventArg: CustomEvent) => {
|
||||||
|
// Handle "Create new..." option
|
||||||
|
if (eventArg.detail.key === '__create_new__') {
|
||||||
|
const org = await CreateOrgModal.show();
|
||||||
|
if (org) {
|
||||||
|
await this.navigateTo(`/org/${org.data.slug}/billing`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const currentState = states.accountState.getState();
|
const currentState = states.accountState.getState();
|
||||||
states.accountState.dispatchAction(
|
const newOrg = currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload);
|
||||||
states.setSelectedOrg,
|
states.accountState.dispatchAction(states.setSelectedOrg, newOrg);
|
||||||
currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload)
|
|
||||||
);
|
// Auto-navigate to new org's current page type (reactivity)
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
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>
|
></dees-input-dropdown>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption ${this.isActive('org-overview') ? 'active' : ''}"
|
||||||
@click=${async () => {
|
@click=${() => this.navigateToOrgPage('')}
|
||||||
const currentState = states.accountState.getState();
|
>
|
||||||
if (currentState.selectedOrg) {
|
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
||||||
const subrouter = await this.getAccountRouter();
|
Overview
|
||||||
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/apps`);
|
</div>
|
||||||
}
|
<div
|
||||||
}}
|
class="navigationOption ${this.isActive('apps') ? 'active' : ''}"
|
||||||
|
@click=${() => this.navigateToOrgPage('apps')}
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:box'}></dees-icon>
|
<dees-icon .icon=${'lucide:box'}></dees-icon>
|
||||||
Apps
|
Apps
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption ${this.isActive('users') ? 'active' : ''}"
|
||||||
@click=${async () => {}}
|
@click=${() => this.navigateToOrgPage('users')}
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:users'}></dees-icon>
|
<dees-icon .icon=${'lucide:users'}></dees-icon>
|
||||||
Users
|
Users
|
||||||
@@ -244,14 +293,8 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
Activity
|
Activity
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption ${this.isActive('billing') ? 'active' : ''}"
|
||||||
@click=${async () => {
|
@click=${() => this.navigateToOrgPage('billing')}
|
||||||
const currentState = states.accountState.getState();
|
|
||||||
if (currentState.selectedOrg) {
|
|
||||||
const subrouter = await this.getAccountRouter();
|
|
||||||
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/billing`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
|
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
|
||||||
Billing
|
Billing
|
||||||
@@ -272,11 +315,8 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<div class="navigationGroupLabel">Platform</div>
|
<div class="navigationGroupLabel">Platform</div>
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption ${this.isActive('admin') ? 'active' : ''}"
|
||||||
@click=${async () => {
|
@click=${() => this.navigateTo('/admin')}
|
||||||
const subrouter = await this.getAccountRouter();
|
|
||||||
subrouter.pushUrl('/admin');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
||||||
Global Admin
|
Global Admin
|
||||||
@@ -284,7 +324,37 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public firstUpdated() {
|
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 deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
|
||||||
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
|
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
|
||||||
if (!orgArg) {
|
if (!orgArg) {
|
||||||
@@ -296,11 +366,21 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
payload: orgArg.data.slug,
|
payload: orgArg.data.slug,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// "Create new..." option to add at the end
|
||||||
|
const createNewOption = {
|
||||||
|
option: '+ Create new...',
|
||||||
|
key: '__create_new__',
|
||||||
|
payload: '__create_new__',
|
||||||
|
};
|
||||||
|
|
||||||
states.accountState
|
states.accountState
|
||||||
.select((stateArg) => stateArg.organizations)
|
.select((stateArg) => stateArg.organizations)
|
||||||
.pipe(
|
.pipe(
|
||||||
plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => {
|
plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => {
|
||||||
return orgArrayArg.map(orgToMenuEntry);
|
const orgEntries = orgArrayArg.map(orgToMenuEntry);
|
||||||
|
// Add "Create new..." at the end
|
||||||
|
return [...orgEntries, createNewOption];
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.subscribe((menuEntries) => {
|
.subscribe((menuEntries) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,19 @@ export const cardStyles = css`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base styles for all view components
|
||||||
|
* Provides consistent background and foreground colors
|
||||||
|
*/
|
||||||
|
export const viewBaseStyles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typography styles for consistent text hierarchy
|
* Typography styles for consistent text hierarchy
|
||||||
*/
|
*/
|
||||||
@@ -108,10 +121,3 @@ export const navigationStyles = css`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy export for backwards compatibility
|
|
||||||
*/
|
|
||||||
export default css`
|
|
||||||
${accountDesignTokens}
|
|
||||||
${typographyStyles}
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import { IdpState } from '../../../states/idp.state.js';
|
import { IdpState } from '../../../states/idp.state.js';
|
||||||
import { accountDesignTokens } from '../sharedstyles.js';
|
import * as sharedStyles from '../sharedstyles.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -43,15 +43,9 @@ export class AdminView extends DeesElement {
|
|||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
accountDesignTokens,
|
sharedStyles.accountDesignTokens,
|
||||||
|
sharedStyles.viewBaseStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
min-height: 100%;
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -617,8 +611,7 @@ export class AdminView extends DeesElement {
|
|||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const jwt = await idpState.idpClient.getJwt();
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||||
'/typedrequest',
|
|
||||||
'getGlobalAppStats'
|
'getGlobalAppStats'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -644,8 +637,7 @@ export class AdminView extends DeesElement {
|
|||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const jwt = await idpState.idpClient.getJwt();
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
||||||
'/typedrequest',
|
|
||||||
'createGlobalApp'
|
'createGlobalApp'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -682,8 +674,7 @@ export class AdminView extends DeesElement {
|
|||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const jwt = await idpState.idpClient.getJwt();
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
||||||
'/typedrequest',
|
|
||||||
'updateGlobalApp'
|
'updateGlobalApp'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -717,8 +708,7 @@ export class AdminView extends DeesElement {
|
|||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const jwt = await idpState.idpClient.getJwt();
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||||
'/typedrequest',
|
|
||||||
'regenerateAppCredentials'
|
'regenerateAppCredentials'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -739,8 +729,7 @@ export class AdminView extends DeesElement {
|
|||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const jwt = await idpState.idpClient.getJwt();
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
||||||
'/typedrequest',
|
|
||||||
'deleteGlobalApp'
|
'deleteGlobalApp'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
state,
|
state,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
|
import * as sharedStyles from '../sharedstyles.js';
|
||||||
import * as accountState from '../../../states/accountstate.js';
|
import * as accountState from '../../../states/accountstate.js';
|
||||||
import { IdpState } from '../../../states/idp.state.js';
|
import { IdpState } from '../../../states/idp.state.js';
|
||||||
|
|
||||||
@@ -45,12 +45,12 @@ export class AppsView extends DeesElement {
|
|||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
accountDesignTokens,
|
sharedStyles.accountDesignTokens,
|
||||||
cardStyles,
|
sharedStyles.viewBaseStyles,
|
||||||
typographyStyles,
|
sharedStyles.cardStyles,
|
||||||
|
sharedStyles.typographyStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -374,8 +374,7 @@ export class AppsView extends DeesElement {
|
|||||||
const jwt = await idpState.idpClient.getJwt();
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
// Fetch global apps
|
// Fetch global apps
|
||||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
||||||
'/typedrequest',
|
|
||||||
'getGlobalApps'
|
'getGlobalApps'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -384,8 +383,7 @@ export class AppsView extends DeesElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch connections for this organization
|
// Fetch connections for this organization
|
||||||
const connectionsRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
const connectionsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
||||||
'/typedrequest',
|
|
||||||
'getAppConnections'
|
'getAppConnections'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -424,8 +422,7 @@ export class AppsView extends DeesElement {
|
|||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const jwt = await idpState.idpClient.getJwt();
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
|
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
|
||||||
'/typedrequest',
|
|
||||||
'toggleAppConnection'
|
'toggleAppConnection'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,5 +2,7 @@ export * from './adminview.js';
|
|||||||
export * from './appsview.js';
|
export * from './appsview.js';
|
||||||
export * from './baseview.js';
|
export * from './baseview.js';
|
||||||
export * from './orgsetup.js';
|
export * from './orgsetup.js';
|
||||||
|
export * from './orgview.js';
|
||||||
export * from './paddlesetup.js';
|
export * from './paddlesetup.js';
|
||||||
export * from './subscriptions.js';
|
export * from './subscriptions.js';
|
||||||
|
export * from './usersview.js';
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
import * as plugins from '../../../plugins.js';
|
||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
property,
|
||||||
|
html,
|
||||||
|
cssManager,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import * as sharedStyles 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,
|
||||||
|
sharedStyles.accountDesignTokens,
|
||||||
|
sharedStyles.viewBaseStyles,
|
||||||
|
css`
|
||||||
|
|
||||||
|
.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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import * as plugins from '../../../plugins.js';
|
import * as plugins from '../../../plugins.js';
|
||||||
import sharedStyles from '../sharedstyles.js';
|
import * as sharedStyles from '../sharedstyles.js';
|
||||||
import * as state from '../../../states/accountstate.js';
|
import * as state from '../../../states/accountstate.js';
|
||||||
|
import { IdpState } from '../../../states/idp.state.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -22,13 +23,13 @@ declare global {
|
|||||||
export class PaddleSetupView extends DeesElement {
|
export class PaddleSetupView extends DeesElement {
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
sharedStyles,
|
sharedStyles.accountDesignTokens,
|
||||||
|
sharedStyles.viewBaseStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
padding: 48px;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: auto;
|
margin: 0 auto;
|
||||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -61,28 +62,50 @@ export class PaddleSetupView extends DeesElement {
|
|||||||
public async openPaddle() {
|
public async openPaddle() {
|
||||||
await this.domtoolsPromise;
|
await this.domtoolsPromise;
|
||||||
const paddleButton = this.shadowRoot.querySelector('dees-button');
|
const paddleButton = this.shadowRoot.querySelector('dees-button');
|
||||||
await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/paddle.js');
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
globalThis.Paddle.Setup({
|
|
||||||
vendor: 30954,
|
// 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) => {
|
eventCallback: async (dataArg: any) => {
|
||||||
// The data.event will specify the event type
|
// Paddle Billing v2 event handling
|
||||||
if (dataArg.event === 'Checkout.Complete') {
|
if (dataArg.name === 'checkout.completed') {
|
||||||
const data: plugins.idpInterfaces.data.IPaddleCheckoutData = dataArg.eventData;
|
|
||||||
const paddleIframe = document.body.querySelector('iframe');
|
const paddleIframe = document.body.querySelector('iframe');
|
||||||
if (paddleIframe) {
|
if (paddleIframe) {
|
||||||
document.body.removeChild(paddleIframe);
|
document.body.removeChild(paddleIframe);
|
||||||
}
|
}
|
||||||
paddleButton.status = 'pending';
|
paddleButton.status = 'pending';
|
||||||
paddleButton.text = 'Processing...';
|
paddleButton.text = 'Processing...';
|
||||||
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, data.checkout.id);
|
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, dataArg.data.transaction_id);
|
||||||
paddleButton.status = 'success';
|
paddleButton.status = 'success';
|
||||||
paddleButton.text = 'Paddle connected!'
|
paddleButton.text = 'Paddle connected!'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
globalThis.Paddle.Checkout.open({
|
globalThis.Paddle.Checkout.open({
|
||||||
product: 561076,
|
items: [{ priceId: paddlePriceId, quantity: 1 }],
|
||||||
email: 'phil@kunz.io',
|
customer: { email: userEmail },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
css,
|
css,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
|
import * as sharedStyles from '../sharedstyles.js';
|
||||||
|
|
||||||
import * as state from '../../../states/accountstate.js';
|
import * as state from '../../../states/accountstate.js';
|
||||||
|
|
||||||
@@ -46,12 +46,12 @@ export class SubscriptionView extends DeesElement {
|
|||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
accountDesignTokens,
|
sharedStyles.accountDesignTokens,
|
||||||
cardStyles,
|
sharedStyles.viewBaseStyles,
|
||||||
typographyStyles,
|
sharedStyles.cardStyles,
|
||||||
|
sharedStyles.typographyStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -0,0 +1,941 @@
|
|||||||
|
import * as plugins from '../../../plugins.js';
|
||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
cssManager,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import * as sharedStyles from '../sharedstyles.js';
|
||||||
|
import * as accountState from '../../../states/accountstate.js';
|
||||||
|
import { IdpState } from '../../../states/idp.state.js';
|
||||||
|
import { BulkInviteModal } from '../bulk-invite-modal.js';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'lele-accountview-users': UsersView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IMemberDisplay {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
isOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IInvitationDisplay {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
invitedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('lele-accountview-users')
|
||||||
|
export class UsersView extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor members: IMemberDisplay[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor invitations: IInvitationDisplay[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor loading: boolean = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor activeTab: 'members' | 'pending' | 'invite' = 'members';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor organizationId: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor organizationName: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor inviteEmail: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor inviteRoles: string[] = ['viewer'];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isAdmin: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isOwner: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor currentUserId: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor submitting: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor actionMessage: { type: 'success' | 'error'; text: string } | null = null;
|
||||||
|
|
||||||
|
private static readonly AVAILABLE_ROLES = ['owner', 'admin', 'editor', 'viewer', 'guest'];
|
||||||
|
|
||||||
|
private emailInputSubscribed: boolean = false;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
sharedStyles.accountDesignTokens,
|
||||||
|
sharedStyles.viewBaseStyles,
|
||||||
|
sharedStyles.cardStyles,
|
||||||
|
sharedStyles.typographyStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card:hover {
|
||||||
|
border-color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-email {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-roles {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.owner {
|
||||||
|
background: rgba(234, 179, 8, 0.2);
|
||||||
|
color: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.admin {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.editor {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.viewer {
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.guest {
|
||||||
|
background: rgba(168, 162, 158, 0.2);
|
||||||
|
color: #a8a29e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
border-color: var(--foreground);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.danger:hover {
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-email {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invitation-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--foreground);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.success {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: #22c55e;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.you-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #3b82f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return html`
|
||||||
|
<h1>Users</h1>
|
||||||
|
<p>Manage members and invitations for ${this.organizationName || 'your organization'}.</p>
|
||||||
|
|
||||||
|
${this.actionMessage ? html`
|
||||||
|
<div class="message ${this.actionMessage.type}">${this.actionMessage.text}</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab ${this.activeTab === 'members' ? 'active' : ''}"
|
||||||
|
@click=${() => this.activeTab = 'members'}
|
||||||
|
>
|
||||||
|
Members (${this.members.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab ${this.activeTab === 'pending' ? 'active' : ''}"
|
||||||
|
@click=${() => this.activeTab = 'pending'}
|
||||||
|
>
|
||||||
|
Pending (${this.invitations.length})
|
||||||
|
</button>
|
||||||
|
${this.isAdmin ? html`
|
||||||
|
<button
|
||||||
|
class="tab ${this.activeTab === 'invite' ? 'active' : ''}"
|
||||||
|
@click=${() => this.activeTab = 'invite'}
|
||||||
|
>
|
||||||
|
Invite
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.renderTabContent()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTabContent() {
|
||||||
|
if (this.loading) {
|
||||||
|
return html`
|
||||||
|
<div class="loading">
|
||||||
|
<span>Loading users...</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.activeTab) {
|
||||||
|
case 'members':
|
||||||
|
return this.renderMembers();
|
||||||
|
case 'pending':
|
||||||
|
return this.renderPendingInvitations();
|
||||||
|
case 'invite':
|
||||||
|
return this.renderInviteForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMembers() {
|
||||||
|
if (this.members.length === 0) {
|
||||||
|
return html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<dees-icon .icon=${'lucide:users'}></dees-icon>
|
||||||
|
<h2>No Members</h2>
|
||||||
|
<p>This organization has no members yet.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="member-list">
|
||||||
|
${this.members.map(member => html`
|
||||||
|
<div class="member-card">
|
||||||
|
<div class="member-info">
|
||||||
|
<div class="member-avatar">
|
||||||
|
${member.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="member-details">
|
||||||
|
<span class="member-name">
|
||||||
|
${member.name}
|
||||||
|
${member.userId === this.currentUserId ? html`<span class="you-badge">You</span>` : ''}
|
||||||
|
</span>
|
||||||
|
<span class="member-email">${member.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="member-roles">
|
||||||
|
${member.roles.map(role => html`
|
||||||
|
<span class="role-badge ${role}">${role}</span>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
${member.userId !== this.currentUserId ? html`
|
||||||
|
<div class="member-actions">
|
||||||
|
${this.isOwner && !member.isOwner ? html`
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
@click=${() => this.handleTransferOwnership(member.userId, member.name)}
|
||||||
|
?disabled=${this.submitting}
|
||||||
|
title="Transfer ownership to this member"
|
||||||
|
>
|
||||||
|
Transfer Ownership
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${this.isAdmin ? html`
|
||||||
|
<button
|
||||||
|
class="action-button danger"
|
||||||
|
@click=${() => this.handleRemoveMember(member.userId, member.name)}
|
||||||
|
?disabled=${this.submitting || member.isOwner}
|
||||||
|
title=${member.isOwner ? 'Cannot remove owner' : 'Remove member'}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPendingInvitations() {
|
||||||
|
if (this.invitations.length === 0) {
|
||||||
|
return html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<dees-icon .icon=${'lucide:mail'}></dees-icon>
|
||||||
|
<h2>No Pending Invitations</h2>
|
||||||
|
<p>There are no pending invitations for this organization.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="member-list">
|
||||||
|
${this.invitations.map(inv => html`
|
||||||
|
<div class="invitation-card">
|
||||||
|
<div class="invitation-info">
|
||||||
|
<span class="invitation-email">${inv.email}</span>
|
||||||
|
<span class="invitation-meta">
|
||||||
|
Invited ${this.formatDate(inv.invitedAt)} · Expires ${this.formatDate(inv.expiresAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="member-roles">
|
||||||
|
${inv.roles.map(role => html`
|
||||||
|
<span class="role-badge ${role}">${role}</span>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
${this.isAdmin ? html`
|
||||||
|
<div class="member-actions">
|
||||||
|
<button
|
||||||
|
class="action-button"
|
||||||
|
@click=${() => this.handleResendInvitation(inv.id)}
|
||||||
|
?disabled=${this.submitting}
|
||||||
|
>
|
||||||
|
Resend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-button danger"
|
||||||
|
@click=${() => this.handleCancelInvitation(inv.id, inv.email)}
|
||||||
|
?disabled=${this.submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderInviteForm(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="invite-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Email Address</label>
|
||||||
|
<dees-input-text
|
||||||
|
.label=${''}
|
||||||
|
.placeholder=${'Enter email address'}
|
||||||
|
.value=${this.inviteEmail}
|
||||||
|
?disabled=${this.submitting}
|
||||||
|
></dees-input-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<div class="role-selector">
|
||||||
|
${UsersView.AVAILABLE_ROLES.filter(r => r !== 'owner').map(role => html`
|
||||||
|
<button
|
||||||
|
class="role-option ${this.inviteRoles.includes(role) ? 'selected' : ''}"
|
||||||
|
@click=${() => this.toggleRole(role)}
|
||||||
|
?disabled=${this.submitting}
|
||||||
|
>
|
||||||
|
${role}
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dees-button
|
||||||
|
.text=${'Send Invitation'}
|
||||||
|
.status=${this.submitting ? 'pending' : 'normal'}
|
||||||
|
@click=${() => this.handleSendInvitation()}
|
||||||
|
></dees-button>
|
||||||
|
|
||||||
|
<div style="margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--border);">
|
||||||
|
<p style="color: var(--muted-foreground); font-size: 13px; margin: 0 0 12px 0;">
|
||||||
|
Need to invite multiple people at once?
|
||||||
|
</p>
|
||||||
|
<dees-button
|
||||||
|
.text=${'Import from CSV'}
|
||||||
|
.type=${'secondary'}
|
||||||
|
@click=${() => this.handleBulkImport()}
|
||||||
|
></dees-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async firstUpdated() {
|
||||||
|
await this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public updated(changedProperties: Map<string, any>) {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
|
||||||
|
// Subscribe to email input when Invite tab is shown
|
||||||
|
if (this.activeTab === 'invite' && !this.emailInputSubscribed) {
|
||||||
|
const emailInput = this.shadowRoot?.querySelector('.invite-form dees-input-text') as any;
|
||||||
|
if (emailInput?.changeSubject) {
|
||||||
|
emailInput.changeSubject.subscribe((element: any) => {
|
||||||
|
this.inviteEmail = element.value;
|
||||||
|
});
|
||||||
|
this.emailInputSubscribed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadData() {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the organization from 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;
|
||||||
|
this.organizationName = selectedOrg.data.name;
|
||||||
|
this.currentUserId = currentState.user?.id || '';
|
||||||
|
|
||||||
|
// Check if current user is admin/owner
|
||||||
|
const currentUserRole = currentState.roles.find(
|
||||||
|
r => r.data.organizationId === this.organizationId && r.data.userId === this.currentUserId
|
||||||
|
);
|
||||||
|
this.isAdmin = currentUserRole?.data?.roles?.some(r => ['owner', 'admin'].includes(r)) ?? false;
|
||||||
|
this.isOwner = currentUserRole?.data?.roles?.includes('owner') ?? false;
|
||||||
|
|
||||||
|
// Get JWT from IdpState
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
// Fetch members
|
||||||
|
const membersRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||||
|
'getOrgMembers'
|
||||||
|
);
|
||||||
|
|
||||||
|
const membersResponse = await membersRequest.fire({
|
||||||
|
jwt,
|
||||||
|
organizationId: this.organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.members = membersResponse.members.map(m => ({
|
||||||
|
userId: m.user.id,
|
||||||
|
name: m.user.data.name || m.user.data.username || 'Unknown',
|
||||||
|
email: m.user.data.email,
|
||||||
|
roles: m.role.data.roles || [],
|
||||||
|
isOwner: m.role.data.roles?.includes('owner') ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fetch invitations if admin
|
||||||
|
if (this.isAdmin) {
|
||||||
|
const invitationsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
|
||||||
|
'getOrgInvitations'
|
||||||
|
);
|
||||||
|
|
||||||
|
const invitationsResponse = await invitationsRequest.fire({
|
||||||
|
jwt,
|
||||||
|
organizationId: this.organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.invitations = invitationsResponse.invitations.map(inv => {
|
||||||
|
const orgRef = inv.data.organizationRefs.find(ref => ref.organizationId === this.organizationId);
|
||||||
|
return {
|
||||||
|
id: inv.id,
|
||||||
|
email: inv.data.email,
|
||||||
|
roles: orgRef?.roles || [],
|
||||||
|
invitedAt: orgRef?.invitedAt || inv.data.createdAt,
|
||||||
|
expiresAt: inv.data.expiresAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading users:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleRole(role: string) {
|
||||||
|
if (this.inviteRoles.includes(role)) {
|
||||||
|
this.inviteRoles = this.inviteRoles.filter(r => r !== role);
|
||||||
|
} else {
|
||||||
|
this.inviteRoles = [...this.inviteRoles, role];
|
||||||
|
}
|
||||||
|
// Ensure at least one role is selected
|
||||||
|
if (this.inviteRoles.length === 0) {
|
||||||
|
this.inviteRoles = ['viewer'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSendInvitation() {
|
||||||
|
if (!this.inviteEmail.trim()) {
|
||||||
|
this.showMessage('error', 'Please enter an email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.inviteRoles.length === 0) {
|
||||||
|
this.showMessage('error', 'Please select at least one role.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
this.actionMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||||
|
'createInvitation'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
jwt,
|
||||||
|
organizationId: this.organizationId,
|
||||||
|
email: this.inviteEmail.trim(),
|
||||||
|
roles: this.inviteRoles,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.showMessage('success', response.message || 'Invitation sent successfully!');
|
||||||
|
this.inviteEmail = '';
|
||||||
|
this.inviteRoles = ['viewer'];
|
||||||
|
await this.loadData();
|
||||||
|
this.activeTab = 'pending';
|
||||||
|
} else {
|
||||||
|
this.showMessage('error', response.message || 'Failed to send invitation.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending invitation:', error);
|
||||||
|
this.showMessage('error', 'Failed to send invitation. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleResendInvitation(invitationId: string) {
|
||||||
|
this.submitting = true;
|
||||||
|
this.actionMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResendInvitation>(
|
||||||
|
'resendInvitation'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
jwt,
|
||||||
|
organizationId: this.organizationId,
|
||||||
|
invitationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.showMessage('success', 'Invitation resent successfully!');
|
||||||
|
await this.loadData();
|
||||||
|
} else {
|
||||||
|
this.showMessage('error', response.message || 'Failed to resend invitation.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resending invitation:', error);
|
||||||
|
this.showMessage('error', 'Failed to resend invitation. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCancelInvitation(invitationId: string, email: string) {
|
||||||
|
if (!confirm(`Cancel invitation for ${email}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
this.actionMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CancelInvitation>(
|
||||||
|
'cancelInvitation'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
jwt,
|
||||||
|
organizationId: this.organizationId,
|
||||||
|
invitationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.showMessage('success', 'Invitation cancelled.');
|
||||||
|
await this.loadData();
|
||||||
|
} else {
|
||||||
|
this.showMessage('error', response.message || 'Failed to cancel invitation.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling invitation:', error);
|
||||||
|
this.showMessage('error', 'Failed to cancel invitation. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRemoveMember(userId: string, name: string) {
|
||||||
|
if (!confirm(`Remove ${name} from this organization?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
this.actionMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RemoveMember>(
|
||||||
|
'removeMember'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
jwt,
|
||||||
|
organizationId: this.organizationId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.showMessage('success', `${name} has been removed from the organization.`);
|
||||||
|
await this.loadData();
|
||||||
|
} else {
|
||||||
|
this.showMessage('error', response.message || 'Failed to remove member.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing member:', error);
|
||||||
|
this.showMessage('error', 'Failed to remove member. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTransferOwnership(newOwnerId: string, name: string) {
|
||||||
|
const confirmed = await this.showTransferConfirmation(name);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
this.actionMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_TransferOwnership>(
|
||||||
|
'transferOwnership'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
jwt,
|
||||||
|
organizationId: this.organizationId,
|
||||||
|
newOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.showMessage('success', `Ownership transferred to ${name}. You are now an admin.`);
|
||||||
|
await this.loadData();
|
||||||
|
} else {
|
||||||
|
this.showMessage('error', response.message || 'Failed to transfer ownership.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error transferring ownership:', error);
|
||||||
|
this.showMessage('error', 'Failed to transfer ownership. Please try again.');
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showTransferConfirmation(name: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
plugins.deesCatalog.DeesModal.createAndShow({
|
||||||
|
heading: 'Transfer Ownership',
|
||||||
|
content: html`
|
||||||
|
<div style="padding: 16px 0;">
|
||||||
|
<p style="margin: 0 0 12px 0;">Are you sure you want to transfer ownership to <strong>${name}</strong>?</p>
|
||||||
|
<p style="margin: 0; color: var(--muted-foreground);">
|
||||||
|
You will be demoted to admin role and will no longer be the owner of this organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modal) => { modal.destroy(); resolve(false); } },
|
||||||
|
{ name: 'Transfer Ownership', action: async (modal) => { modal.destroy(); resolve(true); } },
|
||||||
|
],
|
||||||
|
width: 420,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleBulkImport() {
|
||||||
|
const result = await BulkInviteModal.show({
|
||||||
|
organizationId: this.organizationId,
|
||||||
|
organizationName: this.organizationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result && result.invitedCount > 0) {
|
||||||
|
this.showMessage('success', `${result.invitedCount} invitation(s) sent successfully.`);
|
||||||
|
await this.loadData();
|
||||||
|
this.activeTab = 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showMessage(type: 'success' | 'error', text: string) {
|
||||||
|
this.actionMessage = { type, text };
|
||||||
|
// Auto-hide after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.actionMessage = null;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(timestamp: number): string {
|
||||||
|
return new Date(timestamp).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,13 +174,11 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
||||||
const loginRequestWithUsernameAndPassword =
|
const loginRequestWithUsernameAndPassword =
|
||||||
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||||
'/typedrequest',
|
|
||||||
'loginWithEmailOrUsernameAndPassword'
|
'loginWithEmailOrUsernameAndPassword'
|
||||||
);
|
);
|
||||||
const loginRequestWithEmail =
|
const loginRequestWithEmail =
|
||||||
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||||
'/typedrequest',
|
|
||||||
'loginWithEmail'
|
'loginWithEmail'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -170,9 +170,9 @@ export class IdpRegistrationPrompt extends DeesElement {
|
|||||||
private register = async (valueArg: { emailAddress: string }) => {
|
private register = async (valueArg: { emailAddress: string }) => {
|
||||||
const registrationForm: DeesForm = this.shadowRoot.querySelector('#registrationForm');
|
const registrationForm: DeesForm = this.shadowRoot.querySelector('#registrationForm');
|
||||||
registrationForm.setStatus('pending', 'registering...');
|
registrationForm.setStatus('pending', 'registering...');
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const firstSignupRequest =
|
const firstSignupRequest =
|
||||||
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
|
||||||
'/typedrequest',
|
|
||||||
'firstRegistrationRequest'
|
'firstRegistrationRequest'
|
||||||
);
|
);
|
||||||
const response = await firstSignupRequest
|
const response = await firstSignupRequest
|
||||||
@@ -209,8 +209,8 @@ export class IdpRegistrationPrompt extends DeesElement {
|
|||||||
public async handleRefreshToken(refreshTokenArg: string, delayDispatchMillisArg = 0) {
|
public async handleRefreshToken(refreshTokenArg: string, delayDispatchMillisArg = 0) {
|
||||||
// a refreshToken binds directly to a session.
|
// a refreshToken binds directly to a session.
|
||||||
// the refresh token is used on a continuous basis to get fresh and short-lived jwts
|
// the refresh token is used on a continuous basis to get fresh and short-lived jwts
|
||||||
const refreshJwt = new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
'/typedrequest',
|
const refreshJwt = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||||
'refreshJwt'
|
'refreshJwt'
|
||||||
);
|
);
|
||||||
const responseJwt = await refreshJwt.fire({
|
const responseJwt = await refreshJwt.fire({
|
||||||
|
|||||||
+3
-5
@@ -12,7 +12,7 @@ const run = async () => {
|
|||||||
metaObject: {
|
metaObject: {
|
||||||
title: 'idp.global',
|
title: 'idp.global',
|
||||||
description:
|
description:
|
||||||
'the code that runs idp.global',
|
'Your permanent identity on the web',
|
||||||
canonicalDomain: 'https://idp.global',
|
canonicalDomain: 'https://idp.global',
|
||||||
ldCompany: {
|
ldCompany: {
|
||||||
name: 'Task Venture Capital GmbH',
|
name: 'Task Venture Capital GmbH',
|
||||||
@@ -29,9 +29,7 @@ const run = async () => {
|
|||||||
description: 'work',
|
description: 'work',
|
||||||
name: 'Task Venture Capital GmbH',
|
name: 'Task Venture Capital GmbH',
|
||||||
type: 'company',
|
type: 'company',
|
||||||
facebookUrl: 'https://www.facebook.com/undefined variable',
|
website: 'https://task.vc',
|
||||||
twitterUrl: 'https://twitter.com/undefined variable',
|
|
||||||
website: 'https://Task Venture Capital GmbH',
|
|
||||||
phone: '+49 421 16767 548',
|
phone: '+49 421 16767 548',
|
||||||
},
|
},
|
||||||
closedDate: null,
|
closedDate: null,
|
||||||
@@ -44,7 +42,7 @@ const run = async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// const serviceWorker = await serviceworker.getServiceworkerClient();
|
await serviceworker.getServiceworkerClient();
|
||||||
|
|
||||||
const mainTemplate = html`
|
const mainTemplate = html`
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# @idp.global/web
|
||||||
|
|
||||||
|
Web Components and UI elements for the idp.global Identity Provider platform. Built with `@design.estate/dees-element` and the dees-catalog component library.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This package provides the complete web interface for idp.global, including authentication flows, account management, and organization administration. All components are built as Web Components using the Lit-based `dees-element` framework.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @idp.global/web
|
||||||
|
# or
|
||||||
|
pnpm add @idp.global/web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ts_web/
|
||||||
|
├── index.ts # Application entry point
|
||||||
|
├── plugins.ts # Plugin imports
|
||||||
|
├── views/
|
||||||
|
│ ├── viewcontainer.ts # Main view router
|
||||||
|
│ └── index.ts
|
||||||
|
├── elements/ # Web Components
|
||||||
|
│ ├── idp-loginprompt.ts # Login form
|
||||||
|
│ ├── idp-registerprompt.ts # Registration form
|
||||||
|
│ ├── idp-registration-stepper.ts # Multi-step registration
|
||||||
|
│ ├── idp-centercontainer.ts # Centered layout container
|
||||||
|
│ ├── idp-transfermanager.ts # SSO transfer handling
|
||||||
|
│ ├── idp-welcome.ts # Welcome/landing page
|
||||||
|
│ └── account/ # Account dashboard components
|
||||||
|
│ ├── content.ts # Main account layout
|
||||||
|
│ ├── navigation.ts # Sidebar navigation
|
||||||
|
│ ├── org-select-modal.ts # Organization switcher
|
||||||
|
│ ├── create-org-modal.ts # Create organization dialog
|
||||||
|
│ ├── bulk-invite-modal.ts # Bulk member invite dialog
|
||||||
|
│ └── views/ # Account sub-views
|
||||||
|
│ ├── baseview.ts # Base view class
|
||||||
|
│ ├── usersview.ts # User profile view
|
||||||
|
│ ├── orgview.ts # Organization details
|
||||||
|
│ ├── orgsetup.ts # Organization setup
|
||||||
|
│ ├── appsview.ts # Connected apps
|
||||||
|
│ ├── adminview.ts # Global admin panel
|
||||||
|
│ ├── subscriptions.ts # Billing subscriptions
|
||||||
|
│ └── paddlesetup.ts # Payment setup
|
||||||
|
└── states/
|
||||||
|
├── idp.state.ts # Main application state
|
||||||
|
└── accountstate.ts # Account dashboard state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Authentication Components
|
||||||
|
|
||||||
|
#### `<idp-loginprompt>`
|
||||||
|
|
||||||
|
Login form supporting password and magic link authentication.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<idp-loginprompt></idp-loginprompt>
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Email/username + password login
|
||||||
|
- Magic link (passwordless) authentication
|
||||||
|
- Automatic button text based on password presence
|
||||||
|
- Form validation and error handling
|
||||||
|
- Redirect to registration
|
||||||
|
|
||||||
|
#### `<idp-registerprompt>`
|
||||||
|
|
||||||
|
Initial registration form for new users.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<idp-registerprompt></idp-registerprompt>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `<idp-registration-stepper>`
|
||||||
|
|
||||||
|
Multi-step registration wizard for completing user profile.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<idp-registration-stepper></idp-registration-stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps include:
|
||||||
|
- Profile information
|
||||||
|
- Email verification
|
||||||
|
- Mobile verification (optional)
|
||||||
|
- Password setup
|
||||||
|
|
||||||
|
### Layout Components
|
||||||
|
|
||||||
|
#### `<idp-viewcontainer>`
|
||||||
|
|
||||||
|
Main view container that handles routing between views.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<idp-viewcontainer></idp-viewcontainer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported views:
|
||||||
|
- `welcome` - Landing page
|
||||||
|
- `login` - Login form
|
||||||
|
- `register` - Registration form
|
||||||
|
- `finishregistration` - Registration stepper
|
||||||
|
- `account` - Account dashboard
|
||||||
|
|
||||||
|
#### `<idp-centercontainer>`
|
||||||
|
|
||||||
|
Centered container with animation support for forms.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<idp-centercontainer>
|
||||||
|
<h2>Your Content</h2>
|
||||||
|
<form>...</form>
|
||||||
|
</idp-centercontainer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `show()` - Animate container into view
|
||||||
|
- `hide()` - Animate container out of view
|
||||||
|
|
||||||
|
### Account Dashboard Components
|
||||||
|
|
||||||
|
#### `<idp-account-content>`
|
||||||
|
|
||||||
|
Main account dashboard layout with navigation.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<idp-account-content></idp-account-content>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Navigation Views
|
||||||
|
|
||||||
|
| Component | Route | Description |
|
||||||
|
|-----------|-------|-------------|
|
||||||
|
| `<idp-usersview>` | `/account/users` | User profile management |
|
||||||
|
| `<idp-orgview>` | `/account/org` | Organization details |
|
||||||
|
| `<idp-orgsetup>` | `/account/orgsetup` | Organization configuration |
|
||||||
|
| `<idp-appsview>` | `/account/apps` | Connected applications |
|
||||||
|
| `<idp-adminview>` | `/account/admin` | Global admin panel |
|
||||||
|
| `<idp-subscriptions>` | `/account/subscriptions` | Billing management |
|
||||||
|
| `<idp-paddlesetup>` | `/account/paddle` | Payment method setup |
|
||||||
|
|
||||||
|
### Modal Components
|
||||||
|
|
||||||
|
#### `<idp-org-select-modal>`
|
||||||
|
|
||||||
|
Organization switcher modal for users with multiple organizations.
|
||||||
|
|
||||||
|
#### `<idp-create-org-modal>`
|
||||||
|
|
||||||
|
Dialog for creating new organizations with slug validation.
|
||||||
|
|
||||||
|
#### `<idp-bulk-invite-modal>`
|
||||||
|
|
||||||
|
Bulk invitation dialog for inviting multiple members at once.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### IdpState
|
||||||
|
|
||||||
|
Central application state using `@push.rocks/smartstate`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { IdpState } from '@idp.global/web';
|
||||||
|
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
|
||||||
|
// Access IdP client
|
||||||
|
const isLoggedIn = await idpState.idpClient.determineLoginStatus();
|
||||||
|
|
||||||
|
// Access router
|
||||||
|
idpState.domtools.router.pushUrl('/login');
|
||||||
|
|
||||||
|
// Subscribe to view changes
|
||||||
|
idpState.mainStatePart.select(s => s.view).subscribe(view => {
|
||||||
|
console.log('Current view:', view);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### AccountState
|
||||||
|
|
||||||
|
State for the account dashboard section.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AccountState } from '@idp.global/web';
|
||||||
|
|
||||||
|
const accountState = await AccountState.getSingletonInstance();
|
||||||
|
|
||||||
|
// Access current organization
|
||||||
|
const currentOrg = accountState.currentOrganization;
|
||||||
|
|
||||||
|
// Access user roles
|
||||||
|
const roles = accountState.userRoles;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
Components use CSS custom properties for theming:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:host {
|
||||||
|
--foreground: hsl(0 0% 98%);
|
||||||
|
--muted-foreground: hsl(240 5% 64.9%);
|
||||||
|
--background-accent: #303f9f;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All components include:
|
||||||
|
- Dark mode by default
|
||||||
|
- Geist Sans font family
|
||||||
|
- Smooth animations
|
||||||
|
- Responsive layouts
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@design.estate/dees-element` - Web Component base class
|
||||||
|
- `@design.estate/dees-catalog` - UI component library
|
||||||
|
- `@design.estate/dees-domtools` - DOM utilities and routing
|
||||||
|
- `@idp.global/idpclient` - IdP client library
|
||||||
|
- `@idp.global/interfaces` - TypeScript interfaces
|
||||||
|
- `@push.rocks/smartstate` - State management
|
||||||
|
- `@uptime.link/webwidget` - Status widget
|
||||||
|
|
||||||
|
## Views and Routes
|
||||||
|
|
||||||
|
| Route | View | Component |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| `/` | `welcome` | `IdpWelcome` |
|
||||||
|
| `/login` | `login` | `IdpLoginPrompt` |
|
||||||
|
| `/register` | `register` | `IdpRegistrationPrompt` |
|
||||||
|
| `/finishregistration` | `finishregistration` | `IdpRegistrationStepper` |
|
||||||
|
| `/account` | `account` | `IdpAccountContent` |
|
||||||
|
| `/logout` | - | Logout handler |
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
The web module is bundled using `@git.zone/tsbundle`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development with hot reload
|
||||||
|
pnpm watch
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
The bundled output is served from `dist_ts_web/` by the TypedServer.
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This 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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By 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.
|
||||||
@@ -87,6 +87,9 @@ export const manifestNewOrgName = accountState.createAction(async (statePartArg,
|
|||||||
'manifest'
|
'manifest'
|
||||||
);
|
);
|
||||||
currentState.organizations.push(result.resultingOrganization);
|
currentState.organizations.push(result.resultingOrganization);
|
||||||
|
if (result.role) {
|
||||||
|
currentState.roles.push(result.role);
|
||||||
|
}
|
||||||
currentState.selectedOrg = result.resultingOrganization;
|
currentState.selectedOrg = result.resultingOrganization;
|
||||||
return currentState;
|
return currentState;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class IdpState {
|
|||||||
}>
|
}>
|
||||||
|
|
||||||
public async init() {
|
public async init() {
|
||||||
this.idpClient.enableTypedSocket();
|
await this.idpClient.enableTypedSocket();
|
||||||
const domtoolsInstance = await domtools.DomTools.setupDomTools();
|
const domtoolsInstance = await domtools.DomTools.setupDomTools();
|
||||||
this.domtools = domtoolsInstance;
|
this.domtools = domtoolsInstance;
|
||||||
const state = new plugins.deesDomtools.plugins.smartstate.Smartstate<'main'>();
|
const state = new plugins.deesDomtools.plugins.smartstate.Smartstate<'main'>();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"order": 5
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user