Compare commits

..

36 Commits

Author SHA1 Message Date
jkunz 1532c9704b v1.17.1
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 08:15:42 +00:00
jkunz 76efcb835f fix(docs): refresh module readmes and add repository license file 2026-04-20 08:15:42 +00:00
jkunz 2d1e6ea6e1 v1.17.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 08:12:07 +00:00
jkunz 98e614a945 feat(auth): harden authentication with argon2 passwords and rotating hashed refresh tokens 2026-04-20 08:12:07 +00:00
jkunz ad3e51a9e8 v1.16.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-01-29 15:06:40 +00:00
jkunz d8f72d620a feat(dev): add local development docs, update tswatch preset and add Playwright screenshots 2026-01-29 15:06:40 +00:00
jkunz 53b36e506c v1.15.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-01-29 14:24:08 +00:00
jkunz 7d5ad29a27 feat(build): add tsbundle/tswatch configs, update build/watch scripts, bump dependencies, and add CLI documentation 2026-01-29 14:24:08 +00:00
jkunz 724ec2d134 v1.14.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-22 15:56:20 +00:00
jkunz 32ffc1bbaa fix(oidc): migrate OIDC endpoints and internal handlers to use typedserver IRequestContext and update dependencies 2025-12-22 15:56:20 +00:00
jkunz a91dd9dda6 v1.14.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-16 12:46:42 +00:00
jkunz 5462257398 feat(docs): add package READMEs and publish metadata; update web package publish order 2025-12-16 12:46:42 +00:00
jkunz 2ad751ecba v1.13.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-15 19:45:57 +00:00
jkunz a24b0d8be7 feat(oidc): feat(oidc): add OIDC provider (OidcManager, endpoints, and interfaces) 2025-12-15 19:45:57 +00:00
jkunz 02c700e44d v1.12.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-15 19:17:12 +00:00
jkunz e9f1b5dac9 fix(dependencies): fix(deps): bump @uptime.link/webwidget to ^1.2.6 2025-12-15 19:17:12 +00:00
jkunz 6645806a87 v1.12.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-15 18:58:10 +00:00
jkunz dc3f232f43 feat(interfaces): Add JWT public-key and blocklist request interfaces, publish ordering files, and update dependencies 2025-12-15 18:58:10 +00:00
jkunz cc9d56ff4b v1.11.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-14 10:58:46 +00:00
jkunz 47ca5934a6 feat(idpcli): Add idp CLI (IdpCli) with commands, file-based credential storage, typed request APIs; bump deps and update config 2025-12-14 10:58:46 +00:00
jkunz dddd968796 v1.10.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-07 20:45:30 +00:00
jkunz 2cdf86744e feat(billingplan): Add Paddle v2 checkout support and backend config endpoint; add CSP headers and bump typedserver 2025-12-07 20:45:30 +00:00
jkunz 9d9f90c1d5 feat(account): enhance session item removal animation and update metadata description 2025-12-05 10:23:49 +00:00
jkunz 833cf3b4b8 feat: Update organization member management and bulk invite functionality
- Marked the status of "Invite and Manage Team Members" story as Complete in README.
- Updated the status of ORG-002 to Complete in the corresponding markdown file.
- Modified OrganizationManager to assign roles as 'owner' during organization creation.
- Implemented bulk invitation feature in UserInvitationManager, allowing multiple users to be invited via CSV upload.
- Added IReq_BulkCreateInvitations interface for bulk invitation requests.
- Enhanced CreateOrgForm to update state with new roles upon organization creation.
- Introduced BulkInviteModal for bulk inviting users, including email validation and role assignment.
- Updated UsersView to support ownership transfer and bulk invitation functionality.
- Improved account state management to handle new roles and organizations.
2025-12-05 09:34:19 +00:00
jkunz 8df44b99b9 feat: Enhance WebSocket integration and add SPA fallback for routing 2025-12-04 18:06:49 +00:00
jkunz d32103618f update 2025-12-04 17:45:40 +00:00
jkunz a83858beb0 v1.9.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 20:03:34 +00:00
jkunz 5f29edf449 feat(account): Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking 2025-12-01 20:03:34 +00:00
jkunz 173735a84e v1.8.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 18:56:16 +00:00
jkunz 8756258324 feat(reception): Add activity logging, session metadata and org-selection UI (backend and frontend) 2025-12-01 18:56:16 +00:00
jkunz d11f5a0c72 fix(deps): update @push.rocks/smartdata and @git.zone/tswatch versions; refactor App and Jwt manager instantiation 2025-12-01 18:07:34 +00:00
jkunz cc040e5088 v1.7.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 09:44:37 +00:00
jkunz af0c24f7ca feat(admin): Add global admin functionality: backend admin APIs, model fields and UI integration 2025-12-01 09:44:37 +00:00
jkunz fd089b2cee v1.6.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 09:18:48 +00:00
jkunz 6b04c529da feat(apps): Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation 2025-12-01 09:18:48 +00:00
jkunz f54588e877 update paddle view nav 2025-12-01 04:44:47 +00:00
104 changed files with 15894 additions and 3199 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

+139
View File
@@ -1,5 +1,144 @@
# Changelog
## 2026-04-20 - 1.17.1 - fix(docs)
refresh module readmes and add repository license file
- rewrite the root, backend, web, client, CLI, and interfaces README content to focus on current module responsibilities and usage
- standardize README license references to the lowercase license file path
- add the repository MIT license file
## 2026-04-20 - 1.17.0 - feat(auth)
harden authentication with argon2 passwords and rotating hashed refresh tokens
- replace SHA-256 password hashing with argon2 while preserving verification and upgrade support for legacy hashes
- rotate refresh tokens on JWT refresh, detect token reuse, and invalidate compromised sessions
- store refresh and transfer tokens as hashes with one-time transfer token validation and expiry
- persist refresh tokens separately on the client so sessions can recover and refresh without embedding tokens in JWTs
- add authentication tests covering password verification, legacy hash migration, refresh token rotation, reuse detection, and one-time transfer tokens
## 2026-01-29 - 1.16.0 - feat(dev)
add local development docs, update tswatch preset and add Playwright screenshots
- readme.md: added a Local Development section with prerequisites, quick-start commands, environment variables, development routes, and default development credentials + security note
- npmextra.json: changed @git.zone/tswatch preset from "website" to "service" and disabled the built-in server (removed port/serveDir/liveReload and set server.enabled false); removed triggerReload from website watcher
- .playwright-mcp: added Playwright screenshots (login-page.png, register-page.png, account-dashboard.png) for visual tests / CI
## 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)
Add global admin functionality: backend admin APIs, model fields and UI integration
- Backend: Add AppManager admin endpoints (getGlobalAppStats, create/update/delete/global apps, regenerate credentials) and checkGlobalAdmin handler; enforce admin checks via verifyGlobalAdmin
- Data models: Add createdAt and createdByUserId to global app data; add optional isGlobalAdmin flag to user data (IUser)
- Typed requests: Add new request definitions in loint-reception.admin.ts and export it from request index
- UI: Expose Global Admin entry in account navigation (isGlobalAdmin reactive state), add /admin subroute and AdminView export
- Account state: Fetch whoIs() on load to populate user information for admin checks
- App seeding: Seed global apps with createdAt and createdByUserId metadata
- Docs: Story index updated to include ADM-008 Manage Global Apps and adjust priority summary
## 2025-12-01 - 1.6.0 - feat(apps)
Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation
- Introduce App and AppConnection SmartData models (ts/reception/classes.app.ts, ts/reception/classes.appconnection.ts)
- Add AppManager and AppConnectionManager with typed handlers for getGlobalApps, getAppConnections and toggleAppConnection (ts/reception/classes.appmanager.ts, ts/reception/classes.appconnectionmanager.ts)
- Add request and data interfaces for apps and app connections (ts_interfaces/data/loint-reception.app.ts, ts_interfaces/data/loint-reception.appconnection.ts, ts_interfaces/request/loint-reception.app.ts)
- Seed default global apps and support OAuth credential shape (IOAuthCredentials) in app data
- Wire App managers into Reception (ts/reception/classes.reception.ts) and Reception startup
- Update idp client types to use legacy app shape where required (IAppLegacy) and adapt typed requests (ts_idpclient/*)
- Expose web UI routes and navigation for organization Apps view and export the AppsView (ts_web/elements/account/*, ts_web/elements/account/views/index.ts)
- Add registration of new stories for Apps feature (stories/*: ORG-009, ORG-010, ORG-011, DEV-008) and update story index
- Adjust typed request shapes for login/transfer flows to accept IAppLegacy where transfer/app data is exchanged
## 2025-12-01 - 1.5.0 - feat(account)
Refactor account UI styles into reusable design tokens, apply updated styles across views and fix login submit behavior
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51 -15
View File
@@ -1,5 +1,18 @@
{
"gitzone": {
"npmci": {
"npmGlobalTools": [],
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
},
"dockerBuildargEnvMap": {
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
},
"npmRegistryUrl": "registry.npmjs.org"
},
"tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
},
"@git.zone/cli": {
"projectType": "website",
"module": {
"githost": "code.foss.global",
@@ -32,22 +45,45 @@
"user sessions"
]
},
"services": [
"mongodb",
"minio"
"services": ["mongodb", "minio"],
"release": {
"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": {
"npmGlobalTools": [],
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
"@git.zone/tswatch": {
"preset": "service",
"server": {
"enabled": false
},
"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"
"watchers": [
{
"name": "backend",
"watch": "./ts/**/*",
"command": "npm run startTs",
"restart": true,
"debounce": 300,
"runOnStart": true
}
],
"bundles": [
{
"name": "website",
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"watchPatterns": ["./ts_web/**/*"]
}
]
}
}
+32 -26
View File
@@ -1,14 +1,14 @@
{
"name": "@idp.global/idp.global",
"version": "1.5.0",
"version": "1.17.1",
"description": "An identity provider software managing user authentications, registrations, and sessions.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
"scripts": {
"test": "npm run build",
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle website --production",
"watch": "tswatch website",
"test": "pnpm run build && tstest test/",
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle",
"watch": "tswatch",
"start": "(node cli.js)",
"startTs": "(node cli.ts.js)",
"buildDocs": "tsdoc"
@@ -16,45 +16,51 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"dependencies": {
"@api.global/typedrequest": "^3.1.10",
"@api.global/typedrequest": "^3.3.0",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^3.0.80",
"@api.global/typedsocket": "^3.0.1",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@consent.software/catalog": "^2.0.1",
"@design.estate/dees-catalog": "^2.0.2",
"@design.estate/dees-domtools": "^2.3.6",
"@design.estate/dees-element": "^2.1.3",
"@push.rocks/lik": "^6.2.2",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-domtools": "^2.5.4",
"@design.estate/dees-element": "^2.2.4",
"@git.zone/tspublish": "^1.11.5",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartdata": "^7.0.14",
"@push.rocks/smartcli": "^4.0.20",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartfile": "^13.1.0",
"@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/smartlog": "^3.1.10",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmail": "^2.2.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.0.27",
"@push.rocks/smarttime": "^4.1.1",
"@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smarttime": "^4.2.3",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smarturl": "^3.1.0",
"@push.rocks/taskbuffer": "^3.4.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@push.rocks/webjwt": "^1.0.9",
"@push.rocks/websetup": "^3.0.15",
"@push.rocks/webstore": "^2.0.20",
"@push.rocks/webstore": "^2.0.21",
"@serve.zone/platformclient": "^1.1.2",
"@tsclass/tsclass": "^9.3.0",
"@uptime.link/webwidget": "^1.2.4"
"@tsclass/tsclass": "^9.5.0",
"@uptime.link/webwidget": "^1.2.6",
"argon2": "^0.44.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^3.1.2",
"@git.zone/tsbundle": "^2.6.2",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tswatch": "^2.2.1",
"@push.rocks/projectinfo": "^5.0.1",
"@types/node": "^24.10.1"
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@push.rocks/projectinfo": "^5.1.0",
"@types/node": "^25.6.0"
},
"private": true,
"repository": {
+4298 -2422
View File
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -1,3 +1,23 @@
# Project Readme Hints
This is the initial readme hints file.
## UI Components
Always check dees-catalog for available elements before implementing custom solutions:
- Documentation: https://code.foss.global/design.estate/dees-catalog
- Key components: `dees-modal`, `dees-button`, `dees-input-*`, `dees-form`, etc.
### dees-input-* Event Pattern
All dees-input components use **RxJS Subjects** for value changes, NOT DOM events:
```typescript
// Subscribe to value changes in firstUpdated():
const inputElement = this.shadowRoot.querySelector('dees-input-text');
inputElement.changeSubject.subscribe((element) => {
const value = element.value;
// handle value change
});
```
- Do NOT use `@changeValue` or similar DOM events - they don't exist
- The Subject emits the element itself, access value via `element.value`
## Project Structure
- `ts_web/elements/account/` - Account dashboard components
- `ts_web/states/` - State management (accountstate, idp.state)
+157 -261
View File
@@ -1,312 +1,208 @@
# @idp.global/idp.global
An identity provider software managing user authentications, registrations, and sessions.
Identity infrastructure for apps that need accounts, sessions, organizations, invites, admin tooling, and OpenID Connect in one TypeScript codebase.
## Install
This repository ships the `idp.global` server, the browser/client SDK, the CLI, shared request/data interfaces, and the web UI used by the hosted 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.
## What It Does
- Runs an identity provider with MongoDB-backed users, sessions, roles, organizations, invitations, API tokens, and billing plans.
- Serves a web app for login, registration, account management, org management, billing flows, and global admin views.
- Exposes typed realtime APIs over `typedrequest` and `typedsocket`.
- Implements OIDC/OAuth endpoints including discovery, JWKS, authorization, token, userinfo, and revoke.
- Includes a reusable browser client and a terminal CLI for common account and org workflows.
## Monorepo Modules
| Folder | Purpose |
| --- | --- |
| `ts/` | Backend service entrypoint and the core `Reception` managers |
| `ts_interfaces/` | Shared request and data contracts used by server, client, CLI, and UI |
| `ts_idpclient/` | Browser-focused SDK published as `@idp.global/client` |
| `ts_idpcli/` | CLI published as `@idp.global/cli` |
| `ts_web/` | Frontend bundle with login, registration, account, org, billing, and admin views |
## Core Backend Pieces
`Reception` wires the service together and starts these managers:
- `JwtManager` for signing, refreshing, and validating JWTs.
- `LoginSessionManager` for login state and session lifecycle.
- `RegistrationSessionManager` for multi-step sign-up flows.
- `UserManager` for user lookups and account data.
- `OrganizationManager` for org creation and membership lookup.
- `RoleManager` for org roles and permissions.
- `UserInvitationManager` for invites, membership updates, and ownership transfer.
- `ApiTokenManager` for long-lived token auth.
- `BillingPlanManager` for Paddle-backed billing data.
- `AppManager` and `AppConnectionManager` for app connections and admin app stats.
- `ActivityLogManager` for audit-style activity entries.
- `OidcManager` for the OIDC/OAuth provider surface.
## Quick Start
### Prerequisites
- Node.js 20+
- `pnpm`
- MongoDB
### Install
```bash
npm install @idp.global/idp.global
pnpm install
```
This will download and install the necessary dependencies along with the module to your project.
### Required Environment
## Usage
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.
### Setting Up the Environment
First, let's set up the environment:
```typescript
// Import the necessary modules
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();
```bash
export MONGODB_URL=mongodb://localhost:27017/idp-dev
export IDP_BASEURL=http://localhost:2999
export INSTANCE_NAME=idp-dev
```
### Using the IDP Client
Optional:
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:
- `SERVEZONE_PLATFROM_AUTHORIZATION`
- `PADDLE_TOKEN`
- `PADDLE_PRICE_ID`
```typescript
import { IdpState } from './idp.state.js';
import * as plugins from './plugins.js';
### Build
// Instantiate IdpState which provides a singleton instance
export class IdpDemo {
private idpState = IdpState.getSingletonInstance();
```bash
pnpm build
```
// Function to initialize and use IdpClient
public async demo() {
// Fetch the client instance
const { idpClient } = this.idpState;
// Handler for login
const handleLogin = async () => {
const response = await idpClient.requests.loginWithUserNameAndPassword.fire({
### Run Locally
```bash
pnpm watch
```
This starts the backend from `ts/` and rebuilds the frontend bundle from `ts_web/`. The service listens on port `2999`.
## Runtime Surface
### Web Routes
| Route | Purpose |
| --- | --- |
| `/` | Welcome page |
| `/login` | Login flow |
| `/register` | Registration flow |
| `/finishregistration` | Multi-step registration completion |
| `/account` | Signed-in account area |
### OIDC and OAuth Endpoints
| Route | Purpose |
| --- | --- |
| `/.well-known/openid-configuration` | Discovery document |
| `/.well-known/jwks.json` | Public signing keys |
| `/oauth/authorize` | Authorization endpoint |
| `/oauth/token` | Token exchange |
| `/oauth/userinfo` | UserInfo endpoint |
| `/oauth/revoke` | Token revocation |
Supported scopes in the OIDC manager include `openid`, `profile`, `email`, `organizations`, and `roles`.
## SDK Example
The browser SDK lives in `ts_idpclient/` and is published as `@idp.global/client`.
```ts
import { IdpClient } from '@idp.global/client';
const idpClient = new IdpClient('https://idp.global');
await idpClient.enableTypedSocket();
const isLoggedIn = await idpClient.determineLoginStatus();
if (!isLoggedIn) {
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
username: 'user@example.com',
password: 'password123',
password: 'secret',
});
if (response.refreshToken) {
await idpClient.storeJwt(response.jwt);
console.log("Logged in successfully, JWT stored.");
} else {
console.log("Login failed.");
}
};
// Execute login handler
await handleLogin();
if (loginResult.refreshToken) {
await idpClient.refreshJwt(loginResult.refreshToken);
}
}
// Instantiate and run demo
const demo = new IdpDemo();
demo.demo();
const whoIs = await idpClient.whoIs();
console.log(whoIs.user.data.email);
```
### Managing User Authentication
## CLI Example
Several functionalities are available for managing user authentication. These include registering, logging in, and refreshing JWTs.
The terminal client lives in `ts_idpcli/` and is published as `@idp.global/cli`.
#### 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
import * as plugins from './plugins.js';
import { IdpState } from './idp.state.js';
// Registration stepper element
export class IdpRegistrationStepper extends plugins.DeesElement {
private idpState = IdpState.getSingletonInstance();
public async firstUpdated() {
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) {
return await this.idpState.idpClient.requests.afterRegistrationEmailClicked.fire({
token
});
}
private async renderRegistrationForm(email: string) {
const template = plugins.html`
<dees-form @formData="${async (event) => await this.handleFormSubmission(event, email)}">
<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) {
const formData = (event.target as any).getFormData();
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) {
const template = plugins.html`<div>Error: ${message}</div>`;
this.render(template, this.shadowRoot);
}
}
```bash
idp login
idp whoami
idp orgs
idp members --org <org-id>
idp invite --org <org-id> --email user@example.com
```
### User Management
The CLI stores credentials in `~/.idp-global/credentials.json` and reads `IDP_URL` to override the target server.
Managing user data including roles, organizations, and billing plans is essential in any identity provider software.
## Shared Interfaces
#### Getting User Data
`ts_interfaces/` exports the type contracts shared across the stack:
```typescript
import * as plugins from './plugins.js';
- `data/*` for users, orgs, roles, JWTs, sessions, devices, billing plans, apps, and OIDC payloads.
- `request/*` for auth, registration, user, org, invitation, app, admin, billing, and JWT request contracts.
- `tags/*` for shared tag exports.
const fetchUserData = async (jwt: string) => {
const user = await plugins.typedrequest.TypedRequest<plugins.lointReception.request.IReq_GetUserData>(
`/getUserData`, 'POST').fire({jwt});
console.log(user);
};
## Frontend
fetchUserData('<JWT_TOKEN_HERE>');
```
`ts_web/` is the web application bundle. It contains:
#### Creating an Organization
- Login and registration prompts.
- A registration stepper.
- Account navigation and account views.
- Organization creation and bulk invite modals.
- Billing and Paddle setup views.
- A global admin view.
```typescript
import { IdpState } from './idp.state.js';
## Package Scripts
export class OrganizationManager {
private idpState = IdpState.getSingletonInstance();
| Command | Purpose |
| --- | --- |
| `pnpm build` | Build TypeScript output and frontend bundle |
| `pnpm watch` | Run backend watch mode and frontend bundle watch |
| `pnpm test` | Build and run the test suite |
public async createOrganization(name: string, slug: string, jwt: string) {
const response = await this.idpState.idpClient.requests.createOrganization.fire({
jwt: jwt,
organizationName: name,
organizationSlug: slug,
action: 'manifest',
});
if (response.resultingOrganization) {
console.log(`Organization ${name} created successfully.`);
} else {
console.log(`Organization creation failed.`);
}
}
}
## Repository Notes
// 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.
- Package manager: `pnpm`
- Main backend entrypoint: `ts/index.ts`
- Frontend entrypoint: `ts_web/index.ts`
- Browser SDK entrypoint: `ts_idpclient/index.ts`
- CLI entrypoint: `ts_idpcli/index.ts`
## 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.
### 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
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.
+13 -8
View File
@@ -7,9 +7,9 @@ This directory contains user stories for the idp.global Identity Provider platfo
```
stories/
├── end-user/ # Stories for regular users (8)
├── organization-owner/ # Stories for organization admins (8)
├── developer/ # Stories for API/SDK consumers (7)
└── admin/ # Stories for platform administrators (7)
├── organization-owner/ # Stories for organization admins (11)
├── developer/ # Stories for API/SDK consumers (8)
└── admin/ # Stories for platform administrators (8)
```
## Story Index
@@ -30,13 +30,16 @@ stories/
| ID | Title | Priority | Source |
|----|-------|----------|--------|
| ORG-001 | [Sync Billing Plans with Users](organization-owner/ORG-001-billing-sync.md) | High | TODO |
| ORG-002 | [Invite and Manage Team Members](organization-owner/ORG-002-member-management.md) | Critical | 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-004 | [Customize Organization Branding](organization-owner/ORG-004-org-branding.md) | Medium | New |
| ORG-005 | [View Organization Usage Analytics](organization-owner/ORG-005-usage-analytics.md) | Medium | New |
| ORG-006 | [Configure SSO for Organization](organization-owner/ORG-006-sso-config.md) | High | New |
| ORG-007 | [View Organization Audit Logs](organization-owner/ORG-007-audit-logs.md) | Medium | New |
| ORG-008 | [Manage Subscription and Billing](organization-owner/ORG-008-subscription-management.md) | Medium | Enhance |
| ORG-009 | [Connect Global Apps](organization-owner/ORG-009-global-apps.md) | High | New |
| ORG-010 | [Browse and Install Partner Apps](organization-owner/ORG-010-app-store.md) | Medium | New |
| ORG-011 | [Create Custom OIDC Apps](organization-owner/ORG-011-custom-oidc-apps.md) | Medium | New |
### Developer (DEV)
| ID | Title | Priority | Source |
@@ -48,6 +51,7 @@ stories/
| DEV-005 | [Register OAuth Client App](developer/DEV-005-oauth-client.md) | Medium | New |
| DEV-006 | [Understand API Rate Limits](developer/DEV-006-rate-limiting.md) | Low | New |
| DEV-007 | [Validate JWTs in My Application](developer/DEV-007-jwt-validation.md) | Medium | Enhance |
| DEV-008 | [Submit App to AppStore](developer/DEV-008-submit-partner-app.md) | Low | New |
### Platform Admin (ADM)
| ID | Title | Priority | Source |
@@ -59,15 +63,16 @@ stories/
| ADM-005 | [Security Monitoring Dashboard](admin/ADM-005-security-dashboard.md) | Medium | New |
| ADM-006 | [Impersonate Users for Support](admin/ADM-006-user-impersonation.md) | Low | New |
| ADM-007 | [Manage JWT Blocklist](admin/ADM-007-blocklist-management.md) | Medium | Enhance |
| ADM-008 | [Manage Global Apps](admin/ADM-008-global-app-management.md) | High | In Development |
## Priority Summary
| Priority | Count | Stories |
|----------|-------|---------|
| Critical | 3 | EU-002, ORG-002, ADM-001 |
| High | 10 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003 |
| Medium | 12 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 |
| Low | 5 | EU-007, EU-008, DEV-006, ADM-006 |
| Critical | 2 | EU-002, ADM-001 |
| High | 12 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003, ADM-008 |
| Medium | 14 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, ORG-010, ORG-011, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 |
| Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
## Source Legend
@@ -0,0 +1,130 @@
# Manage Global Apps
**ID:** ADM-008
**Priority:** High
**Status:** In Development
**Phase:** 1
## User Story
As a global administrator, I want to create, configure, and manage first-party global apps (foss.global, task.vc, etc.) so that organization owners can connect to these integrated services.
## Acceptance Criteria
- [ ] Only users with `isGlobalAdmin: true` can access the admin page
- [ ] View list of all global apps with their status
- [ ] Create new global apps with OAuth credentials
- [ ] Edit existing global app details (name, description, logo, URLs)
- [ ] Activate/deactivate global apps (inactive apps hidden from org owners)
- [ ] View connection statistics per app (how many orgs connected)
- [ ] Regenerate OAuth client credentials for an app
- [ ] Delete global apps (with confirmation and impact warning)
- [ ] Admin page accessible at `/admin` route
## Technical Notes
- Global admin flag stored on user: `isGlobalAdmin: boolean`
- Separate from organization roles (platform-level permission)
- OAuth credentials generated server-side, secrets never exposed in full
- App deletion should warn about existing connections
- Audit logging for all admin actions
## Data Model
```typescript
interface IUser {
id: string;
data: {
// ... existing fields ...
isGlobalAdmin?: boolean; // Platform-level admin flag
};
}
interface IGlobalApp {
id: string;
type: 'global';
data: {
name: string;
description: string;
logoUrl: string;
appUrl: string;
oauthCredentials: IOAuthCredentials;
isActive: boolean;
category: string;
createdAt: number;
createdByUserId: string;
};
}
```
## Request Interfaces
```typescript
interface IReq_CreateGlobalApp {
method: 'createGlobalApp';
request: {
jwt: string;
name: string;
description: string;
logoUrl: string;
appUrl: string;
category: string;
redirectUris: string[];
allowedScopes: string[];
};
response: {
app: IGlobalApp;
clientSecret: string; // Only shown once on creation
};
}
interface IReq_UpdateGlobalApp {
method: 'updateGlobalApp';
request: {
jwt: string;
appId: string;
updates: Partial<IGlobalApp['data']>;
};
response: {
app: IGlobalApp;
};
}
interface IReq_DeleteGlobalApp {
method: 'deleteGlobalApp';
request: {
jwt: string;
appId: string;
};
response: {
success: boolean;
disconnectedOrganizations: number;
};
}
interface IReq_GetGlobalAppStats {
method: 'getGlobalAppStats';
request: {
jwt: string;
};
response: {
apps: Array<{
app: IGlobalApp;
connectionCount: number;
}>;
};
}
```
## UI Components
- **GlobalAdminView** (`/admin`) - Main admin dashboard
- **Global Apps Tab** - List of global apps with CRUD operations
- **Create/Edit App Dialog** - Form for app configuration
- Navigation shows "Admin" link only for global admins
## Security Considerations
- Server-side validation of `isGlobalAdmin` flag on all admin endpoints
- JWT must be validated and user's admin status checked
- Rate limiting on credential regeneration
- Audit trail for all changes
## Related Stories
- ORG-009: Connect Global Apps (organization perspective)
- ADM-003: Platform-wide Audit Logging
+19 -3
View File
@@ -19,10 +19,26 @@ As a developer, I want to properly register my application with a unique App ID
## Technical Notes
- Current client has `id: ''` placeholder (TODO in code)
- Need Application model in database
- App credentials similar to OAuth client credentials
- App ID is now part of the unified Apps model (`IApp` discriminated union)
- Three app types exist: Global Apps, Partner Apps, Custom OIDC Apps
- For custom applications, use the Custom OIDC Apps flow (ORG-011)
- App credentials stored as `IOAuthCredentials` with hashed client secret
- Validate redirect URIs to prevent open redirector attacks
- App ID should be included in JWT claims
- App ID/Client ID is included in JWT claims
## Apps Architecture
The Apps system supports three types:
1. **Global Apps** (ORG-009) - First-party platform apps (foss.global, task.vc)
2. **Partner Apps** (ORG-010, DEV-008) - AppStore model for third-party apps
3. **Custom OIDC Apps** (ORG-011) - Organization-created OAuth/OIDC clients
## Related Stories
- ORG-009: Connect Global Apps
- ORG-010: Browse and Install Partner Apps
- ORG-011: Create Custom OIDC Apps
- DEV-005: Register OAuth Client App
- DEV-008: Submit App to AppStore
## Related TODOs
- `ts_idpclient/classes.idpclient.ts:30` - `id: '', // TODO`
+26 -3
View File
@@ -18,11 +18,34 @@ As a developer, I want to register my application as an OAuth client so that use
- [ ] Client credentials flow for server-to-server
## Technical Notes
- OAuth keywords in package.json suggest this is planned
- Implement OAuth 2.0 authorization server endpoints
- OAuth/OIDC client registration is now part of the Apps system
- **For organization owners**: Use Custom OIDC Apps (ORG-011) to create OAuth clients
- **For third-party developers**: Submit to AppStore (DEV-008) for public apps
- Standard OAuth 2.0 / OpenID Connect flows supported
- Scopes: openid, profile, email, organizations
- Consider OpenID Connect for identity layer
- PKCE is required for mobile and SPA security
## Implementation Path
This story's functionality is now implemented through:
1. **Custom OIDC Apps** (ORG-011) - Create org-specific OAuth clients via the Apps UI
2. **Partner Apps** (DEV-008) - Submit public apps to the AppStore
Both use the same underlying `IOAuthCredentials` model:
```typescript
interface IOAuthCredentials {
clientId: string;
clientSecretHash: string;
redirectUris: string[];
allowedScopes: string[];
grantTypes: ('authorization_code' | 'client_credentials' | 'refresh_token')[];
}
```
## Related Stories
- ORG-011: Create Custom OIDC Apps (primary implementation)
- DEV-004: Proper App ID Initialization
- DEV-008: Submit App to AppStore
## Related TODOs
- New feature - OAuth server implementation
@@ -0,0 +1,70 @@
# Submit App to AppStore
**ID:** DEV-008
**Priority:** Low
**Status:** Planned
**Phase:** 4
## User Story
As a developer, I want to submit my application to the AppStore so that other organizations can discover and install my app.
## Acceptance Criteria
- [ ] Submit a new app to the AppStore
- [ ] Provide app name, description, and logo
- [ ] Add screenshots for the store listing
- [ ] Select app category and tags
- [ ] Set pricing model (free, paid, freemium)
- [ ] Configure OAuth credentials (redirect URIs, scopes)
- [ ] Submit for review
- [ ] View submission status (draft, pending_review, approved, rejected, suspended)
- [ ] Receive notification on approval/rejection
- [ ] Edit app listing after approval
- [ ] View app analytics (install count, usage)
## Technical Notes
- Submitter organization becomes `ownerOrganizationId`
- Apps start in `draft` status, move to `pending_review` on submit
- Platform admins review and approve/reject apps
- Approved apps become visible in the AppStore
- App updates may require re-approval
## Approval Workflow
```
draft → pending_review → approved → published
↘ rejected
approved ↔ suspended (admin action)
```
## Data Model
```typescript
interface IPartnerApp {
id: string;
type: 'partner';
data: {
ownerOrganizationId: string;
appStoreMetadata: {
shortDescription: string;
longDescription: string;
screenshots: string[];
category: string;
tags: string[];
pricing: { model: 'free' | 'paid' | 'freemium' };
};
approvalStatus: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'suspended';
isPublished: boolean;
installCount: number;
// ... other fields
};
}
```
## UI Components
- **AppSubmissionView** (`/account/org/:orgName/apps/submit`) - Submit new partner app form
## Related Stories
- ORG-010: Browse and Install Partner Apps
- ORG-011: Create Custom OIDC Apps
- ADM-008: Review Partner App Submissions (new admin story)
@@ -2,27 +2,127 @@
**ID:** ORG-002
**Priority:** Critical
**Status:** Planned
**Status:** Complete
## User Story
As an organization owner, I want to invite team members to my organization and manage their access so that my team can collaborate securely.
## Acceptance Criteria
- [ ] Owner can invite users via email address
- [ ] Invited user receives email with invitation link
- [ ] Invitation can be accepted by existing users or during registration
- [ ] Owner can view pending invitations and resend/cancel them
- [ ] Owner can see all current members with their roles
- [ ] Owner can remove members from organization
- [ ] Owner can transfer ownership to another member
- [ ] Bulk invite via CSV upload
- [x] Owner can invite users via email address
- [x] Invited user receives email with invitation link
- [x] Invitation can be accepted by existing users or during registration
- [x] Owner can view pending invitations and resend/cancel them
- [x] Owner can see all current members with their roles
- [x] Owner can remove members from organization
- [x] Owner can transfer ownership to another member
- [x] Bulk invite via CSV upload
## Technical Implementation
### UserInvitation System
The invitation system uses a shared `UserInvitation` model that supports multiple organizations inviting the same email address.
#### Invitation Lifecycle
1. **Create**: Org admin invites email → `UserInvitation` created (or existing one is updated)
2. **Share**: Multiple orgs can link to the same invitation (by email)
3. **Convert**: When user registers with that email → invitation converts to real User
4. **Fold**: If existing user adds that email as secondary → invitation folds into existing user
5. **Expire**: Auto-delete after 90 days with cleanup of all org refs
#### Data Model
```typescript
// IUserInvitation
{
id: string;
data: {
email: string; // Unique key for sharing
token: string; // Secure invitation link token
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
createdAt: number;
expiresAt: number; // 90 days from creation
organizationRefs: Array<{ // Multiple orgs can share
organizationId: string;
invitedByUserId: string;
invitedAt: number;
roles: string[]; // Roles to assign on acceptance
}>;
acceptedAt?: number;
convertedToUserId?: string;
};
}
```
### Role System Enhancement
Users can have multiple roles within an organization:
```typescript
// IRole
{
id: string;
data: {
userId: string;
organizationId: string;
roles: string[]; // e.g., ['owner', 'billing-admin', 'developer']
};
}
```
Standard roles: `owner`, `admin`, `editor`, `viewer`, `guest`
Custom roles are also supported.
### API Endpoints
| Method | Purpose |
|--------|---------|
| `createInvitation` | Invite email to org with roles |
| `getOrgInvitations` | List pending invitations |
| `getOrgMembers` | List members with roles |
| `cancelInvitation` | Cancel pending invitation |
| `resendInvitation` | Resend invitation email |
| `removeMember` | Remove user from org |
| `updateMemberRoles` | Change member's roles |
| `transferOwnership` | Transfer org ownership |
| `acceptInvitation` | Accept invitation |
| `getInvitationByToken` | Get invitation details for landing page |
### Frontend Implementation
The Users page (`/account/org/:orgName/users`) provides:
- **Members tab**: List all members with roles, remove/edit actions
- **Pending tab**: List pending invitations with resend/cancel
- **Invite tab**: Form to invite by email with role selection
### Files
**Backend:**
- `ts_interfaces/data/loint-reception.userinvitation.ts` - Data interface
- `ts_interfaces/request/loint-reception.userinvitation.ts` - API contracts
- `ts/reception/classes.userinvitation.ts` - Model
- `ts/reception/classes.userinvitationmanager.ts` - Manager with handlers
- `ts/reception/classes.receptionmailer.ts` - Invitation email
**Frontend:**
- `ts_web/elements/account/views/usersview.ts` - Users page component
- `ts_web/elements/account/content.ts` - Route registration
- `ts_web/elements/account/navigation.ts` - Nav link
## Technical Notes
- Organization and User models exist with association
- Need new Invitation model with token and expiry
- Use `ReceptionMailer` for invitation emails
- RoleManager can be leveraged for role assignment
- Consider invitation expiry (7 days default)
- UserInvitation model stores invitation data with 90-day expiry
- `ReceptionMailer.sendInvitationEmail()` handles email delivery
- RoleManager updated to support `roles: string[]` array
- Backward compatible with existing single-role data
## Related Stories
- ORG-003: Assign Roles to Members (enhanced with multi-role support)
## Related TODOs
- 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
@@ -0,0 +1,65 @@
# Connect Global Apps
**ID:** ORG-009
**Priority:** High
**Status:** In Development
**Phase:** 1
## User Story
As an organization owner, I want to connect and disconnect first-party apps (foss.global, task.vc, etc.) for my organization so that my team members can use these integrated services.
## Acceptance Criteria
- [ ] View list of available global apps (foss.global, task.vc)
- [ ] See connection status for each global app
- [ ] Connect a global app to the organization
- [ ] Disconnect a global app from the organization
- [ ] View which user connected the app and when
- [ ] See what scopes/permissions each app requires
- [ ] Toggle does not require page reload
## Technical Notes
- Global apps are pre-registered by the platform administrators
- Uses `IAppConnection` to track org-to-app relationships
- Connection creates OAuth authorization for the app
- Apps access org data via granted scopes
- No credentials shown to org owners (managed by platform)
## Data Model
```typescript
interface IGlobalApp {
id: string;
type: 'global';
data: {
name: string;
description: string;
logoUrl: string;
appUrl: string;
oauthCredentials: IOAuthCredentials;
isActive: boolean;
category: string;
};
}
interface IAppConnection {
id: string;
data: {
organizationId: string;
appId: string;
appType: 'global' | 'partner' | 'custom_oidc';
status: 'active' | 'disconnected';
connectedAt: number;
connectedByUserId: string;
grantedScopes: string[];
};
}
```
## UI Components
- **AppsView** (`/account/org/:orgName/apps`) - Main tabbed interface
- **Global Apps Tab** - List of global apps with toggle switches
## Related Stories
- ORG-010: Browse and Install Partner Apps (AppStore)
- ORG-011: Create Custom OIDC Apps
- DEV-004: Proper App ID Initialization
@@ -0,0 +1,63 @@
# Browse and Install Partner Apps
**ID:** ORG-010
**Priority:** Medium
**Status:** Planned
**Phase:** 3
## User Story
As an organization owner, I want to browse and install partner apps from the AppStore so that my organization can benefit from third-party integrations.
## Acceptance Criteria
- [ ] Browse available partner apps in the AppStore
- [ ] Search apps by name or description
- [ ] Filter apps by category
- [ ] View curated sections (Featured, Popular, New)
- [ ] View app details (description, screenshots, pricing)
- [ ] See app install count and ratings
- [ ] Install/connect a partner app to the organization
- [ ] Uninstall/disconnect a partner app
- [ ] View installed apps list
## Technical Notes
- Partner apps are submitted by other organizations (DEV-008)
- Apps must be approved by platform admins before appearing in store
- Uses `IPartnerApp` with `appStoreMetadata`
- Connection uses same `IAppConnection` as global apps
## Data Model
```typescript
interface IPartnerApp {
id: string;
type: 'partner';
data: {
name: string;
description: string;
logoUrl: string;
appUrl: string;
ownerOrganizationId: string;
oauthCredentials: IOAuthCredentials;
appStoreMetadata: {
shortDescription: string;
longDescription: string;
screenshots: string[];
category: string;
tags: string[];
pricing: { model: 'free' | 'paid' | 'freemium' };
};
approvalStatus: TAppApprovalStatus;
isPublished: boolean;
installCount: number;
};
}
```
## UI Components
- **AppsView** - App Store tab with search and categories
- **AppStoreDetailView** (`/account/org/:orgName/apps/store/:appId`) - Full app details page
## Related Stories
- ORG-009: Connect Global Apps
- ORG-011: Create Custom OIDC Apps
- DEV-008: Submit App to AppStore
@@ -0,0 +1,70 @@
# Create Custom OIDC Apps
**ID:** ORG-011
**Priority:** Medium
**Status:** Planned
**Phase:** 2
## User Story
As an organization owner, I want to create custom OAuth/OIDC client applications so that I can integrate my own internal tools and services with the identity provider.
## Acceptance Criteria
- [ ] Create a new custom OIDC application
- [ ] Configure application name and description
- [ ] Upload application logo
- [ ] Set application URL
- [ ] Configure redirect URIs
- [ ] Select allowed OAuth scopes
- [ ] Choose grant types (authorization_code, client_credentials, refresh_token)
- [ ] View client ID and client secret
- [ ] Regenerate client secret if compromised
- [ ] Edit existing applications
- [ ] Delete applications
- [ ] Configure token lifetimes
## Technical Notes
- Custom OIDC apps are organization-scoped
- Client secret is hashed in database, shown only once at creation
- Redirect URIs validated to prevent open redirect attacks
- Standard OAuth 2.0 / OpenID Connect flows supported
- PKCE support for public clients
## Data Model
```typescript
interface ICustomOidcApp {
id: string;
type: 'custom_oidc';
data: {
name: string;
description: string;
logoUrl: string;
appUrl: string;
ownerOrganizationId: string;
oauthCredentials: IOAuthCredentials;
oidcSettings: {
accessTokenLifetime: number; // seconds
refreshTokenLifetime: number; // seconds
};
};
}
interface IOAuthCredentials {
clientId: string;
clientSecretHash: string;
redirectUris: string[];
allowedScopes: string[];
grantTypes: ('authorization_code' | 'client_credentials' | 'refresh_token')[];
}
```
## UI Components
- **AppsView** - Custom OIDC tab with app list
- **OidcAppFormView** (`/account/org/:orgName/apps/custom/new`) - Create new app form
- **OidcAppFormView** (`/account/org/:orgName/apps/custom/:appId`) - Edit existing app
## Related Stories
- ORG-009: Connect Global Apps
- ORG-010: Browse and Install Partner Apps
- DEV-004: Proper App ID Initialization
- DEV-005: Register OAuth Client App
+61
View File
@@ -0,0 +1,61 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { LoginSession } from '../ts/reception/classes.loginsession.js';
import { User } from '../ts/reception/classes.user.js';
import * as plugins from '../ts/plugins.js';
const createTestLoginSession = () => {
const loginSession = new LoginSession();
loginSession.id = 'test-session';
loginSession.data.userId = 'test-user';
(loginSession as LoginSession & { save: () => Promise<void> }).save = async () => undefined;
return loginSession;
};
tap.test('hashes passwords with argon2 and verifies them', async () => {
const passwordHash = await User.hashPassword('correct horse battery staple');
expect(passwordHash.startsWith('$argon2')).toBeTrue();
expect(await User.verifyPassword('correct horse battery staple', passwordHash)).toBeTrue();
expect(await User.verifyPassword('wrong password', passwordHash)).toBeFalse();
expect(User.shouldUpgradePasswordHash(passwordHash)).toBeFalse();
});
tap.test('accepts legacy sha256 hashes and marks them for upgrade', async () => {
const legacyHash = await plugins.smarthash.sha256FromString('legacy-password');
expect(User.isLegacyPasswordHash(legacyHash)).toBeTrue();
expect(await User.verifyPassword('legacy-password', legacyHash)).toBeTrue();
expect(await User.verifyPassword('different-password', legacyHash)).toBeFalse();
expect(User.shouldUpgradePasswordHash(legacyHash)).toBeTrue();
});
tap.test('rotates refresh tokens and detects reuse', async () => {
const loginSession = createTestLoginSession();
const firstRefreshToken = await loginSession.getRefreshToken();
const secondRefreshToken = await loginSession.getRefreshToken();
expect(firstRefreshToken.startsWith('refresh_')).toBeTrue();
expect(secondRefreshToken.startsWith('refresh_')).toBeTrue();
expect(firstRefreshToken).not.toEqual(secondRefreshToken);
expect(loginSession.data.refreshToken).toBeNullOrUndefined();
expect(loginSession.data.refreshTokenHash).toBeTruthy();
expect(await loginSession.validateRefreshToken(secondRefreshToken)).toEqual('current');
expect(await loginSession.validateRefreshToken(firstRefreshToken)).toEqual('reused');
await loginSession.invalidate();
expect(await loginSession.validateRefreshToken(secondRefreshToken)).toEqual('invalidated');
});
tap.test('persists transfer tokens as one-time hashes', async () => {
const loginSession = createTestLoginSession();
const transferToken = await loginSession.getTransferToken();
expect(transferToken.startsWith('transfer_')).toBeTrue();
expect(loginSession.data.transferTokenHash).toBeTruthy();
expect(await loginSession.validateTransferToken(transferToken)).toBeTrue();
expect(await loginSession.validateTransferToken(transferToken)).toBeFalse();
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.5.0',
version: '1.17.1',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
+58 -2
View File
@@ -4,14 +4,70 @@ import { Reception } from './reception/classes.reception.js';
export const runCli = async () => {
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({
feedMetadata: null,
domain: 'idp.global',
serveDir: paths.distWebDir,
securityHeaders: {
csp: {
defaultSrc: "'self'",
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.paddle.com", "https://public.profitwell.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.paddle.com", "https://assetbroker.lossless.one"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "data:"],
connectSrc: ["'self'", "https://*.paddle.com", "https://buy.paddle.com", "https://checkout.paddle.com", "https://checkout-service.paddle.com", "https://cdn.paddle.com", "https://*.sentry.io", "https://public.profitwell.com", "wss:"],
frameSrc: ["https://buy.paddle.com", "https://checkout.paddle.com", "https://*.paddle.com"],
},
},
addCustomRoutes: async (typedserver) => {
// Enable SPA fallback - serves index.html for non-file routes (e.g., /login, /dashboard)
typedserver.options.spaFallback = true;
// 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
const reception = new Reception({
reception = new Reception({
name: (await serviceQenv.getEnvVarOnDemand('INSTANCE_NAME')) || 'idp.global',
mongoDescriptor: {
mongoDbUrl: await serviceQenv.getEnvVarOnDemand('MONGODB_URL'),
@@ -21,5 +77,5 @@ export const runCli = async () => {
});
await reception.start();
await websiteServer.start();
await websiteServer.start(2999);
};
+4 -1
View File
@@ -1,6 +1,7 @@
// Native scope
import * as crypto from 'node:crypto';
import * as path from 'path';
export { path };
export { crypto, path };
// Project scope
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
@@ -32,8 +33,10 @@ import * as smartpromise from '@push.rocks/smartpromise';
import * as smarttime from '@push.rocks/smarttime';
import * as smartunique from '@push.rocks/smartunique';
import * as taskbuffer from '@push.rocks/taskbuffer';
import * as argon2 from 'argon2';
export {
argon2,
lik,
projectinfo,
qenv,
+87
View File
@@ -0,0 +1,87 @@
# `ts/` Backend Module
The `ts/` folder contains the server runtime for `idp.global`: startup, website server wiring, typed routes, OIDC endpoints, and the core `Reception` managers.
## 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.
## What Lives Here
- `index.ts` boots the service, loads env vars, starts the website server, and mounts OIDC endpoints.
- `reception/classes.reception.ts` creates the service container and initializes all managers.
- `reception/` contains the domain logic for users, sessions, orgs, roles, invites, apps, billing, and OIDC.
- `plugins.ts` centralizes external imports used by the backend.
## Startup Behavior
The backend startup in `ts/index.ts` does four main things:
1. Loads runtime configuration from `.nogit` and the working directory.
2. Creates a `UtilityWebsiteServer` that serves the built frontend.
3. Registers OIDC endpoints such as discovery, JWKS, authorize, token, userinfo, and revoke.
4. Creates and starts `Reception`, then starts HTTP serving on port `2999`.
## Required Environment
```bash
export MONGODB_URL=mongodb://localhost:27017/idp-dev
export IDP_BASEURL=http://localhost:2999
export INSTANCE_NAME=idp-dev
```
Optional:
- `SERVEZONE_PLATFROM_AUTHORIZATION`
- `PADDLE_TOKEN`
- `PADDLE_PRICE_ID`
## Key Managers
| Class | Responsibility |
| --- | --- |
| `JwtManager` | JWT issuance, validation, and key rotation support |
| `LoginSessionManager` | Session creation, refresh, logout, and session metadata |
| `RegistrationSessionManager` | Registration flow state |
| `UserManager` | User-centric queries and mutations |
| `OrganizationManager` | Organization creation and access checks |
| `RoleManager` | Role and permission management |
| `UserInvitationManager` | Invitations, member updates, and ownership transfer |
| `BillingPlanManager` | Billing plan state and Paddle config endpoint |
| `AppManager` | Global app administration |
| `AppConnectionManager` | App connection tracking |
| `ActivityLogManager` | User activity logging |
| `OidcManager` | OIDC discovery, auth code flow, token exchange, userinfo, revoke |
## Local Development
From the repository root:
```bash
pnpm install
pnpm build
pnpm watch
```
The watch setup runs the backend from `ts/` and rebuilds the frontend bundle from `ts_web/`.
## 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.
+62
View File
@@ -0,0 +1,62 @@
import * as plugins from '../plugins.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
/**
* ActivityLog tracks user actions for audit and display purposes
*/
@plugins.smartdata.Manager()
export class ActivityLog extends plugins.smartdata.SmartDataDbDoc<
ActivityLog,
plugins.idpInterfaces.data.IActivityLog,
ActivityLogManager
> {
// ======
// static
// ======
public static async createActivityLog(
managerArg: ActivityLogManager,
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
const activityLog = new managerArg.CActivityLog();
activityLog.id = plugins.smartunique.shortId();
activityLog.data = {
userId,
action,
timestamp: Date.now(),
metadata: {
description,
...metadata,
},
};
await activityLog.save();
return activityLog;
}
// ========
// INSTANCE
// ========
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IActivityLog['data'] = {
userId: null,
action: null,
timestamp: null,
metadata: {
description: null,
},
};
constructor() {
super();
}
}
@@ -0,0 +1,77 @@
import * as plugins from '../plugins.js';
import { ActivityLog } from './classes.activitylog.js';
import { Reception } from './classes.reception.js';
export class ActivityLogManager {
// refs
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public CActivityLog = plugins.smartdata.setDefaultManagerForDoc(this, ActivityLog);
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
// Get user activity handler
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'getUserActivity',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
const limit = requestArg.limit || 20;
const offset = requestArg.offset || 0;
// Get activities for this user
const activities = await this.CActivityLog.getInstances({
'data.userId': jwt.data.userId,
});
// Sort by timestamp descending
const sortedActivities = activities
.sort((a, b) => b.data.timestamp - a.data.timestamp)
.slice(offset, offset + limit);
return {
activities: sortedActivities.map((a) => ({
id: a.id,
data: a.data,
})),
total: activities.length,
};
}
)
);
}
/**
* Log a user activity
*/
public async logActivity(
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
return await ActivityLog.createActivityLog(
this,
userId,
action,
description,
metadata
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import * as plugins from '../plugins.js';
import type { AppManager } from './classes.appmanager.js';
@plugins.smartdata.Manager()
export class App extends plugins.smartdata.SmartDataDbDoc<
App,
plugins.idpInterfaces.data.IAppDocument,
AppManager
> {
// INSTANCE
@plugins.smartdata.unI()
id: plugins.idpInterfaces.data.IAppDocument['id'];
@plugins.smartdata.svDb()
type: plugins.idpInterfaces.data.IAppDocument['type'];
@plugins.smartdata.svDb()
data: plugins.idpInterfaces.data.IAppDocument['data'];
/**
* Check if the app is a global app
*/
public isGlobalApp(): this is App & { type: 'global' } {
return this.type === 'global';
}
/**
* Check if the app is a partner app
*/
public isPartnerApp(): this is App & { type: 'partner' } {
return this.type === 'partner';
}
/**
* Check if the app is a custom OIDC app
*/
public isCustomOidcApp(): this is App & { type: 'custom_oidc' } {
return this.type === 'custom_oidc';
}
}
+41
View File
@@ -0,0 +1,41 @@
import * as plugins from '../plugins.js';
import type { AppConnectionManager } from './classes.appconnectionmanager.js';
@plugins.smartdata.Manager()
export class AppConnection extends plugins.smartdata.SmartDataDbDoc<
AppConnection,
plugins.idpInterfaces.data.IAppConnection,
AppConnectionManager
> {
// INSTANCE
@plugins.smartdata.unI()
id: plugins.idpInterfaces.data.IAppConnection['id'];
@plugins.smartdata.svDb()
data: plugins.idpInterfaces.data.IAppConnection['data'];
/**
* Check if the connection is active
*/
public isActive(): boolean {
return this.data.status === 'active';
}
/**
* Disconnect the app
*/
public async disconnect(): Promise<void> {
this.data.status = 'disconnected';
await this.save();
}
/**
* Reconnect the app
*/
public async reconnect(userId: string): Promise<void> {
this.data.status = 'active';
this.data.connectedAt = Date.now();
this.data.connectedByUserId = userId;
await this.save();
}
}
@@ -0,0 +1,187 @@
import * as plugins from '../plugins.js';
import type { Reception } from './classes.reception.js';
import { AppConnection } from './classes.appconnection.js';
export class AppConnectionManager {
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedrouter = new plugins.typedrequest.TypedRouter();
public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
// Handler: Get app connections for an organization
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'getAppConnections',
async (requestArg) => {
// Verify JWT and get user
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
const user = await this.receptionRef.userManager.CUser.getInstance({
id: jwtData.data.userId,
});
// Check user has access to the organization
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: requestArg.organizationId,
});
if (!organization) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: {
organizationId: organization.id,
userId: user.id,
},
});
if (!role) {
throw new plugins.typedrequest.TypedResponseError(
'User not authorized for this organization'
);
}
// Get all connections for this organization
const connections = await this.CAppConnection.getInstances({
'data.organizationId': requestArg.organizationId,
});
const connectionObjects = await Promise.all(
connections.map(async (conn) => await conn.createSavableObject())
);
return {
connections: connectionObjects,
};
}
)
);
// Handler: Toggle app connection (connect/disconnect)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
'toggleAppConnection',
async (requestArg) => {
// Verify JWT and get user
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
const user = await this.receptionRef.userManager.CUser.getInstance({
id: jwtData.data.userId,
});
// Check user has admin access to the organization
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: requestArg.organizationId,
});
if (!organization) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
const isAdmin = await organization.checkIfUserIsAdmin(user);
if (!isAdmin) {
throw new plugins.typedrequest.TypedResponseError(
'Only organization admins can manage app connections'
);
}
// Get the app
const app = await this.receptionRef.appManager.getAppById(requestArg.appId);
if (!app) {
throw new plugins.typedrequest.TypedResponseError('App not found');
}
// Find existing connection
let connection = await this.CAppConnection.getInstance({
'data.organizationId': requestArg.organizationId,
'data.appId': requestArg.appId,
});
if (requestArg.action === 'connect') {
if (connection && connection.isActive()) {
// Already connected
return {
success: true,
connection: await connection.createSavableObject(),
};
}
if (connection) {
// Reconnect existing connection
await connection.reconnect(user.id);
} else {
// Create new connection
connection = new AppConnection();
connection.id = plugins.smartunique.shortId();
connection.data = {
organizationId: requestArg.organizationId,
appId: requestArg.appId,
appType: app.type,
status: 'active',
connectedAt: Date.now(),
connectedByUserId: user.id,
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
};
await connection.save();
}
return {
success: true,
connection: await connection.createSavableObject(),
};
} else {
// Disconnect
if (!connection) {
return {
success: true,
};
}
await connection.disconnect();
return {
success: true,
connection: await connection.createSavableObject(),
};
}
}
)
);
}
/**
* Get all connections for an organization
*/
public async getConnectionsForOrganization(organizationId: string): Promise<AppConnection[]> {
return await this.CAppConnection.getInstances({
'data.organizationId': organizationId,
});
}
/**
* Get connection for a specific app and organization
*/
public async getConnection(
organizationId: string,
appId: string
): Promise<AppConnection | null> {
return await this.CAppConnection.getInstance({
'data.organizationId': organizationId,
'data.appId': appId,
});
}
/**
* Check if an app is connected to an organization
*/
public async isAppConnected(organizationId: string, appId: string): Promise<boolean> {
const connection = await this.getConnection(organizationId, appId);
return connection?.isActive() || false;
}
}
+316
View File
@@ -0,0 +1,316 @@
import * as plugins from '../plugins.js';
import type { Reception } from './classes.reception.js';
import { App } from './classes.app.js';
// Note: App class is imported for use with setDefaultManagerForDoc
export class AppManager {
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedrouter = new plugins.typedrequest.TypedRouter();
public CApp = plugins.smartdata.setDefaultManagerForDoc(this, App);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
// Handler: Get all global apps (for org owners)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
'getGlobalApps',
async (requestArg) => {
// Verify JWT
await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
// Get all active global apps
const globalApps = await this.CApp.getInstances({
type: 'global',
'data.isActive': true,
});
const appObjects = await Promise.all(
globalApps.map(async (app) => await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp)
);
return {
apps: appObjects,
};
}
)
);
// Handler: Check if user is global admin
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
'checkGlobalAdmin',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwt(requestArg.jwt);
return {
isGlobalAdmin: user?.data?.isGlobalAdmin ?? false,
};
}
)
);
// Handler: Get global apps with stats (admin only)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
'getGlobalAppStats',
async (requestArg) => {
await this.verifyGlobalAdmin(requestArg.jwt);
// Get all global apps (including inactive)
const globalApps = await this.CApp.getInstances({
type: 'global',
});
const appsWithStats = await Promise.all(
globalApps.map(async (app) => {
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
'data.appId': app.id,
'data.status': 'active',
});
return {
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
connectionCount: connections.length,
};
})
);
return { apps: appsWithStats };
}
)
);
// Handler: Create global app (admin only)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
'createGlobalApp',
async (requestArg) => {
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
// Generate OAuth credentials
const clientId = `app-${plugins.smartunique.shortId(12)}`;
const clientSecret = plugins.smartunique.shortId(32);
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
const app = new this.CApp();
app.id = `app-${plugins.smartunique.shortId(8)}`;
app.type = 'global';
app.data = {
name: requestArg.name,
description: requestArg.description,
logoUrl: requestArg.logoUrl,
appUrl: requestArg.appUrl,
category: requestArg.category,
isActive: true,
createdAt: Date.now(),
createdByUserId: jwtData.data.userId,
oauthCredentials: {
clientId,
clientSecretHash,
redirectUris: requestArg.redirectUris,
allowedScopes: requestArg.allowedScopes,
grantTypes: ['authorization_code', 'refresh_token'],
},
};
await app.save();
return {
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
clientSecret, // Only shown once
};
}
)
);
// Handler: Update global app (admin only)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
'updateGlobalApp',
async (requestArg) => {
await this.verifyGlobalAdmin(requestArg.jwt);
const app = await this.CApp.getInstance({ id: requestArg.appId });
if (!app) {
throw new Error('App not found');
}
if (!app.isGlobalApp()) {
throw new Error('Can only update global apps');
}
// Update allowed fields - cast data to global app type after type guard
const appData = app.data as plugins.idpInterfaces.data.IGlobalApp['data'];
if (requestArg.updates.name !== undefined) appData.name = requestArg.updates.name;
if (requestArg.updates.description !== undefined) appData.description = requestArg.updates.description;
if (requestArg.updates.logoUrl !== undefined) appData.logoUrl = requestArg.updates.logoUrl;
if (requestArg.updates.appUrl !== undefined) appData.appUrl = requestArg.updates.appUrl;
if (requestArg.updates.category !== undefined) appData.category = requestArg.updates.category;
if (requestArg.updates.isActive !== undefined) appData.isActive = requestArg.updates.isActive;
if (requestArg.updates.redirectUris !== undefined) appData.oauthCredentials.redirectUris = requestArg.updates.redirectUris;
if (requestArg.updates.allowedScopes !== undefined) appData.oauthCredentials.allowedScopes = requestArg.updates.allowedScopes;
await app.save();
return {
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
};
}
)
);
// Handler: Delete global app (admin only)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
'deleteGlobalApp',
async (requestArg) => {
await this.verifyGlobalAdmin(requestArg.jwt);
const app = await this.CApp.getInstance({ id: requestArg.appId });
if (!app) {
throw new Error('App not found');
}
// Get and disconnect all connections
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
'data.appId': requestArg.appId,
});
for (const connection of connections) {
await connection.delete();
}
await app.delete();
return {
success: true,
disconnectedOrganizations: connections.length,
};
}
)
);
// Handler: Regenerate OAuth credentials (admin only)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
'regenerateAppCredentials',
async (requestArg) => {
await this.verifyGlobalAdmin(requestArg.jwt);
const app = await this.CApp.getInstance({ id: requestArg.appId });
if (!app) {
throw new Error('App not found');
}
// Generate new credentials
const clientId = `app-${plugins.smartunique.shortId(12)}`;
const clientSecret = plugins.smartunique.shortId(32);
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
app.data.oauthCredentials.clientId = clientId;
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
await app.save();
return {
clientId,
clientSecret, // Only shown once
};
}
)
);
}
/**
* Verify that the user is a global admin
*/
private async verifyGlobalAdmin(jwt: string) {
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwt);
const user = await this.receptionRef.userManager.getUserByJwt(jwt);
if (!user?.data?.isGlobalAdmin) {
throw new Error('Access denied: Global admin privileges required');
}
return jwtData;
}
/**
* Get all global apps
*/
public async getGlobalApps(): Promise<App[]> {
return await this.CApp.getInstances({
type: 'global',
});
}
/**
* Get app by ID
*/
public async getAppById(appId: string): Promise<App | null> {
return await this.CApp.getInstance({
id: appId,
});
}
/**
* Seed initial global apps (for development/testing)
*/
public async seedGlobalApps() {
const defaultGlobalApps: Partial<plugins.idpInterfaces.data.IGlobalApp>[] = [
{
id: 'app-foss-global',
type: 'global',
data: {
name: 'foss.global',
description: 'Open Source Package Registry and Collaboration Platform',
logoUrl: 'https://foss.global/assets/logo.png',
appUrl: 'https://foss.global',
oauthCredentials: {
clientId: 'foss-global-client',
clientSecretHash: '', // Will be set when OAuth is configured
redirectUris: ['https://foss.global/auth/callback'],
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
grantTypes: ['authorization_code', 'refresh_token'],
},
isActive: true,
category: 'Development',
createdAt: Date.now(),
createdByUserId: 'system',
},
},
{
id: 'app-task-vc',
type: 'global',
data: {
name: 'task.vc',
description: 'Task Management and Project Collaboration',
logoUrl: 'https://task.vc/assets/logo.png',
appUrl: 'https://task.vc',
oauthCredentials: {
clientId: 'task-vc-client',
clientSecretHash: '',
redirectUris: ['https://task.vc/auth/callback'],
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
grantTypes: ['authorization_code', 'refresh_token'],
},
isActive: true,
category: 'Productivity',
createdAt: Date.now(),
createdByUserId: 'system',
},
},
];
for (const appData of defaultGlobalApps) {
const existing = await this.CApp.getInstance({ id: appData.id });
if (!existing) {
const app = new this.CApp();
app.id = appData.id!;
app.type = appData.type!;
app.data = appData.data as any;
await app.save();
}
}
}
}
+12 -1
View File
@@ -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'),
})
)
);
}
}
+36 -14
View File
@@ -1,5 +1,6 @@
import * as plugins from '../plugins.js';
import { JwtManager } from './classes.jwtmanager.js';
import type { LoginSession } from './classes.loginsession.js';
/**
* a User is identified by its username or email.
@@ -11,21 +12,27 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
public static async createJwtForRefreshToken(
jwtManagerInstance: JwtManager,
refreshTokenArg: string
) {
const loginSession =
await jwtManagerInstance.receptionRef.loginSessionManager.CLoginSession.getLoginSessionByRefreshToken(
): Promise<string | null> {
const sessionLookup =
await jwtManagerInstance.receptionRef.loginSessionManager.findLoginSessionByRefreshToken(
refreshTokenArg
);
if (!loginSession) {
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
return null;
}
const refreshTokenValid = await loginSession.validateRefreshToken(refreshTokenArg);
if (!refreshTokenValid) {
return null;
return this.createJwtForLoginSession(jwtManagerInstance, sessionLookup.loginSession);
}
public static async createJwtForLoginSession(
jwtManagerInstance: JwtManager,
loginSession: LoginSession
): Promise<string | null> {
const user = await jwtManagerInstance.receptionRef.userManager.CUser.getInstance({
id: loginSession.data.userId,
});
if (!user) {
return null;
}
const validUntil = plugins.smarttime.ExtendedDate.fromMillis(
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 1 })
);
@@ -33,10 +40,10 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
jwt.id = plugins.smartunique.shortId();
jwt.data = {
userId: user.id,
sessionId: loginSession.id,
validUntil: validUntil.getTime(),
refreshEvery: 1000000,
refreshFrom: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 0.5 }),
refreshToken: await loginSession.getRefreshToken(), // TODO: handle multiple refresh tokens
justForLooks: {
validUntilIsoString: validUntil.toISOString(),
}
@@ -46,7 +53,7 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
const jwtString = await jwtManagerInstance.smartjwtInstance.createJWT({
id: jwt.id,
blocked: null,
blocked: false,
data: jwt.data,
} as plugins.idpInterfaces.data.IJwt);
return jwtString;
@@ -68,11 +75,26 @@ export class Jwt extends plugins.smartdata.SmartDataDbDoc<Jwt, plugins.idpInterf
}
public async getLoginSession() {
const loginSession = await this.manager.receptionRef.loginSessionManager.CLoginSession.getInstance({
data: {
refreshToken: this.data.refreshToken,
}
if (this.data.sessionId) {
return this.manager.receptionRef.loginSessionManager.CLoginSession.getInstance({
id: this.data.sessionId,
});
return loginSession;
}
if (!this.data.refreshToken) {
return null;
}
const sessionLookup =
await this.manager.receptionRef.loginSessionManager.findLoginSessionByRefreshToken(
this.data.refreshToken
);
if (!sessionLookup) {
return null;
}
return sessionLookup.loginSession;
}
}
+40 -4
View File
@@ -25,10 +25,41 @@ export class JwtManager {
new plugins.typedrequest.TypedHandler(
'refreshJwt',
async (requestArg) => {
const resultJwt = await Jwt.createJwtForRefreshToken(this, requestArg.refreshToken);
const sessionLookup =
await this.receptionRef.loginSessionManager.findLoginSessionByRefreshToken(
requestArg.refreshToken
);
if (!sessionLookup || sessionLookup.validationStatus === 'invalid') {
return {
status: 'not found',
};
}
if (sessionLookup.validationStatus === 'invalidated') {
return {
status: 'invalidated',
};
}
if (sessionLookup.validationStatus === 'reused') {
await sessionLookup.loginSession.invalidate();
return {
status: 'invalidated',
};
}
const rotatedRefreshToken = await sessionLookup.loginSession.getRefreshToken();
const resultJwt = await Jwt.createJwtForLoginSession(this, sessionLookup.loginSession);
if (!rotatedRefreshToken || !resultJwt) {
return {
status: 'invalidated',
};
}
return {
status: 'loggedIn',
jwt: resultJwt,
refreshToken: rotatedRefreshToken,
};
}
)
@@ -120,19 +151,24 @@ export class JwtManager {
await this.pushPublicKeyToClients();
}
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> {
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt | null> {
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
const jwt = await Jwt.getInstance({
const jwt = await this.CJwt.getInstance({
id: jwtData.id,
});
if (!jwt) {
return null;
}
if (jwt.blocked) {
return null;
}
if (jwt) {
const loginSession = await jwt.getLoginSession();
if (!loginSession) {
if (!loginSession || loginSession.data.invalidated) {
await jwt.block();
if (!this.blockedJwtIdList.includes(jwt.id)) {
this.blockedJwtIdList.push(jwt.id);
}
return null;
}
}
+95 -12
View File
@@ -2,6 +2,8 @@ import * as plugins from '../plugins.js';
import { LoginSessionManager } from './classes.loginsessionmanager.js';
import { User } from './classes.user.js';
export type TRefreshTokenValidationResult = 'current' | 'invalid' | 'invalidated' | 'reused';
/**
* a LoginSession keeps track of a login over the whole time of the user being loggedin
*/
@@ -40,7 +42,14 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
}
public static async getLoginSessionByRefreshToken(refreshTokenArg: string) {
const loginSession = await LoginSession.getInstance({
const refreshTokenHash = await LoginSession.hashSessionToken(refreshTokenArg);
let loginSession = await LoginSession.getInstance({
'data.refreshTokenHash': refreshTokenHash,
});
if (loginSession) {
return loginSession;
}
loginSession = await LoginSession.getInstance({
data: {
refreshToken: refreshTokenArg,
},
@@ -48,6 +57,14 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
return loginSession;
}
public static async hashSessionToken(tokenArg: string) {
return plugins.smarthash.sha256FromString(tokenArg);
}
public static createOpaqueToken(prefixArg: string) {
return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`;
}
// ========
// INSTANCE
// ========
@@ -60,10 +77,17 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
invalidated: false,
refreshToken: null,
deviceId: null
refreshTokenHash: null,
rotatedRefreshTokenHashes: [],
transferTokenHash: null,
transferTokenExpiresAt: null,
deviceId: null,
deviceInfo: null,
createdAt: Date.now(),
lastActive: Date.now(),
};
public transferToken: string;
public transferToken: string | null = null;
constructor() {
super();
@@ -74,40 +98,99 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
*/
public async invalidate() {
this.data.invalidated = true;
this.data.refreshToken = null;
this.data.refreshTokenHash = null;
this.data.transferTokenHash = null;
this.data.transferTokenExpiresAt = null;
await this.save();
}
/**
* a refresh token is unique to a login session and ONLY created once per login session
* a refresh token is unique to a login session and rotated whenever it is issued
* @returns
*/
public async getRefreshToken() {
if (this.data.invalidated) {
console.log('login session is invalidated. no refresh token can be generated.');
return null;
}
if (!this.data.refreshToken) {
this.data.refreshToken = plugins.smartunique.uni('refresh_');
const previousRefreshTokenHash =
this.data.refreshTokenHash ||
(this.data.refreshToken
? await LoginSession.hashSessionToken(this.data.refreshToken)
: null);
if (previousRefreshTokenHash) {
this.data.rotatedRefreshTokenHashes = [
...(this.data.rotatedRefreshTokenHashes || []),
previousRefreshTokenHash,
].slice(-5);
}
const refreshToken = LoginSession.createOpaqueToken('refresh_');
this.data.refreshTokenHash = await LoginSession.hashSessionToken(refreshToken);
this.data.refreshToken = null;
this.data.lastActive = Date.now();
await this.save();
return this.data.refreshToken;
return refreshToken;
}
public async getTransferToken() {
this.transferToken = plugins.smartunique.uni('transfer_');
this.transferToken = LoginSession.createOpaqueToken('transfer_');
this.data.transferTokenHash = await LoginSession.hashSessionToken(this.transferToken);
this.data.transferTokenExpiresAt =
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 5 });
await this.save();
return this.transferToken;
}
public async validateRefreshToken(refreshTokenArg: string) {
return this.data.refreshToken === refreshTokenArg;
public async validateRefreshToken(
refreshTokenArg: string
): Promise<TRefreshTokenValidationResult> {
if (this.data.invalidated) {
return 'invalidated';
}
const refreshTokenHash = await LoginSession.hashSessionToken(refreshTokenArg);
if (
this.data.refreshTokenHash === refreshTokenHash ||
(!!this.data.refreshToken && this.data.refreshToken === refreshTokenArg)
) {
return 'current';
}
if ((this.data.rotatedRefreshTokenHashes || []).includes(refreshTokenHash)) {
return 'reused';
}
return 'invalid';
}
public async validateTransferToken(transferTokenArg: string) {
const result = this.transferToken === transferTokenArg;
if (this.data.invalidated || !this.data.transferTokenHash) {
return false;
}
if (
this.data.transferTokenExpiresAt &&
this.data.transferTokenExpiresAt < Date.now()
) {
this.data.transferTokenHash = null;
this.data.transferTokenExpiresAt = null;
await this.save();
return false;
}
const result =
this.data.transferTokenHash ===
(await LoginSession.hashSessionToken(transferTokenArg));
// a transfer token can only be used once, so we invalidate it here
if (result) {
this.transferToken = null;
this.data.transferTokenHash = null;
this.data.transferTokenExpiresAt = null;
await this.save();
}
return result;
}
+172 -40
View File
@@ -1,5 +1,5 @@
import * as plugins from '../plugins.js';
import { LoginSession } from './classes.loginsession.js';
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
import { Reception } from './classes.reception.js';
import { logger } from './logging.js';
@@ -32,9 +32,6 @@ export class LoginSessionManager {
let user = await this.receptionRef.userManager.CUser.getInstance({
data: {
username: requestData.username,
passwordHash: await this.receptionRef.userManager.CUser.hashPassword(
requestData.password
),
},
});
@@ -42,33 +39,30 @@ export class LoginSessionManager {
user = await this.receptionRef.userManager.CUser.getInstance({
data: {
email: requestData.username,
passwordHash: await this.receptionRef.userManager.CUser.hashPassword(
requestData.password
),
},
});
}
if (user) {
// lets recheck
if (
(user.data.username !== requestData.username &&
user.data.email !== requestData.username) ||
user.data.passwordHash !==
(await this.receptionRef.userManager.CUser.hashPassword(requestData.password))
) {
throw new Error(
'database returned a user that does not match wanted criterea. CRITICAL!'
if (user && (await this.receptionRef.userManager.CUser.verifyPassword(
requestData.password,
user.data.passwordHash
))) {
if (this.receptionRef.userManager.CUser.shouldUpgradePasswordHash(user.data.passwordHash)) {
user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword(
requestData.password
);
await user.save();
}
const loginSession = await LoginSession.createLoginSessionForUser(user);
this.loginSessions.add(loginSession);
const refreshToken = await loginSession.getRefreshToken();
if (!refreshToken) {
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
}
return {
status: 'ok',
refreshToken: refreshToken,
refreshToken,
twoFaNeeded: false,
};
} else {
@@ -109,12 +103,14 @@ export class LoginSessionManager {
} else {
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
}
const testOnlyToken =
process.env.TEST_MODE && existingUser
? this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
?.token
: undefined;
return {
status: 'ok',
testOnlyToken: process.env.TEST_MODE
? this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
.token
: null,
testOnlyToken,
};
}
)
@@ -133,10 +129,17 @@ export class LoginSessionManager {
email: requestArg.email,
},
});
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
const loginSession = await LoginSession.createLoginSessionForUser(user);
this.loginSessions.add(loginSession);
const refreshToken = await loginSession.getRefreshToken();
if (!refreshToken) {
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
}
return {
refreshToken: await loginSession.getRefreshToken(),
refreshToken,
};
} else {
throw new plugins.typedrequest.TypedResponseError('Validation Token not found');
@@ -147,8 +150,11 @@ export class LoginSessionManager {
this.typedRouter.addTypedHandler<plugins.idpInterfaces.request.ILogoutRequest>(
new plugins.typedrequest.TypedHandler('logout', async (requestDataArg) => {
const loginSession = await this.CLoginSession.getLoginSessionByRefreshToken(requestDataArg.refreshToken);
await loginSession.invalidate();
const sessionLookup = await this.findLoginSessionByRefreshToken(requestDataArg.refreshToken);
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
throw new plugins.typedrequest.TypedResponseError('Invalid refresh token');
}
await sessionLookup.loginSession.invalidate();
return {}
})
);
@@ -158,31 +164,39 @@ export class LoginSessionManager {
'exchangeRefreshTokenAndTransferToken',
async (requestDataArg) => {
switch (true) {
case !!requestDataArg.refreshToken:
const loginSession = await this.loginSessions.find(async (loginSessionArg) => {
return loginSessionArg.validateRefreshToken(requestDataArg.refreshToken);
});
if (!loginSession) {
case !!requestDataArg.refreshToken: {
const sessionLookup = await this.findLoginSessionByRefreshToken(
requestDataArg.refreshToken
);
if (!sessionLookup || sessionLookup.validationStatus !== 'current') {
if (sessionLookup?.validationStatus === 'reused') {
await sessionLookup.loginSession.invalidate();
}
throw new plugins.typedrequest.TypedResponseError('your refresh token is invalid');
}
return {
transferToken: await loginSession.getTransferToken(),
transferToken: await sessionLookup.loginSession.getTransferToken(),
};
break;
case !!requestDataArg.transferToken:
let transferToken: string;
const loginSession2 = await this.loginSessions.find(async (loginSessionArg) => {
return loginSessionArg.validateTransferToken(requestDataArg.transferToken);
});
}
case !!requestDataArg.transferToken: {
const loginSession2 = await this.findLoginSessionByTransferToken(
requestDataArg.transferToken
);
if (!loginSession2) {
throw new plugins.typedrequest.TypedResponseError(
'Your transfer token is not valid.'
);
}
const refreshToken = await loginSession2.getRefreshToken();
if (!refreshToken) {
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
}
return {
refreshToken: await loginSession2.getRefreshToken(),
refreshToken,
};
break;
}
default:
throw new plugins.typedrequest.TypedResponseError('Invalid token exchange request');
}
}
)
@@ -259,6 +273,124 @@ export class LoginSessionManager {
ok: false
}
})
);
// Get all sessions for the current user
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'getUserSessions',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
const currentLoginSession = await jwt.getLoginSession();
// 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.id === currentLoginSession?.id,
})),
};
}
)
);
// 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');
}
const currentLoginSession = await jwt.getLoginSession();
// Don't allow revoking the current session via this method
if (sessionToRevoke.id === currentLoginSession?.id) {
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 };
}
)
);
}
public async findLoginSessionByRefreshToken(refreshTokenArg: string): Promise<{
loginSession: LoginSession;
validationStatus: TRefreshTokenValidationResult;
} | null> {
const directMatch = await this.CLoginSession.getLoginSessionByRefreshToken(refreshTokenArg);
if (directMatch) {
return {
loginSession: directMatch,
validationStatus: await directMatch.validateRefreshToken(refreshTokenArg),
};
}
const loginSessions = await this.CLoginSession.getInstances({});
for (const loginSession of loginSessions) {
const validationStatus = await loginSession.validateRefreshToken(refreshTokenArg);
if (validationStatus !== 'invalid') {
return {
loginSession,
validationStatus,
};
}
}
return null;
}
public async findLoginSessionByTransferToken(transferTokenArg: string) {
const transferTokenHash = await LoginSession.hashSessionToken(transferTokenArg);
const loginSession = await this.CLoginSession.getInstance({
'data.transferTokenHash': transferTokenHash,
});
if (!loginSession) {
return null;
}
const isValid = await loginSession.validateTransferToken(transferTokenArg);
return isValid ? loginSession : null;
}
}
+683
View File
@@ -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
}
}
+1 -1
View File
@@ -35,6 +35,6 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
public async checkIfUserIsAdmin(userArg: User) {
const role = await this.manager.receptionRef.roleManager.getRoleForUserAndOrg(userArg, this);
return role.data.role === 'admin';
return role.data.roles?.includes('admin') || role.data.roles?.includes('owner');
}
}
+3 -2
View File
@@ -50,13 +50,14 @@ export class OrganizationManager {
action: 'create',
organizationId: newOrg.id,
userId: userData.id,
role: 'admin',
roles: ['owner'],
});
newOrg.data.roleIds.push(role.id);
await newOrg.save();
return {
nameAvailable: true,
resultingOrganization: await newOrg.createSavableObject()
resultingOrganization: await newOrg.createSavableObject(),
role: await role.createSavableObject(),
}
break;
}
+10 -2
View File
@@ -1,5 +1,4 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from './logging.js';
import { JwtManager } from './classes.jwtmanager.js';
@@ -13,6 +12,11 @@ import { ReceptionHousekeeping } from './classes.housekeeping.js';
import { OrganizationManager } from './classes.organizationmanager.js';
import { RoleManager } from './classes.rolemanager.js';
import { BillingPlanManager } from './classes.billingplanmanager.js';
import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
import { UserInvitationManager } from './classes.userinvitationmanager.js';
import { OidcManager } from './classes.oidcmanager.js';
export interface IReceptionOptions {
/**
@@ -25,7 +29,6 @@ export interface IReceptionOptions {
}
export class Reception {
public projectinfoNpm = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
public typedrouter = new plugins.typedrequest.TypedRouter();
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
public szPlatformClient = new plugins.szPlatformClient.SzPlatformClient();
@@ -41,6 +44,11 @@ export class Reception {
public organizationmanager = new OrganizationManager(this);
public roleManager = new RoleManager(this);
public billingPlanManager = new BillingPlanManager(this);
public appManager = new AppManager(this);
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
public userInvitationManager = new UserInvitationManager(this);
public oidcManager = new OidcManager(this);
housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) {
-1
View File
@@ -10,7 +10,6 @@ export class ReceptionDb {
}
public async start() {
console.log(this.receptionRef.options.mongoDescriptor);
this.smartdataDb = new plugins.smartdata.SmartdataDb(this.receptionRef.options.mongoDescriptor);
await this.smartdataDb.init();
}
+29
View File
@@ -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>
`),
});
}
}
+48 -2
View File
@@ -15,13 +15,24 @@ export class RoleManager {
this.receptionRef = receptionRefArg;
}
/**
* Create, change, or delete a role for a user in an organization.
* Supports both old single-role and new multi-role patterns.
*/
public async modifyRoleForUserAtOrg(optionsArg: {
action: 'create' | 'change' | 'delete';
userId: string;
organizationId: string;
role: plugins.idpInterfaces.data.IRole['data']['role'];
/** @deprecated Use `roles` instead */
role?: string;
/** Array of roles to assign */
roles?: string[];
}) {
let returnRole: Role;
// Support both old single role and new roles array
const roles = optionsArg.roles || (optionsArg.role ? [optionsArg.role] : ['viewer']);
switch (optionsArg.action) {
case 'create':
returnRole = new this.CRole();
@@ -29,9 +40,35 @@ export class RoleManager {
returnRole.data = {
userId: optionsArg.userId,
organizationId: optionsArg.organizationId,
role: optionsArg.role,
roles: roles,
};
await returnRole.save();
break;
case 'change':
returnRole = await this.CRole.getInstance({
data: {
userId: optionsArg.userId,
organizationId: optionsArg.organizationId,
},
});
if (returnRole) {
returnRole.data.roles = roles;
await returnRole.save();
}
break;
case 'delete':
returnRole = await this.CRole.getInstance({
data: {
userId: optionsArg.userId,
organizationId: optionsArg.organizationId,
},
});
if (returnRole) {
await returnRole.delete();
}
break;
}
return returnRole;
}
@@ -54,4 +91,13 @@ export class RoleManager {
});
return roles;
}
public async getAllRolesForOrg(organizationId: string) {
const roles = await this.CRole.getInstances({
data: {
organizationId: organizationId
}
});
return roles;
}
}
+21 -3
View File
@@ -17,7 +17,7 @@ export class User extends plugins.smartdata.SmartDataDbDoc<
const newUser = new User();
newUser.id = plugins.smartunique.shortId();
newUser.data = {
connectedOrgs: null,
connectedOrgs: [],
status: 'new',
name: userDataArg.name,
username: userDataArg.username,
@@ -31,8 +31,26 @@ export class User extends plugins.smartdata.SmartDataDbDoc<
return newUser;
}
public static hashPassword(passwordArg: string) {
return plugins.smarthash.sha256FromString(passwordArg);
public static async hashPassword(passwordArg: string) {
return plugins.argon2.hash(passwordArg);
}
public static isLegacyPasswordHash(passwordHashArg?: string) {
return !!passwordHashArg && !passwordHashArg.startsWith('$argon2');
}
public static shouldUpgradePasswordHash(passwordHashArg?: string) {
return this.isLegacyPasswordHash(passwordHashArg);
}
public static async verifyPassword(passwordArg: string, passwordHashArg?: string) {
if (!passwordHashArg) {
return false;
}
if (this.isLegacyPasswordHash(passwordHashArg)) {
return passwordHashArg === (await plugins.smarthash.sha256FromString(passwordArg));
}
return plugins.argon2.verify(passwordHashArg, passwordArg);
}
// INSTANCE
+136
View File
@@ -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);
}
}
+12 -3
View File
@@ -23,6 +23,9 @@ export class UserManager {
new plugins.typedrequest.TypedHandler('getRolesAndOrganizationsForUserId', async reqArg => {
console.log('user manager: getting roles and orgs');
const user = await this.getUserByJwtValidation(reqArg.jwt);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
const organizations = await this.receptionRef.organizationmanager.getAllOrganizationsForUser(
user
);
@@ -49,8 +52,8 @@ export class UserManager {
email: user.data.email,
mobileNumber: user.data.mobileNumber,
connectedOrgs: user.data.connectedOrgs,
status: null,
password: null,
status: user.data.status,
isGlobalAdmin: user.data.isGlobalAdmin,
} as plugins.idpInterfaces.data.IUser['data']
}
}
@@ -63,6 +66,9 @@ export class UserManager {
*/
public async getUserByJwt(jwtString: string) {
const jwtInstance = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtString);
if (!jwtInstance) {
return null;
}
const user = await this.CUser.getInstance({
id: jwtInstance.data.userId
});
@@ -74,7 +80,10 @@ export class UserManager {
* faster than the "getUserByJwt"
*/
public async getUserByJwtValidation(jwtStringArg: string) {
const jwtDataArg: plugins.idpInterfaces.data.IJwt = await this.receptionRef.jwtManager.smartjwtInstance.verifyJWTAndGetData(jwtStringArg);
const jwtDataArg = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtStringArg);
if (!jwtDataArg) {
return null;
}
const resultingUser = await this.CUser.getInstance({
id: jwtDataArg.data.userId
});
-3
View File
@@ -1,6 +1,3 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
const projectinfoNpm = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
export const logger = new plugins.smartlog.ConsoleLog();
+3
View File
@@ -0,0 +1,3 @@
{
"order": 2
}
+478
View File
@@ -0,0 +1,478 @@
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,
refreshToken: response.refreshToken || credentials.refreshToken,
});
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;
}
}
+362
View File
@@ -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);
+25
View File
@@ -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 };
+111
View File
@@ -0,0 +1,111 @@
# @idp.global/cli
Terminal client for `idp.global`.
It wraps the same typed backend used by the web app and SDK, but stores credentials on disk so you can inspect accounts, sessions, orgs, and admin state from the shell.
## 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.
## Install
```bash
pnpm add -g @idp.global/cli
```
## Quick Start
```bash
idp login
idp whoami
idp orgs
idp sessions
```
## Commands
| Command | Purpose |
| --- | --- |
| `idp login` | Prompt for email and password |
| `idp login-token` | Prompt for an API token |
| `idp logout` | Remove local credentials and try server-side logout |
| `idp whoami` | Print the current user |
| `idp sessions` | List active sessions |
| `idp revoke --session <session-id>` | Revoke a session |
| `idp orgs` | List organizations for the current user |
| `idp orgs-create` | Interactively create an organization |
| `idp members --org <org-id>` | List members for an organization |
| `idp invite --org <org-id> --email user@example.com` | Invite a member |
| `idp admin-check` | Check global admin status |
| `idp admin-apps` | List global app stats |
| `idp admin-suspend --user <user-id>` | Suspend a user |
## Configuration
The CLI reads `IDP_URL` and defaults to `https://idp.global`.
```bash
IDP_URL=http://localhost:2999 idp whoami
```
Credentials are stored in:
```text
~/.idp-global/credentials.json
```
## Programmatic Usage
```ts
import { IdpCli } from '@idp.global/cli';
const cli = new IdpCli({
idpBaseUrl: 'http://localhost:2999',
});
await cli.loginWithPassword('user@example.com', 'secret');
const me = await cli.whoami();
const orgs = await cli.getOrganizations();
console.log(me?.data?.email);
console.log(orgs?.organizations.length);
await cli.disconnect();
```
## What The Class Exposes
- `loginWithPassword()` and `loginWithApiToken()`
- `refreshJwt()` and `logout()`
- `whoami()`, `getSessions()`, and `revokeSession()`
- `getOrganizations()`, `createOrganization()`, `getOrgMembers()`, and `inviteMember()`
- `checkGlobalAdmin()`, `getGlobalAppStats()`, and `suspendUser()`
## Implementation Notes
- The CLI connects to the backend websocket surface at `/typedrequest`.
- It uses file-based credentials instead of browser storage.
- `orgs-create` first checks availability, then creates the organization.
## 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.
+4
View File
@@ -0,0 +1,4 @@
{
"name": "@idp.global/cli",
"order": 4
}
+79 -32
View File
@@ -11,12 +11,12 @@ export class IdpClient {
// INSTANCE PUBLIC
public appData: plugins.idpInterfaces.data.IApp;
public appData: plugins.idpInterfaces.data.IAppLegacy;
public rolesReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1);
public organizationsReplaySubject = new plugins.smartrx.rxjs.ReplaySubject(1);
public parsedReceptionUrl: plugins.smarturl.Smarturl;
constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IApp) {
constructor(receptionBaseUrlArg: string, appDataArg?: plugins.idpInterfaces.data.IAppLegacy) {
if (receptionBaseUrlArg.endsWith('/')) {
receptionBaseUrlArg = receptionBaseUrlArg.slice(0, -1);
}
@@ -29,9 +29,9 @@ export class IdpClient {
appDataArg = {
id: '', // TODO
appUrl: `https://${window.location.host}/`,
description: null,
logoUrl: null,
name: null,
description: '',
logoUrl: '',
name: '',
};
}
this.appData = appDataArg;
@@ -67,10 +67,14 @@ export class IdpClient {
await this.storeJwt(jwtStringArg);
}
public async setRefreshToken(refreshTokenArg: string) {
await this.storeRefreshToken(refreshTokenArg);
}
/**
* a typedsocket for going reactive
*/
public typedsocket: plugins.typedsocket.TypedSocket;
public typedsocket!: plugins.typedsocket.TypedSocket;
/**
* a typed router to go reactive
@@ -89,16 +93,30 @@ export class IdpClient {
await this.ssoStore.set('idpJwt', jwtString);
}
public async storeRefreshToken(refreshToken: string) {
await this.ssoStore.set('idpRefreshToken', refreshToken);
}
public async getJwt(): Promise<string> {
return await this.ssoStore.get('idpJwt');
}
public async getRefreshToken(): Promise<string> {
return await this.ssoStore.get('idpRefreshToken');
}
public async getJwtData(): Promise<plugins.idpInterfaces.data.IJwt> {
return this.helpers.extractDataFromJwtString(await this.getJwt());
}
public async deleteJwt() {
await this.ssoStore.delete('idpJwt');
console.log('removed jwt');
}
public async deleteRefreshToken() {
await this.ssoStore.delete('idpRefreshToken');
}
public async clearAuthState() {
await Promise.all([this.deleteJwt(), this.deleteRefreshToken()]);
}
/**
@@ -115,47 +133,63 @@ export class IdpClient {
if (extractedJwt.data.refreshFrom < Date.now() && Date.now() < extractedJwt.data.validUntil) {
jwt = await this.refreshJwt();
} else if (Date.now() > extractedJwt.data.validUntil) {
this.deleteJwt();
await this.deleteJwt();
jwt = await this.refreshJwt();
}
return jwt;
}
public async refreshJwt(refreshTokenArg?: string): Promise<string> {
let extractedJwt: plugins.idpInterfaces.data.IJwt;
public async refreshJwt(refreshTokenArg?: string): Promise<string | null> {
const refreshToken = refreshTokenArg || (await this.getRefreshToken());
if (!refreshTokenArg) {
extractedJwt = await this.helpers.extractDataFromJwtString(await this.getJwt());
if (!refreshToken) {
return null;
}
await this.typedsocketDeferred.promise;
const refreshJwtReq =
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
this.parsedReceptionUrl.toString(),
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
'refreshJwt'
);
const response = await refreshJwtReq.fire({
refreshToken: refreshTokenArg || extractedJwt.data.refreshToken,
const response = await refreshJwtReq
.fire({
refreshToken,
})
.catch(async () => {
await this.clearAuthState();
return null;
});
if (response.jwt) {
await this.storeJwt(response.jwt);
} else {
await this.deleteJwt();
if (!response?.jwt) {
await this.clearAuthState();
this.statusObservable.next(response?.status || 'loggedOut');
return null;
}
if (response.refreshToken) {
await this.storeRefreshToken(response.refreshToken);
}
await this.storeJwt(response.jwt);
this.statusObservable.next(response.status);
return await this.getJwt();
return response.jwt;
}
/**
* can be used to switch between pages
*/
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IApp): Promise<string> {
const jwt = await this.performJwtHousekeeping();
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string | null> {
await this.performJwtHousekeeping();
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return null;
}
await this.typedsocketDeferred.promise;
const getTransferToken =
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
this.parsedReceptionUrl.toString(),
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
'exchangeRefreshTokenAndTransferToken'
);
const response = await getTransferToken.fire({
refreshToken: extractedJwt.data.refreshToken,
refreshToken,
appData: appDataArg || this.appData,
});
return response.transferToken;
@@ -187,9 +221,9 @@ export class IdpClient {
const url = plugins.smarturl.Smarturl.createFromUrl(href);
const transferToken = url.searchParams['transfertoken'];
if (transferToken) {
await this.typedsocketDeferred.promise;
const getTransferToken =
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
this.parsedReceptionUrl.toString(),
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
'exchangeRefreshTokenAndTransferToken'
);
const response = await getTransferToken.fire({
@@ -230,6 +264,13 @@ export class IdpClient {
const jwt = await this.performJwtHousekeeping();
return !!jwt;
} else {
const refreshToken = await this.getRefreshToken();
if (refreshToken) {
const jwt = await this.refreshJwt(refreshToken);
if (jwt) {
return true;
}
}
const transferTokenResult = await this.processTransferToken();
if (transferTokenResult) {
// we are in the clear
@@ -258,12 +299,18 @@ export class IdpClient {
*/
public async logout() {
const idpLogoutUrl = this.parsedReceptionUrl.clone().set('path', '/logout');
const refreshToken = await this.getRefreshToken();
if (!globalThis.location.href.startsWith(idpLogoutUrl.origin)) {
// we are somewhere in an app
await this.deleteJwt();
await this.clearAuthState();
globalThis.location.href = idpLogoutUrl.toString();
} else {
// we are in the sso page
if (!refreshToken) {
await this.clearAuthState();
window.location.href = this.parsedReceptionUrl.origin;
return;
}
await this.enableTypedSocket();
console.log(`logging out against ${this.parsedReceptionUrl.toString()}`);
const logoutTr =
@@ -271,9 +318,9 @@ export class IdpClient {
'logout'
);
await logoutTr.fire({
refreshToken: (await this.getJwtData()).data.refreshToken,
refreshToken,
});
await this.deleteJwt();
await this.clearAuthState();
const appData = await this.getAppDataOnSsoDomain();
if (appData) {
console.log(`redirecting to app after logout: ${appData.appUrl}`);
+278 -14
View File
@@ -3,6 +3,7 @@ import type { IdpClient } from "./classes.idpclient.js";
/**
* this class bundles all the typed requests that are used by the idp
* All requests use TypedSocket (WebSocket) transport
*/
export class IdpRequests {
idpClientArg: IdpClient;
@@ -11,52 +12,315 @@ export class IdpRequests {
}
public get afterRegistrationEmailClicked () {
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
this.idpClientArg.parsedReceptionUrl.toString(),
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
'afterRegistrationEmailClicked'
);
}
public get setData() {
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
this.idpClientArg.parsedReceptionUrl.toString(),
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
'setDataForRegistration'
);
}
public get mobileNumberVerification () {
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
this.idpClientArg.parsedReceptionUrl.toString(),
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
'mobileVerificationForRegistration'
);
}
public get finishRegistration() {
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
this.idpClientArg.parsedReceptionUrl.toString(),
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
'finishRegistration'
);
}
public get loginWithUserNameAndPassword () {
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
this.idpClientArg.parsedReceptionUrl.toString(),
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'loginWithEmailOrUsernameAndPassword'
);
}
public get obtainJwt () {
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
this.idpClientArg.parsedReceptionUrl.toString(),
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
'refreshJwt'
);
}
public get obtainOneTimeToken () {
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
this.idpClientArg.parsedReceptionUrl.toString(),
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
'exchangeRefreshTokenAndTransferToken'
);
}
// ============================================
// Login & Authentication
// ============================================
public get loginWithEmail() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
'loginWithEmail'
);
}
public get loginWithEmailAfterToken() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
'loginWithEmailAfterEmailTokenAquired'
);
}
public get loginWithApiToken() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithApiToken>(
'loginWithApiToken'
);
}
public get resetPassword() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
'resetPassword'
);
}
public get setNewPassword() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetNewPassword>(
'setNewPassword'
);
}
public get obtainDeviceId() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ObtainDeviceId>(
'obtainDeviceId'
);
}
public get attachDeviceId() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AttachDeviceId>(
'attachDeviceId'
);
}
// ============================================
// Registration
// ============================================
public get firstRegistration() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
'firstRegistrationRequest'
);
}
// ============================================
// User Management
// ============================================
public get getUserData() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserData>(
'getUserData'
);
}
public get setUserData() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetUserData>(
'setUserData'
);
}
public get getUserSessions() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'getUserSessions'
);
}
public get revokeSession() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
'revokeSession'
);
}
public get getUserActivity() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'getUserActivity'
);
}
// ============================================
// Organization Management
// ============================================
public get getOrganizationById() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrganizationById>(
'getOrganizationById'
);
}
public get updateOrganization() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateOrganization>(
'updateOrganization'
);
}
// ============================================
// Member & Invitation Management
// ============================================
public get createInvitation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>(
'createInvitation'
);
}
public get getOrgInvitations() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
'getOrgInvitations'
);
}
public get getOrgMembers() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
'getOrgMembers'
);
}
public get cancelInvitation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CancelInvitation>(
'cancelInvitation'
);
}
public get resendInvitation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResendInvitation>(
'resendInvitation'
);
}
public get removeMember() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RemoveMember>(
'removeMember'
);
}
public get updateMemberRoles() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateMemberRoles>(
'updateMemberRoles'
);
}
public get transferOwnership() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_TransferOwnership>(
'transferOwnership'
);
}
public get getInvitationByToken() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetInvitationByToken>(
'getInvitationByToken'
);
}
public get acceptInvitation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AcceptInvitation>(
'acceptInvitation'
);
}
public get bulkCreateInvitations() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
'bulkCreateInvitations'
);
}
// ============================================
// Billing
// ============================================
public get getBillingPlan() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetBillingPlan>(
'getBillingPlan'
);
}
public get getPaddleConfig() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPaddleConfig>(
'getPaddleConfig'
);
}
// ============================================
// JWT Verification & Management
// ============================================
public get getPublicKeyForValidation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPublicKeyForValidation>(
'getPublicKeyForValidation'
);
}
public get pushPublicKeyForValidation() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PushPublicKeyForValidation>(
'pushPublicKeyForValidation'
);
}
public get pushOrGetJwtIdBlocklist() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PushOrGetJwtIdBlocklist>(
'pushOrGetJwtIdBlocklist'
);
}
// ============================================
// User Suspension (Admin)
// ============================================
public get suspendUser() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SuspendUser>(
'suspendUser'
);
}
public get deleteSuspendedUser() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IDeleteSuspendedUser>(
'deleteSuspendedUser'
);
}
// ============================================
// Admin (Global Admin Only)
// ============================================
public get checkGlobalAdmin() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
'checkGlobalAdmin'
);
}
public get getGlobalAppStats() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
'getGlobalAppStats'
);
}
public get createGlobalApp() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
'createGlobalApp'
);
}
public get updateGlobalApp() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
'updateGlobalApp'
);
}
public get deleteGlobalApp() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
'deleteGlobalApp'
);
}
public get regenerateAppCredentials() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
'regenerateAppCredentials'
);
}
}
+156
View File
@@ -0,0 +1,156 @@
# @idp.global/client
Browser-facing TypeScript client for talking to an `idp.global` server over `typedrequest` and `typedsocket`.
It handles login state, refresh tokens, JWT housekeeping, cross-app transfer tokens, and direct access to the typed request surface.
## 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.
## Install
```bash
pnpm add @idp.global/client
```
## Quick Start
```ts
import { IdpClient } from '@idp.global/client';
const idpClient = new IdpClient('https://idp.global');
await idpClient.enableTypedSocket();
const loggedIn = await idpClient.determineLoginStatus();
if (!loggedIn) {
const loginResult = await idpClient.requests.loginWithUserNameAndPassword.fire({
username: 'user@example.com',
password: 'secret',
});
if (loginResult.refreshToken) {
await idpClient.refreshJwt(loginResult.refreshToken);
}
}
const whoIs = await idpClient.whoIs();
console.log(whoIs.user.data.email);
```
## What The Client Handles
- Normalizes the base URL to the server's `/typedrequest` endpoint.
- Stores JWT and refresh token state in a browser `WebStore`.
- Refreshes expiring JWTs via `performJwtHousekeeping()`.
- Redirects to `/login` when `determineLoginStatus(true)` is used.
- Exchanges refresh tokens for cross-app transfer tokens.
- Exposes the low-level typed requests through `idpClient.requests`.
## Common Flows
### Password Login
```ts
const result = await idpClient.requests.loginWithUserNameAndPassword.fire({
username: 'user@example.com',
password: 'secret',
});
if (result.refreshToken) {
await idpClient.refreshJwt(result.refreshToken);
}
```
### Magic Link Login
```ts
await idpClient.requests.loginWithEmail.fire({
email: 'user@example.com',
});
const result = await idpClient.requests.loginWithEmailAfterToken.fire({
email: 'user@example.com',
token: 'token-from-email',
});
await idpClient.refreshJwt(result.refreshToken);
```
### Session and Identity
```ts
await idpClient.performJwtHousekeeping();
const jwt = await idpClient.getJwt();
const jwtData = await idpClient.getJwtData();
const whoIs = await idpClient.whoIs();
console.log(jwtData.id, whoIs.user.data.username);
```
### Organizations
```ts
const rolesAndOrganizations = await idpClient.getRolesAndOrganizations();
const created = await idpClient.createOrganization(
'Acme',
'acme',
'manifest'
);
const members = await idpClient.requests.getOrgMembers.fire({
jwt: await idpClient.getJwt(),
organizationId: created.resultingOrganization.id,
});
```
### Cross-App Transfer
```ts
const transferToken = await idpClient.getTransferToken();
await idpClient.getTransferTokenAndSwitchToLocation('https://app.example.com/');
```
## Typed Request Surface
`IdpRequests` exposes typed request getters for:
- authentication
- registration
- user/session queries
- org and invitation management
- billing requests
- JWT validation key requests
- admin requests
Use these when you want full control instead of the higher-level helper methods on `IdpClient`.
## Important Runtime Notes
- The default fallback `appData` uses `window.location`, so this package is primarily browser-oriented.
- The client expects the backend `typedrequest` websocket surface to be reachable.
- Auth state is persisted in browser storage under the `idpglobalStore` store name.
## 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.
+4
View File
@@ -0,0 +1,4 @@
{
"name": "@idp.global/client",
"order": 3
}
+4
View File
@@ -1,4 +1,7 @@
export * from './loint-reception.activity.js';
export * from './loint-reception.app.js';
export * from './loint-reception.oidc.js';
export * from './loint-reception.appconnection.js';
export * from './loint-reception.billingplan.js';
export * from './loint-reception.device.js';
export * from './loint-reception.jwt.js';
@@ -7,3 +10,4 @@ export * from './loint-reception.organization.js';
export * from './loint-reception.paddlecheckoutdata.js';
export * from './loint-reception.role.js';
export * from './loint-reception.user.js';
export * from './loint-reception.userinvitation.js';
@@ -0,0 +1,28 @@
export type TActivityAction =
| 'login'
| 'logout'
| 'session_created'
| 'session_revoked'
| 'org_created'
| 'org_joined'
| 'org_left'
| 'role_changed'
| 'profile_updated'
| 'app_connected'
| 'app_disconnected';
export interface IActivityLog {
id: string;
data: {
userId: string;
action: TActivityAction;
timestamp: number;
metadata: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
description: string;
};
};
}
+87 -1
View File
@@ -1,4 +1,80 @@
export interface IApp {
// App Types
export type TAppType = 'global' | 'partner' | 'custom_oidc';
export type TAppApprovalStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'suspended';
// OAuth Credentials
export interface IOAuthCredentials {
clientId: string;
clientSecretHash: string;
redirectUris: string[];
allowedScopes: string[];
grantTypes: ('authorization_code' | 'client_credentials' | 'refresh_token')[];
}
// Base app data shared by all app types
export interface IAppBaseData {
name: string;
description: string;
logoUrl: string;
appUrl: string;
}
// Global App - First-party apps managed by platform (foss.global, task.vc, etc.)
export interface IGlobalApp {
id: string;
type: 'global';
data: IAppBaseData & {
oauthCredentials: IOAuthCredentials;
isActive: boolean;
category: string;
createdAt: number;
createdByUserId: string;
};
}
// Partner App - Third-party apps submitted to AppStore
export interface IPartnerApp {
id: string;
type: 'partner';
data: IAppBaseData & {
ownerOrganizationId: string;
oauthCredentials: IOAuthCredentials;
appStoreMetadata: {
shortDescription: string;
longDescription: string;
screenshots: string[];
category: string;
tags: string[];
pricing: { model: 'free' | 'paid' | 'freemium' };
};
approvalStatus: TAppApprovalStatus;
isPublished: boolean;
installCount: number;
};
}
// Custom OIDC App - Organization-created OAuth clients
export interface ICustomOidcApp {
id: string;
type: 'custom_oidc';
data: IAppBaseData & {
ownerOrganizationId: string;
oauthCredentials: IOAuthCredentials;
oidcSettings: {
accessTokenLifetime: number; // seconds
refreshTokenLifetime: number; // seconds
};
};
}
// Union type for all app types
export type IApp = IGlobalApp | IPartnerApp | ICustomOidcApp;
/**
* Legacy interface for backwards compatibility with existing code
* that expects a flat app structure (e.g., idpclient, transfermanager)
*/
export interface IAppLegacy {
/**
* must be unique
*/
@@ -11,3 +87,13 @@ export interface IApp {
logoUrl: string;
appUrl: string;
}
/**
* Storage interface for SmartData documents
* Uses the discriminated union approach with a 'type' field
*/
export interface IAppDocument {
id: string;
type: TAppType;
data: IGlobalApp['data'] | IPartnerApp['data'] | ICustomOidcApp['data'];
}
@@ -0,0 +1,16 @@
import type { TAppType } from './loint-reception.app.js';
export type TAppConnectionStatus = 'active' | 'disconnected';
export interface IAppConnection {
id: string;
data: {
organizationId: string;
appId: string;
appType: TAppType;
status: TAppConnectionStatus;
connectedAt: number;
connectedByUserId: string;
grantedScopes: string[];
};
}
+7 -2
View File
@@ -10,6 +10,11 @@ export interface IJwt {
*/
userId: string;
/**
* the login session backing this jwt
*/
sessionId?: string;
/**
* the latest point of
*/
@@ -24,9 +29,9 @@ export interface IJwt {
refreshEvery: number;
/**
* the refresh token to obtain a new jwt for a session
* legacy field kept for compatibility with already-issued jwt documents
*/
refreshToken: string;
refreshToken?: string;
/**
* just for looks/debugging
@@ -1,14 +1,38 @@
export interface ILoginSession {
id: string;
data: {
userId: string;
userId: string | null;
validUntil: number;
invalidated: boolean;
refreshToken: string;
/**
* legacy plaintext refresh token field kept so existing sessions can migrate on first use
*/
refreshToken?: string | null;
refreshTokenHash?: string | null;
rotatedRefreshTokenHashes?: string[];
transferTokenHash?: string | null;
transferTokenExpiresAt?: number | null;
/**
* a device id that can be used to share the login session
* in different contexts on the same device
*/
deviceId: string;
deviceId?: string | null;
/**
* Device metadata for session display
*/
deviceInfo?: {
deviceName: string;
browser: string;
os: string;
ip: string;
} | null;
/**
* When this session was created
*/
createdAt?: number;
/**
* Last time this session was active (e.g., refreshed)
*/
lastActive?: number;
};
}
+267
View File
@@ -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;
+7 -2
View File
@@ -1,13 +1,18 @@
import * as plugins from '../loint-reception.plugins.js';
/** Standard role types available in all organizations */
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
/**
* a role describes a
* A role describes a user's permissions within an organization.
* Users can have multiple roles (e.g., ['owner', 'billing-admin']).
*/
export interface IRole {
id: string;
data: {
userId: string;
organizationId: string;
role: 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
/** Array of roles - supports standard roles and custom role names */
roles: string[];
};
}
@@ -26,5 +26,11 @@ export interface IUser {
* speeds up lookup
*/
connectedOrgs: string[];
/**
* Platform-level admin flag
* Users with this flag can access the global admin panel
* to manage global apps, view platform stats, etc.
*/
isGlobalAdmin?: boolean;
};
}
@@ -0,0 +1,58 @@
import * as plugins from '../loint-reception.plugins.js';
/**
* A UserInvitation represents an invitation to join an organization.
* Key characteristics:
* - Unique by email (multiple orgs can share the same invitation)
* - Converts to real User on registration or folds into existing user
* - Auto-expires after 90 days
*/
export interface IUserInvitation {
id: string;
data: {
/** The invited email address - unique key for sharing across orgs */
email: string;
/** Secure token for invitation link validation */
token: string;
/** Current status of the invitation */
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
/** When the invitation was first created */
createdAt: number;
/** When the invitation expires (createdAt + 90 days) */
expiresAt: number;
/**
* Organizations that have invited this email.
* Multiple orgs can link to the same invitation.
*/
organizationRefs: IOrganizationInvitationRef[];
/** When the invitation was accepted (user registered/folded) */
acceptedAt?: number;
/** The User ID after conversion (when accepted) */
convertedToUserId?: string;
};
}
/**
* Represents one organization's invitation to the user.
* Stored as part of IUserInvitation.organizationRefs array.
*/
export interface IOrganizationInvitationRef {
/** The organization that sent this invitation */
organizationId: string;
/** The user who sent the invitation */
invitedByUserId: string;
/** When this org invited the user */
invitedAt: number;
/** Roles to assign when the invitation is accepted */
roles: string[];
}
+128
View File
@@ -0,0 +1,128 @@
# @idp.global/interfaces
Shared TypeScript contracts for the `idp.global` backend, browser client, CLI, and frontend.
Use this package when you want typed request/response payloads and shared data models for users, sessions, organizations, apps, billing, and OIDC.
## 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.
## Install
```bash
pnpm add @idp.global/interfaces
```
## Quick Start
```ts
import { data, request, tags } from '@idp.global/interfaces';
const loginRequest: request.IReq_LoginWithEmailOrUsernameAndPassword['request'] = {
username: 'user@example.com',
password: 'secret',
};
const organization: data.IOrganization = {
id: 'org_1',
data: {
name: 'Acme',
slug: 'acme',
billingPlanId: 'plan_free',
roleIds: [],
},
};
```
## Exports
### `data`
The `data` export includes types for:
- users
- organizations
- roles
- JWT payloads
- login sessions
- devices
- activity logs
- apps and app connections
- billing plans and Paddle checkout data
- OIDC data structures
- invitations
### `request`
The `request` export includes typed request contracts for:
- login, logout, refresh, password reset, and device attachment
- registration flow requests
- user and session queries
- organization CRUD-style requests
- invitations and membership changes
- app and admin actions
- billing and JWT validation support
### `tags`
Shared tag exports live under `tags/`.
## Layout
| Path | Purpose |
| --- | --- |
| `data/index.ts` | Re-exports all shared data interfaces |
| `request/index.ts` | Re-exports all typed request contracts |
| `tags/index.ts` | Re-exports shared tags |
## Examples
### Login Contract
```ts
type TLogin = request.IReq_LoginWithEmailOrUsernameAndPassword;
const payload: TLogin['request'] = {
username: 'user@example.com',
password: 'secret',
};
```
### Session Contract
```ts
type TSessions = request.IReq_GetUserSessions['response']['sessions'];
```
### OIDC Contract
```ts
type TUserInfo = data.IUserInfoResponse;
```
## Scope
This package is intentionally contract-only. It does not open sockets, store auth state, or perform HTTP/websocket communication by itself.
## 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.
+3
View File
@@ -1,4 +1,6 @@
export * from './loint-reception.admin.js';
export * from './loint-reception.apitoken.js';
export * from './loint-reception.app.js';
export * from './loint-reception.authorization.js';
export * from './loint-reception.billingplan.js';
export * from './loint-reception.jwt.js';
@@ -7,3 +9,4 @@ export * from './loint-reception.organization.js';
export * from './loint-reception.plan.js';
export * from './loint-reception.registration.js';
export * from './loint-reception.user.js';
export * from './loint-reception.userinvitation.js';
@@ -0,0 +1,130 @@
import * as plugins from '../loint-reception.plugins.js';
import * as data from '../data/index.js';
/**
* Check if the current user is a global admin
*/
export interface IReq_CheckGlobalAdmin
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CheckGlobalAdmin
> {
method: 'checkGlobalAdmin';
request: {
jwt: string;
};
response: {
isGlobalAdmin: boolean;
};
}
/**
* Get all global apps with statistics (admin only)
*/
export interface IReq_GetGlobalAppStats
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetGlobalAppStats
> {
method: 'getGlobalAppStats';
request: {
jwt: string;
};
response: {
apps: Array<{
app: data.IGlobalApp;
connectionCount: number;
}>;
};
}
/**
* Create a new global app (admin only)
*/
export interface IReq_CreateGlobalApp
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CreateGlobalApp
> {
method: 'createGlobalApp';
request: {
jwt: string;
name: string;
description: string;
logoUrl: string;
appUrl: string;
category: string;
redirectUris: string[];
allowedScopes: string[];
};
response: {
app: data.IGlobalApp;
clientSecret: string; // Only shown once on creation
};
}
/**
* Update an existing global app (admin only)
*/
export interface IReq_UpdateGlobalApp
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_UpdateGlobalApp
> {
method: 'updateGlobalApp';
request: {
jwt: string;
appId: string;
updates: {
name?: string;
description?: string;
logoUrl?: string;
appUrl?: string;
category?: string;
isActive?: boolean;
redirectUris?: string[];
allowedScopes?: string[];
};
};
response: {
app: data.IGlobalApp;
};
}
/**
* Delete a global app (admin only)
*/
export interface IReq_DeleteGlobalApp
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_DeleteGlobalApp
> {
method: 'deleteGlobalApp';
request: {
jwt: string;
appId: string;
};
response: {
success: boolean;
disconnectedOrganizations: number;
};
}
/**
* Regenerate OAuth credentials for a global app (admin only)
*/
export interface IReq_RegenerateAppCredentials
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RegenerateAppCredentials
> {
method: 'regenerateAppCredentials';
request: {
jwt: string;
appId: string;
};
response: {
clientId: string;
clientSecret: string; // Only shown once
};
}
@@ -0,0 +1,52 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
// Get all global apps
export interface IReq_GetGlobalApps
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetGlobalApps
> {
method: 'getGlobalApps';
request: {
jwt: string;
};
response: {
apps: data.IGlobalApp[];
};
}
// Get app connections for an organization
export interface IReq_GetAppConnections
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetAppConnections
> {
method: 'getAppConnections';
request: {
jwt: string;
organizationId: string;
};
response: {
connections: data.IAppConnection[];
};
}
// Connect/disconnect an app for an organization
export interface IReq_ToggleAppConnection
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_ToggleAppConnection
> {
method: 'toggleAppConnection';
request: {
jwt: string;
organizationId: string;
appId: string;
action: 'connect' | 'disconnect';
};
response: {
success: boolean;
connection?: data.IAppConnection;
};
}
@@ -37,3 +37,19 @@ export interface IReq_GetBillingPlan
billingPlan: data.IBillingPlan;
};
}
/**
* Returns Paddle configuration from environment variables
*/
export interface IReq_GetPaddleConfig
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetPaddleConfig
> {
method: 'getPaddleConfig';
request: {};
response: {
paddleToken: string;
paddlePriceId: string;
};
}
+35 -1
View File
@@ -1,6 +1,16 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
/**
* Request to get the public key for JWT validation.
*
* **Direction:** Client → idp.global
* **Requester:** Backend services that need to verify JWTs
* **Handler:** idp.global
*
* Use this to fetch the current public key for verifying JWT signatures.
* The backend token authenticates the requesting service.
*/
export interface IReq_GetPublicKeyForValidation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
@@ -15,6 +25,16 @@ export interface IReq_GetPublicKeyForValidation
};
}
/**
* Push public key to connected backend services for JWT validation.
*
* **Direction:** idp.global → Client
* **Requester:** idp.global (pushes when the JWT signing key rotates)
* **Handler:** Backend services - must register a TypedHandler for this method
*
* Backend services should register a handler using `IdpClient.onPublicKeyPush()`
* to receive key rotation updates and update their local key cache.
*/
export interface IReq_PushPublicKeyForValidation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
@@ -28,7 +48,21 @@ export interface IReq_PushPublicKeyForValidation
}
/**
* allows getting or pushing a blocklist of jwt ids
* Push or get JWT ID blocklist for revoked tokens.
*
* **Bidirectional:**
* - **GET direction:** Client → idp.global - Client requests current blocklist
* - **PUSH direction:** idp.global → Client - Server pushes new blocklisted IDs
*
* **For GET (client fires):**
* - Fire with empty/undefined `blockedJwtIds` to request the full blocklist
* - Response contains the complete list of blocked JWT IDs
* - Use `IdpClient.requests.getJwtIdBlocklist` for this direction
*
* **For PUSH (idp.global fires):**
* - idp.global sends newly blocklisted JWT IDs to connected clients
* - Clients must register a handler using `IdpClient.onBlocklistPush()`
* - Store received IDs locally to reject revoked tokens
*/
export interface IReq_PushOrGetJwtIdBlocklist
extends plugins.typedRequestInterfaces.implementsTR<
@@ -87,7 +87,8 @@ export interface IReq_RefreshJwt
};
response: {
status: data.TLoginStatus;
jwt: string;
jwt?: string;
refreshToken?: string;
};
}
@@ -103,7 +104,7 @@ export interface IReq_ExchangeRefreshTokenAndTransferToken
request: {
transferToken?: string;
refreshToken?: string;
appData: data.IApp;
appData: data.IAppLegacy;
};
response: {
refreshToken?: string;
@@ -84,3 +84,59 @@ export interface IReq_WhoIs {
user: data.IUser;
};
}
export interface IReq_GetUserSessions
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetUserSessions
> {
method: 'getUserSessions';
request: {
jwt: string;
};
response: {
sessions: Array<{
id: string;
deviceId: string;
deviceName: string;
browser: string;
os: string;
ip: string;
lastActive: number;
createdAt: number;
isCurrent: boolean;
}>;
};
}
export interface IReq_RevokeSession
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RevokeSession
> {
method: 'revokeSession';
request: {
jwt: string;
sessionId: string;
};
response: {
success: boolean;
};
}
export interface IReq_GetUserActivity
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetUserActivity
> {
method: 'getUserActivity';
request: {
jwt: string;
limit?: number;
offset?: number;
};
response: {
activities: data.IActivityLog[];
total: number;
};
}
@@ -0,0 +1,247 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
/**
* Create an invitation to join an organization
*/
export interface IReq_CreateInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CreateInvitation
> {
method: 'createInvitation';
request: {
jwt: string;
organizationId: string;
email: string;
roles: string[];
};
response: {
success: boolean;
invitation?: data.IUserInvitation;
message?: string;
/** True if a new invitation was created, false if email was added to existing */
isNew: boolean;
};
}
/**
* Get pending invitations for an organization
*/
export interface IReq_GetOrgInvitations
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetOrgInvitations
> {
method: 'getOrgInvitations';
request: {
jwt: string;
organizationId: string;
};
response: {
invitations: data.IUserInvitation[];
};
}
/**
* Get members of an organization (users with roles)
*/
export interface IReq_GetOrgMembers
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetOrgMembers
> {
method: 'getOrgMembers';
request: {
jwt: string;
organizationId: string;
};
response: {
members: Array<{
user: data.IUser;
role: data.IRole;
}>;
};
}
/**
* Cancel a pending invitation
*/
export interface IReq_CancelInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CancelInvitation
> {
method: 'cancelInvitation';
request: {
jwt: string;
organizationId: string;
invitationId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Resend invitation email
*/
export interface IReq_ResendInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_ResendInvitation
> {
method: 'resendInvitation';
request: {
jwt: string;
organizationId: string;
invitationId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Remove a member from an organization
*/
export interface IReq_RemoveMember
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RemoveMember
> {
method: 'removeMember';
request: {
jwt: string;
organizationId: string;
userId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Update a member's roles
*/
export interface IReq_UpdateMemberRoles
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_UpdateMemberRoles
> {
method: 'updateMemberRoles';
request: {
jwt: string;
organizationId: string;
userId: string;
roles: string[];
};
response: {
success: boolean;
role?: data.IRole;
message?: string;
};
}
/**
* Transfer organization ownership to another member
*/
export interface IReq_TransferOwnership
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_TransferOwnership
> {
method: 'transferOwnership';
request: {
jwt: string;
organizationId: string;
newOwnerId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Accept an invitation (called during registration or email verification)
*/
export interface IReq_AcceptInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_AcceptInvitation
> {
method: 'acceptInvitation';
request: {
token: string;
userId: string;
};
response: {
success: boolean;
organizations?: data.IOrganization[];
roles?: data.IRole[];
message?: string;
};
}
/**
* Get invitation by token (for invitation landing page)
*/
export interface IReq_GetInvitationByToken
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetInvitationByToken
> {
method: 'getInvitationByToken';
request: {
token: string;
};
response: {
invitation?: data.IUserInvitation;
organizations?: Array<{
id: string;
name: string;
}>;
isExpired: boolean;
requiresRegistration: boolean;
};
}
/**
* Bulk create invitations from a list (typically from CSV import)
*/
export interface IReq_BulkCreateInvitations
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_BulkCreateInvitations
> {
method: 'bulkCreateInvitations';
request: {
jwt: string;
organizationId: string;
invitations: Array<{
email: string;
roles?: string[];
}>;
defaultRoles: string[];
};
response: {
success: boolean;
results: Array<{
email: string;
success: boolean;
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
message?: string;
}>;
summary: {
total: number;
invited: number;
alreadyMembers: number;
invalid: number;
errors: number;
};
};
}
+3
View File
@@ -0,0 +1,3 @@
{
"order": 1
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.5.0',
version: '1.17.1',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
@@ -0,0 +1,585 @@
import * as plugins from '../../plugins.js';
import {
customElement,
DeesElement,
html,
css,
cssManager,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from './sharedstyles.js';
import { IdpState } from '../../states/idp.state.js';
interface IParsedEmail {
email: string;
valid: boolean;
error?: string;
}
interface IBulkInviteResult {
invitedCount: number;
failedCount: number;
alreadyMemberCount: number;
}
// Internal form element for reactive state management
@customElement('idp-bulk-invite-form')
export class BulkInviteForm extends DeesElement {
@state()
accessor organizationId: string = '';
@state()
accessor organizationName: string = '';
@state()
accessor parsedEmails: IParsedEmail[] = [];
@state()
accessor selectedRoles: string[] = ['viewer'];
@state()
accessor submitting: boolean = false;
@state()
accessor error: string = '';
@state()
accessor results: IBulkInviteResult | null = null;
private static readonly AVAILABLE_ROLES = ['admin', 'editor', 'viewer', 'guest'];
public resolveWith: ((result: IBulkInviteResult | null) => void) | null = null;
public modal: plugins.deesCatalog.DeesModal | null = null;
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: block;
}
.description {
color: var(--muted-foreground);
font-size: 14px;
margin-bottom: 20px;
}
.file-upload-area {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 32px;
text-align: center;
cursor: pointer;
transition: all 0.15s ease;
margin-bottom: 20px;
}
.file-upload-area:hover {
border-color: var(--muted-foreground);
background: var(--muted);
}
.file-upload-area.has-data {
border-style: solid;
border-color: #22c55e;
background: rgba(34, 197, 94, 0.05);
}
.upload-icon {
font-size: 32px;
color: var(--muted-foreground);
margin-bottom: 12px;
}
.upload-text {
font-size: 14px;
color: var(--foreground);
margin-bottom: 4px;
}
.upload-hint {
font-size: 12px;
color: var(--muted-foreground);
}
.sample-link {
color: #3b82f6;
cursor: pointer;
text-decoration: underline;
}
input[type="file"] {
display: none;
}
.preview-section {
margin-bottom: 20px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.preview-title {
font-size: 13px;
font-weight: 600;
color: var(--foreground);
}
.preview-stats {
font-size: 12px;
color: var(--muted-foreground);
}
.preview-stats .valid {
color: #22c55e;
}
.preview-stats .invalid {
color: #ef4444;
}
.preview-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.preview-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.preview-item:last-child {
border-bottom: none;
}
.preview-item.invalid {
background: rgba(239, 68, 68, 0.05);
}
.preview-email {
color: var(--foreground);
}
.preview-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
}
.preview-status.valid {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.preview-status.invalid {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.role-section {
margin-bottom: 20px;
}
.section-label {
font-size: 13px;
font-weight: 500;
color: var(--foreground);
margin-bottom: 10px;
}
.role-selector {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.role-option {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--border);
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.15s ease;
}
.role-option:hover {
border-color: var(--foreground);
color: var(--foreground);
}
.role-option.selected {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.error-message {
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
font-size: 13px;
margin-bottom: 16px;
}
.results-section {
padding: 16px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
margin-bottom: 16px;
}
.results-section.has-failures {
background: rgba(234, 179, 8, 0.1);
border-color: rgba(234, 179, 8, 0.3);
}
.results-title {
font-weight: 600;
margin-bottom: 8px;
color: var(--foreground);
}
.results-stats {
font-size: 13px;
color: var(--muted-foreground);
}
.clear-button {
font-size: 12px;
color: #ef4444;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
}
.clear-button:hover {
text-decoration: underline;
}
`,
];
public render(): TemplateResult {
if (this.results) {
return this.renderResults();
}
return html`
<div class="description">
Upload a CSV file with email addresses to invite multiple people at once.
</div>
${this.error ? html`
<div class="error-message">${this.error}</div>
` : ''}
${this.renderFileUpload()}
${this.parsedEmails.length > 0 ? this.renderPreview() : ''}
${this.parsedEmails.length > 0 ? this.renderRoleSelector() : ''}
`;
}
private renderFileUpload(): TemplateResult {
const validCount = this.parsedEmails.filter(e => e.valid).length;
const hasData = this.parsedEmails.length > 0;
return html`
<div
class="file-upload-area ${hasData ? 'has-data' : ''}"
@click=${() => this.triggerFileInput()}
@dragover=${(e: DragEvent) => { e.preventDefault(); }}
@drop=${(e: DragEvent) => this.handleFileDrop(e)}
>
<input
type="file"
accept=".csv,.txt"
@change=${(e: Event) => this.handleFileSelect(e)}
/>
${hasData ? html`
<div class="upload-icon">
<dees-icon .icon=${'lucide:check-circle'}></dees-icon>
</div>
<div class="upload-text">${validCount} valid email(s) loaded</div>
<div class="upload-hint">Click to replace with a different file</div>
` : html`
<div class="upload-icon">
<dees-icon .icon=${'lucide:upload'}></dees-icon>
</div>
<div class="upload-text">Drop CSV file here or click to browse</div>
<div class="upload-hint">
<span class="sample-link" @click=${(e: Event) => { e.stopPropagation(); this.downloadSampleCSV(); }}>Download sample CSV</span>
</div>
`}
</div>
`;
}
private renderPreview(): TemplateResult {
const validCount = this.parsedEmails.filter(e => e.valid).length;
const invalidCount = this.parsedEmails.filter(e => !e.valid).length;
return html`
<div class="preview-section">
<div class="preview-header">
<span class="preview-title">Email Preview</span>
<span class="preview-stats">
<span class="valid">${validCount} valid</span>
${invalidCount > 0 ? html`, <span class="invalid">${invalidCount} invalid</span>` : ''}
</span>
<button class="clear-button" @click=${() => this.clearEmails()}>Clear</button>
</div>
<div class="preview-list">
${this.parsedEmails.map(item => html`
<div class="preview-item ${item.valid ? '' : 'invalid'}">
<span class="preview-email">${item.email}</span>
<span class="preview-status ${item.valid ? 'valid' : 'invalid'}">
${item.valid ? 'Valid' : (item.error || 'Invalid')}
</span>
</div>
`)}
</div>
</div>
`;
}
private renderRoleSelector(): TemplateResult {
return html`
<div class="role-section">
<div class="section-label">Assign Role</div>
<div class="role-selector">
${BulkInviteForm.AVAILABLE_ROLES.map(role => html`
<button
class="role-option ${this.selectedRoles.includes(role) ? 'selected' : ''}"
@click=${() => this.toggleRole(role)}
?disabled=${this.submitting}
>
${role}
</button>
`)}
</div>
</div>
`;
}
private renderResults(): TemplateResult {
const hasFailures = this.results!.failedCount > 0 || this.results!.alreadyMemberCount > 0;
return html`
<div class="results-section ${hasFailures ? 'has-failures' : ''}">
<div class="results-title">Bulk Invite Complete</div>
<div class="results-stats">
${this.results!.invitedCount} invitation(s) sent successfully.
${this.results!.alreadyMemberCount > 0 ? html`<br>${this.results!.alreadyMemberCount} already member(s).` : ''}
${this.results!.failedCount > 0 ? html`<br>${this.results!.failedCount} failed.` : ''}
</div>
</div>
`;
}
private triggerFileInput(): void {
const input = this.shadowRoot?.querySelector('input[type="file"]') as HTMLInputElement;
input?.click();
}
private handleFileDrop(e: DragEvent): void {
e.preventDefault();
const file = e.dataTransfer?.files[0];
if (file) {
this.parseCSVFile(file);
}
}
private handleFileSelect(e: Event): void {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
this.parseCSVFile(file);
}
}
private async parseCSVFile(file: File): Promise<void> {
const text = await file.text();
const lines = text.split(/\r?\n/).filter(line => line.trim());
const parsed: IParsedEmail[] = [];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const seen = new Set<string>();
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip header row if it looks like "email" or similar
if (i === 0 && (line.toLowerCase() === 'email' || line.toLowerCase() === 'emails' || line.toLowerCase() === 'e-mail')) {
continue;
}
// Extract email from line (handle quoted values, commas)
const email = line.replace(/["']/g, '').split(',')[0].trim().toLowerCase();
if (!email) {
continue;
}
if (seen.has(email)) {
parsed.push({ email, valid: false, error: 'Duplicate' });
continue;
}
seen.add(email);
if (!emailRegex.test(email)) {
parsed.push({ email, valid: false, error: 'Invalid format' });
continue;
}
parsed.push({ email, valid: true });
}
this.parsedEmails = parsed;
this.error = '';
}
private downloadSampleCSV(): void {
const content = 'email\nuser1@example.com\nuser2@example.com\nuser3@example.com';
const blob = new Blob([content], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sample-invite-list.csv';
a.click();
URL.revokeObjectURL(url);
}
private clearEmails(): void {
this.parsedEmails = [];
this.error = '';
}
private toggleRole(role: string): void {
if (this.selectedRoles.includes(role)) {
this.selectedRoles = this.selectedRoles.filter(r => r !== role);
} else {
this.selectedRoles = [...this.selectedRoles, role];
}
if (this.selectedRoles.length === 0) {
this.selectedRoles = ['viewer'];
}
}
public canSubmit(): boolean {
const validEmails = this.parsedEmails.filter(e => e.valid);
return validEmails.length > 0 && this.selectedRoles.length > 0 && !this.submitting && !this.results;
}
public async handleSubmit(): Promise<IBulkInviteResult | null> {
if (!this.canSubmit()) {
return null;
}
this.submitting = true;
this.error = '';
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const validEmails = this.parsedEmails.filter(e => e.valid);
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
'bulkCreateInvitations'
);
const response = await request.fire({
jwt,
organizationId: this.organizationId,
invitations: validEmails.map(e => ({ email: e.email })),
defaultRoles: this.selectedRoles,
});
this.results = {
invitedCount: response.summary.invited,
failedCount: response.summary.errors + response.summary.invalid,
alreadyMemberCount: response.summary.alreadyMembers,
};
return this.results;
} catch (error) {
console.error('Error sending bulk invitations:', error);
this.error = error instanceof Error ? error.message : 'Failed to send invitations. Please try again.';
return null;
} finally {
this.submitting = false;
}
}
public handleCancel(): void {
this.modal?.destroy();
this.resolveWith?.(null);
}
public handleClose(): void {
this.modal?.destroy();
this.resolveWith?.(this.results);
}
}
// Export the modal utility class
export class BulkInviteModal {
public static async show(options: {
organizationId: string;
organizationName: string;
}): Promise<IBulkInviteResult | null> {
return new Promise<IBulkInviteResult | null>((resolve) => {
const formElement = new BulkInviteForm();
formElement.organizationId = options.organizationId;
formElement.organizationName = options.organizationName;
formElement.resolveWith = resolve;
plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Bulk Invite Members',
content: html`${formElement}`,
menuOptions: [
{
name: 'Cancel',
action: async () => {
formElement.handleCancel();
},
},
{
name: 'Send Invitations',
action: async () => {
const result = await formElement.handleSubmit();
if (result) {
// Wait a bit for user to see results, then close
setTimeout(() => {
formElement.handleClose();
}, 2000);
}
},
},
],
width: 520,
}).then((modal) => {
formElement.modal = modal;
});
});
}
}
+72 -1
View File
@@ -12,12 +12,14 @@ import {
} from '@design.estate/dees-element';
import { LeleAccountNavigation } from './navigation.js';
import { OrgSelectModal, type IOrgSelectResult } from './org-select-modal.js';
import { CreateOrgModal } from './create-org-modal.js';
import { accountDesignTokens } from './sharedstyles.js';
import * as views from './views/index.js';
import * as accountstate from '../../states/accountstate.js';
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
import { commitinfo } from '../../../ts/00_commitinfo_data.js';
declare global {
@@ -100,6 +102,25 @@ export class IdpAccountContent extends DeesElement {
this.subrouter = this.domtools.router.createSubRouter('/account');
const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer');
// Setup event listeners for modals
this.addEventListener('open-org-select-modal', (async (e: CustomEvent) => {
const result = await OrgSelectModal.show({
targetPath: e.detail.targetPath,
title: e.detail.title,
description: e.detail.description,
});
if (result) {
this.subrouter.pushUrl(result.path);
}
}) as EventListener);
this.addEventListener('open-create-org-modal', async () => {
const org = await CreateOrgModal.show();
if (org) {
this.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
}
});
const cleanupViews = async () => {
for (const child of Array.from(viewcontainer.children)) {
viewcontainer.removeChild(child);
@@ -129,6 +150,56 @@ export class IdpAccountContent extends DeesElement {
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName/paddlesetup', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the paddle setup page');
await cleanupViews();
viewcontainer.append(new views.PaddleSetupView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the org overview page');
await cleanupViews();
viewcontainer.append(new views.OrgView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName/apps', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the apps page');
await cleanupViews();
viewcontainer.append(new views.AppsView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName/users', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the users page');
await cleanupViews();
viewcontainer.append(new views.UsersView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/admin', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the admin page');
await cleanupViews();
viewcontainer.append(new views.AdminView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter._handleRouteState();
this.registerGarbageFunction(async () => {
+329
View File
@@ -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;
});
});
}
}
+2
View File
@@ -1,2 +1,4 @@
export * from './content.js';
export * from './navigation.js';
export * from './org-select-modal.js';
export * from './create-org-modal.js';
+152 -27
View File
@@ -6,6 +6,7 @@ import {
cssManager,
unsafeCSS,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@@ -13,8 +14,10 @@ import * as plugins from '../../plugins.js';
import * as states from '../../states/accountstate.js';
import { IdpState } from '../../states/idp.state.js';
import { accountDesignTokens } from './sharedstyles.js';
import { CreateOrgModal } from './create-org-modal.js';
import { OrgSelectModal } from './org-select-modal.js';
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
import { commitinfo } from '../../../ts/00_commitinfo_data.js';
declare global {
interface HTMLElementTagNameMap {
@@ -24,10 +27,42 @@ declare global {
@customElement('lele-accountnavigation')
export class LeleAccountNavigation extends DeesElement {
@state()
accessor isGlobalAdmin: boolean = false;
@state()
accessor currentPath: string = window.location.pathname;
constructor() {
super();
}
private async navigateTo(path: string) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(path);
// Update state after navigation to trigger re-render
this.currentPath = window.location.pathname;
}
private async navigateToOrgPage(page: string) {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const path = page ? `/org/${currentState.selectedOrg.data.slug}/${page}` : `/org/${currentState.selectedOrg.data.slug}`;
await this.navigateTo(path);
} else {
const targetPath = page ? `/org/:orgName/${page}` : '/org/:orgName';
const description = page ? `Choose an organization to view its ${page}.` : 'Choose an organization to view its overview.';
const result = await OrgSelectModal.show({
targetPath,
title: 'Select Organization',
description,
});
if (result) {
await this.navigateTo(result.path.replace('/account', ''));
}
}
}
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
@@ -132,6 +167,15 @@ export class LeleAccountNavigation extends DeesElement {
opacity: 1;
}
.navigationOption.active {
background: var(--muted);
color: var(--foreground);
}
.navigationOption.active dees-icon {
opacity: 1;
}
.divider {
height: 1px;
background: var(--border);
@@ -161,11 +205,8 @@ export class LeleAccountNavigation extends DeesElement {
<div class="navContent">
<div class="navigationGroupLabel">Account</div>
<div
class="navigationOption"
@click=${async () => {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl('');
}}
class="navigationOption ${this.isActive('') ? 'active' : ''}"
@click=${() => this.navigateTo('')}
>
<dees-icon .icon=${'lucide:home'}></dees-icon>
Overview
@@ -179,14 +220,6 @@ export class LeleAccountNavigation extends DeesElement {
<dees-icon .icon=${'lucide:shield'}></dees-icon>
Manage Roles
</div>
<div
class="navigationOption"
@click=${async () => {
}}
>
<dees-icon .icon=${'lucide:plus'}></dees-icon>
Create Organization
</div>
<div
class="navigationOption"
@click=${async () => {
@@ -203,25 +236,51 @@ export class LeleAccountNavigation extends DeesElement {
<div class="navigationGroupLabel">Organization</div>
<dees-input-dropdown
.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();
states.accountState.dispatchAction(
states.setSelectedOrg,
currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload)
);
const newOrg = currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload);
states.accountState.dispatchAction(states.setSelectedOrg, newOrg);
// Auto-navigate to new org's current page type (reactivity)
const currentPath = window.location.pathname;
if (currentPath.includes('/org/') && newOrg) {
// Extract the page type (apps, billing, etc.) and navigate to new org
const pathParts = currentPath.split('/');
const pageType = pathParts[5]; // /account/org/:orgName/:pageType
if (pageType) {
await this.navigateTo(`/org/${newOrg.data.slug}/${pageType}`);
} else {
await this.navigateTo(`/org/${newOrg.data.slug}`);
}
}
}}
></dees-input-dropdown>
<div
class="navigationOption"
@click=${async () => {}}
class="navigationOption ${this.isActive('org-overview') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('')}
>
<dees-icon .icon=${'lucide:home'}></dees-icon>
Overview
</div>
<div
class="navigationOption ${this.isActive('apps') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('apps')}
>
<dees-icon .icon=${'lucide:box'}></dees-icon>
Apps
</div>
<div
class="navigationOption"
@click=${async () => {}}
class="navigationOption ${this.isActive('users') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('users')}
>
<dees-icon .icon=${'lucide:users'}></dees-icon>
Users
@@ -234,19 +293,68 @@ export class LeleAccountNavigation extends DeesElement {
Activity
</div>
<div
class="navigationOption"
@click=${async () => {}}
class="navigationOption ${this.isActive('billing') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('billing')}
>
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
Billing
</div>
${this.renderAdminLink()}
</div>
<div class="commitinfo">v${commitinfo.version}</div>
`;
}
public firstUpdated() {
private renderAdminLink(): TemplateResult | null {
if (!this.isGlobalAdmin) {
return null;
}
return html`
<div class="divider"></div>
<div class="navigationGroupLabel">Platform</div>
<div
class="navigationOption ${this.isActive('admin') ? 'active' : ''}"
@click=${() => this.navigateTo('/admin')}
>
<dees-icon .icon=${'lucide:shield'}></dees-icon>
Global Admin
</div>
`;
}
private isActive(page: string): boolean {
const path = this.currentPath;
if (page === '') {
// Account overview - exact match
return path === '/account' || path === '/account/';
}
if (page === 'org-overview') {
// Org overview - /account/org/:slug without trailing page type
return /^\/account\/org\/[^\/]+\/?$/.test(path);
}
// For other pages, check if the path contains the page segment
return path.includes(`/${page}`);
}
public async firstUpdated() {
// Listen for popstate (browser back/forward)
window.addEventListener('popstate', () => {
this.currentPath = window.location.pathname;
});
// Watch for URL changes from external navigation (e.g., modals)
let lastPath = this.currentPath;
const checkPath = () => {
if (window.location.pathname !== lastPath) {
lastPath = window.location.pathname;
this.currentPath = lastPath;
}
requestAnimationFrame(checkPath);
};
requestAnimationFrame(checkPath);
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
if (!orgArg) {
@@ -258,11 +366,21 @@ export class LeleAccountNavigation extends DeesElement {
payload: orgArg.data.slug,
};
};
// "Create new..." option to add at the end
const createNewOption = {
option: '+ Create new...',
key: '__create_new__',
payload: '__create_new__',
};
states.accountState
.select((stateArg) => stateArg.organizations)
.pipe(
plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => {
return orgArrayArg.map(orgToMenuEntry);
const orgEntries = orgArrayArg.map(orgToMenuEntry);
// Add "Create new..." at the end
return [...orgEntries, createNewOption];
})
)
.subscribe((menuEntries) => {
@@ -274,5 +392,12 @@ export class LeleAccountNavigation extends DeesElement {
.subscribe((selectedOrgArg) => {
deesInputDropdown.selectedOption = selectedOrgArg;
});
// Check if user is global admin
states.accountState
.select((stateArg) => stateArg.user)
.subscribe((user) => {
this.isGlobalAdmin = user?.data?.isGlobalAdmin ?? false;
});
}
}
+209
View File
@@ -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;
});
});
}
}
+13 -7
View File
@@ -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
*/
@@ -108,10 +121,3 @@ export const navigationStyles = css`
}
`;
/**
* Legacy export for backwards compatibility
*/
export default css`
${accountDesignTokens}
${typographyStyles}
`;
+748
View File
@@ -0,0 +1,748 @@
import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { IdpState } from '../../../states/idp.state.js';
import * as sharedStyles from '../sharedstyles.js';
declare global {
interface HTMLElementTagNameMap {
'lele-accountview-admin': AdminView;
}
}
interface IAppWithStats {
app: plugins.idpInterfaces.data.IGlobalApp;
connectionCount: number;
}
@customElement('lele-accountview-admin')
export class AdminView extends DeesElement {
@state()
accessor apps: IAppWithStats[] = [];
@state()
accessor loading: boolean = true;
@state()
accessor showCreateDialog: boolean = false;
@state()
accessor editingApp: plugins.idpInterfaces.data.IGlobalApp | null = null;
@state()
accessor newClientSecret: string | null = null;
public static styles = [
cssManager.defaultStyles,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
css`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
}
.subtitle {
color: #71717a;
margin-top: 8px;
font-size: 14px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 20px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #71717a;
}
.apps-section {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.section-title {
font-size: 18px;
font-weight: 600;
}
.app-list {
padding: 0;
}
.app-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid #27272a;
}
.app-item:last-child {
border-bottom: none;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.app-logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-logo dees-icon {
font-size: 24px;
opacity: 0.7;
}
.app-info {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.app-details {
font-size: 13px;
color: #71717a;
display: flex;
gap: 16px;
}
.app-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.app-status.active {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.app-status.inactive {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.app-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid #27272a;
background: transparent;
color: #fafafa;
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn:hover {
background: #27272a;
}
.action-btn.danger:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
.empty-state {
text-align: center;
padding: 48px;
color: #71717a;
}
.empty-state dees-icon {
font-size: 48px;
opacity: 0.5;
margin-bottom: 16px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: #71717a;
}
/* Dialog styles */
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: #18181b;
border: 1px solid #27272a;
border-radius: 16px;
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
}
.dialog-header {
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.dialog-body {
padding: 24px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #27272a;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: #a1a1aa;
}
.form-input {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #27272a;
background: #0a0a0a;
color: #fafafa;
font-size: 14px;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
}
.form-textarea {
min-height: 80px;
resize: vertical;
}
.secret-display {
background: #0a0a0a;
border: 1px solid #27272a;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
.secret-label {
font-size: 12px;
color: #71717a;
margin-bottom: 8px;
}
.secret-value {
font-family: 'Geist Mono', monospace;
font-size: 13px;
word-break: break-all;
color: #22c55e;
}
.secret-warning {
font-size: 12px;
color: #f59e0b;
margin-top: 12px;
display: flex;
align-items: center;
gap: 6px;
}
`,
];
public render(): TemplateResult {
return html`
<div class="container">
<div class="header">
<div>
<h1>Global Admin</h1>
<p class="subtitle">Manage platform-wide settings and global apps</p>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">${this.apps.length}</div>
<div class="stat-label">Total Global Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.apps.filter(a => a.app.data.isActive).length}</div>
<div class="stat-label">Active Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.apps.reduce((sum, a) => sum + a.connectionCount, 0)}</div>
<div class="stat-label">Total Connections</div>
</div>
</div>
<div class="apps-section">
<div class="section-header">
<span class="section-title">Global Apps</span>
<dees-button
@clicked=${() => this.showCreateDialog = true}
>
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
Create App
</dees-button>
</div>
${this.loading ? this.renderLoading() : this.renderAppList()}
</div>
</div>
${this.showCreateDialog ? this.renderCreateDialog() : null}
${this.editingApp ? this.renderEditDialog() : null}
${this.newClientSecret ? this.renderSecretDialog() : null}
`;
}
private renderLoading(): TemplateResult {
return html`
<div class="loading">
<span>Loading apps...</span>
</div>
`;
}
private renderAppList(): TemplateResult {
if (this.apps.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:box'}></dees-icon>
<h3>No Global Apps</h3>
<p>Create your first global app to get started.</p>
</div>
`;
}
return html`
<div class="app-list">
${this.apps.map(({ app, connectionCount }) => html`
<div class="app-item">
<div class="app-logo">
${app.data.logoUrl
? html`<img src="${app.data.logoUrl}" alt="${app.data.name}" />`
: html`<dees-icon .icon=${'lucide:box'}></dees-icon>`
}
</div>
<div class="app-info">
<div class="app-name">${app.data.name}</div>
<div class="app-details">
<span>${app.data.category}</span>
<span>${connectionCount} connections</span>
<span>${app.data.appUrl}</span>
</div>
</div>
<span class="app-status ${app.data.isActive ? 'active' : 'inactive'}">
${app.data.isActive ? 'Active' : 'Inactive'}
</span>
<div class="app-actions">
<button class="action-btn" @click=${() => this.editingApp = app}>
Edit
</button>
<button class="action-btn" @click=${() => this.regenerateCredentials(app.id)}>
Regenerate
</button>
<button class="action-btn danger" @click=${() => this.deleteApp(app.id)}>
Delete
</button>
</div>
</div>
`)}
</div>
`;
}
private renderCreateDialog(): TemplateResult {
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.showCreateDialog = false;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Create Global App</h2>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">App Name</label>
<input type="text" class="form-input" id="app-name" placeholder="e.g., foss.global" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-input form-textarea" id="app-description" placeholder="Describe what this app does..."></textarea>
</div>
<div class="form-group">
<label class="form-label">App URL</label>
<input type="url" class="form-input" id="app-url" placeholder="https://app.example.com" />
</div>
<div class="form-group">
<label class="form-label">Logo URL</label>
<input type="url" class="form-input" id="app-logo" placeholder="https://example.com/logo.png" />
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" class="form-input" id="app-category" placeholder="e.g., Productivity" />
</div>
<div class="form-group">
<label class="form-label">Redirect URIs (comma-separated)</label>
<input type="text" class="form-input" id="app-redirects" placeholder="https://app.example.com/callback" />
</div>
<div class="form-group">
<label class="form-label">Allowed Scopes (comma-separated)</label>
<input type="text" class="form-input" id="app-scopes" placeholder="openid, profile, email" />
</div>
</div>
<div class="dialog-footer">
<dees-button type="secondary" @clicked=${() => this.showCreateDialog = false}>
Cancel
</dees-button>
<dees-button @clicked=${this.createApp}>
Create App
</dees-button>
</div>
</div>
</div>
`;
}
private renderEditDialog(): TemplateResult {
const app = this.editingApp!;
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.editingApp = null;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Edit ${app.data.name}</h2>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">App Name</label>
<input type="text" class="form-input" id="edit-name" .value=${app.data.name} />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-input form-textarea" id="edit-description">${app.data.description}</textarea>
</div>
<div class="form-group">
<label class="form-label">App URL</label>
<input type="url" class="form-input" id="edit-url" .value=${app.data.appUrl} />
</div>
<div class="form-group">
<label class="form-label">Logo URL</label>
<input type="url" class="form-input" id="edit-logo" .value=${app.data.logoUrl} />
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" class="form-input" id="edit-category" .value=${app.data.category} />
</div>
<div class="form-group">
<label class="form-label">Status</label>
<dees-input-checkbox
.label=${'App is active'}
.value=${app.data.isActive}
id="edit-active"
></dees-input-checkbox>
</div>
</div>
<div class="dialog-footer">
<dees-button type="secondary" @clicked=${() => this.editingApp = null}>
Cancel
</dees-button>
<dees-button @clicked=${this.updateApp}>
Save Changes
</dees-button>
</div>
</div>
</div>
`;
}
private renderSecretDialog(): TemplateResult {
return html`
<div class="dialog-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
this.newClientSecret = null;
}
}}>
<div class="dialog">
<div class="dialog-header">
<h2 class="dialog-title">Client Secret Generated</h2>
</div>
<div class="dialog-body">
<p>Your new client secret has been generated. Copy it now - you won't be able to see it again.</p>
<div class="secret-display">
<div class="secret-label">Client Secret</div>
<div class="secret-value">${this.newClientSecret}</div>
</div>
<div class="secret-warning">
<dees-icon .icon=${'lucide:alert-triangle'}></dees-icon>
This secret will only be shown once. Store it securely.
</div>
</div>
<div class="dialog-footer">
<dees-button @clicked=${() => {
navigator.clipboard.writeText(this.newClientSecret!);
}}>
Copy to Clipboard
</dees-button>
<dees-button type="secondary" @clicked=${() => this.newClientSecret = null}>
Close
</dees-button>
</div>
</div>
</div>
`;
}
public async firstUpdated() {
await this.loadApps();
}
private async loadApps() {
this.loading = true;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
'getGlobalAppStats'
);
const response = await typedRequest.fire({ jwt });
this.apps = response?.apps ?? [];
} catch (error) {
console.error('Error loading apps:', error);
} finally {
this.loading = false;
}
}
private async createApp() {
const nameInput = this.shadowRoot!.querySelector('#app-name') as HTMLInputElement;
const descInput = this.shadowRoot!.querySelector('#app-description') as HTMLTextAreaElement;
const urlInput = this.shadowRoot!.querySelector('#app-url') as HTMLInputElement;
const logoInput = this.shadowRoot!.querySelector('#app-logo') as HTMLInputElement;
const categoryInput = this.shadowRoot!.querySelector('#app-category') as HTMLInputElement;
const redirectsInput = this.shadowRoot!.querySelector('#app-redirects') as HTMLInputElement;
const scopesInput = this.shadowRoot!.querySelector('#app-scopes') as HTMLInputElement;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
'createGlobalApp'
);
const response = await typedRequest.fire({
jwt,
name: nameInput.value,
description: descInput.value,
appUrl: urlInput.value,
logoUrl: logoInput.value,
category: categoryInput.value,
redirectUris: redirectsInput.value.split(',').map(s => s.trim()).filter(Boolean),
allowedScopes: scopesInput.value.split(',').map(s => s.trim()).filter(Boolean),
});
this.showCreateDialog = false;
this.newClientSecret = response.clientSecret;
await this.loadApps();
} catch (error) {
console.error('Error creating app:', error);
alert('Failed to create app');
}
}
private async updateApp() {
const app = this.editingApp!;
const nameInput = this.shadowRoot!.querySelector('#edit-name') as HTMLInputElement;
const descInput = this.shadowRoot!.querySelector('#edit-description') as HTMLTextAreaElement;
const urlInput = this.shadowRoot!.querySelector('#edit-url') as HTMLInputElement;
const logoInput = this.shadowRoot!.querySelector('#edit-logo') as HTMLInputElement;
const categoryInput = this.shadowRoot!.querySelector('#edit-category') as HTMLInputElement;
const activeCheckbox = this.shadowRoot!.querySelector('#edit-active') as any;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
'updateGlobalApp'
);
await typedRequest.fire({
jwt,
appId: app.id,
updates: {
name: nameInput.value,
description: descInput.value,
appUrl: urlInput.value,
logoUrl: logoInput.value,
category: categoryInput.value,
isActive: activeCheckbox.value,
},
});
this.editingApp = null;
await this.loadApps();
} catch (error) {
console.error('Error updating app:', error);
alert('Failed to update app');
}
}
private async regenerateCredentials(appId: string) {
if (!confirm('Are you sure you want to regenerate credentials? The current credentials will stop working.')) {
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
'regenerateAppCredentials'
);
const response = await typedRequest.fire({ jwt, appId });
this.newClientSecret = response.clientSecret;
} catch (error) {
console.error('Error regenerating credentials:', error);
alert('Failed to regenerate credentials');
}
}
private async deleteApp(appId: string) {
if (!confirm('Are you sure you want to delete this app? All organizations will be disconnected.')) {
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
'deleteGlobalApp'
);
const response = await typedRequest.fire({ jwt, appId });
if (response.disconnectedOrganizations > 0) {
alert(`App deleted. ${response.disconnectedOrganizations} organizations were disconnected.`);
}
await this.loadApps();
} catch (error) {
console.error('Error deleting app:', error);
alert('Failed to delete app');
}
}
}
+447
View File
@@ -0,0 +1,447 @@
import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
} 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';
declare global {
interface HTMLElementTagNameMap {
'lele-accountview-apps': AppsView;
}
}
interface IAppDisplay {
id: string;
name: string;
description: string;
logoUrl: string;
appUrl: string;
category: string;
isConnected: boolean;
}
@customElement('lele-accountview-apps')
export class AppsView extends DeesElement {
@state()
accessor globalApps: IAppDisplay[] = [];
@state()
accessor loading: boolean = true;
@state()
accessor activeTab: 'global' | 'store' | 'custom' = 'global';
@state()
accessor organizationId: string = '';
public static styles = [
cssManager.defaultStyles,
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);
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.app-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
transition: all 0.15s ease;
}
.app-card:hover {
border-color: var(--muted-foreground);
}
.app-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--muted);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.app-logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-logo dees-icon {
font-size: 24px;
opacity: 0.7;
}
.app-info {
flex: 1;
min-width: 0;
}
.app-name {
font-size: 16px;
font-weight: 600;
color: var(--foreground);
margin: 0 0 4px 0;
}
.app-category {
font-size: 12px;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.app-description {
font-size: 14px;
color: var(--muted-foreground);
line-height: 1.5;
margin: 0 0 16px 0;
}
.app-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.app-link {
font-size: 13px;
color: var(--muted-foreground);
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
transition: color 0.15s ease;
}
.app-link:hover {
color: var(--foreground);
}
.app-link dees-icon {
font-size: 14px;
}
.toggle-container {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-label {
font-size: 13px;
color: var(--muted-foreground);
}
.empty-state {
text-align: center;
padding: 48px;
color: var(--muted-foreground);
}
.empty-state dees-icon {
font-size: 48px;
opacity: 0.5;
margin-bottom: 16px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--muted-foreground);
}
.coming-soon {
text-align: center;
padding: 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
}
.coming-soon dees-icon {
font-size: 48px;
opacity: 0.5;
margin-bottom: 16px;
}
`,
];
public render() {
return html`
<h1>Apps</h1>
<p>Manage apps connected to your organization. Connect global apps, browse the AppStore, or create custom OAuth clients.</p>
<div class="tabs">
<button
class="tab ${this.activeTab === 'global' ? 'active' : ''}"
@click=${() => this.activeTab = 'global'}
>
Global Apps
</button>
<button
class="tab ${this.activeTab === 'store' ? 'active' : ''}"
@click=${() => this.activeTab = 'store'}
>
App Store
</button>
<button
class="tab ${this.activeTab === 'custom' ? 'active' : ''}"
@click=${() => this.activeTab = 'custom'}
>
Custom OIDC
</button>
</div>
${this.renderTabContent()}
`;
}
private renderTabContent() {
switch (this.activeTab) {
case 'global':
return this.renderGlobalApps();
case 'store':
return this.renderAppStore();
case 'custom':
return this.renderCustomOidc();
}
}
private renderGlobalApps() {
if (this.loading) {
return html`
<div class="loading">
<span>Loading apps...</span>
</div>
`;
}
if (this.globalApps.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:box'}></dees-icon>
<h2>No Global Apps Available</h2>
<p>There are no global apps configured yet.</p>
</div>
`;
}
return html`
<div class="app-grid">
${this.globalApps.map(app => html`
<div class="app-card">
<div class="app-header">
<div class="app-logo">
${app.logoUrl ? html`<img src="${app.logoUrl}" alt="${app.name}" />` : html`<dees-icon .icon=${'lucide:box'}></dees-icon>`}
</div>
<div class="app-info">
<h3 class="app-name">${app.name}</h3>
<span class="app-category">${app.category}</span>
</div>
</div>
<p class="app-description">${app.description}</p>
<div class="app-actions">
<a class="app-link" href="${app.appUrl}" target="_blank">
<dees-icon .icon=${'lucide:external-link'}></dees-icon>
Visit App
</a>
<div class="toggle-container">
<span class="toggle-label">${app.isConnected ? 'Connected' : 'Disconnected'}</span>
<dees-input-checkbox
.value=${app.isConnected}
@change=${(e: CustomEvent) => this.toggleAppConnection(app.id, e.detail)}
></dees-input-checkbox>
</div>
</div>
</div>
`)}
</div>
`;
}
private renderAppStore() {
return html`
<div class="coming-soon">
<dees-icon .icon=${'lucide:store'}></dees-icon>
<h2>App Store</h2>
<p>Browse and install partner apps from other organizations.</p>
<p><em>Coming soon in Phase 3</em></p>
</div>
`;
}
private renderCustomOidc() {
return html`
<div class="coming-soon">
<dees-icon .icon=${'lucide:key'}></dees-icon>
<h2>Custom OIDC Apps</h2>
<p>Create and manage your own OAuth/OIDC client applications.</p>
<p><em>Coming soon in Phase 2</em></p>
</div>
`;
}
public async firstUpdated() {
await this.loadApps();
}
private async loadApps() {
this.loading = true;
try {
// Get the organization ID from the URL
const pathParts = window.location.pathname.split('/');
const orgSlug = pathParts[3];
const currentState = accountState.accountState.getState();
const selectedOrg = currentState.organizations.find(org => org.data.slug === orgSlug);
if (!selectedOrg) {
console.error('Organization not found');
this.loading = false;
return;
}
this.organizationId = selectedOrg.id;
// Get JWT from IdpState
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
// Fetch global apps
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
'getGlobalApps'
);
const appsResponse = await typedRequest.fire({
jwt,
});
// Fetch connections for this organization
const connectionsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'getAppConnections'
);
const connectionsResponse = await connectionsRequest.fire({
jwt,
organizationId: this.organizationId,
});
// Map apps with connection status
const connectionMap = new Map(
connectionsResponse.connections
.filter(c => c.data.status === 'active')
.map(c => [c.data.appId, true])
);
this.globalApps = appsResponse.apps.map(app => ({
id: app.id,
name: app.data.name,
description: app.data.description,
logoUrl: app.data.logoUrl,
appUrl: app.data.appUrl,
category: app.data.category,
isConnected: connectionMap.has(app.id),
}));
} catch (error) {
console.error('Error loading apps:', error);
} finally {
this.loading = false;
}
}
private async toggleAppConnection(appId: string, isConnected: boolean) {
try {
// Get JWT from IdpState
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
'toggleAppConnection'
);
await typedRequest.fire({
jwt,
organizationId: this.organizationId,
appId: appId,
action: isConnected ? 'connect' : 'disconnect',
});
// Update local state
this.globalApps = this.globalApps.map(app =>
app.id === appId ? { ...app, isConnected } : app
);
} catch (error) {
console.error('Error toggling app connection:', error);
// Revert the checkbox on error
await this.loadApps();
}
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,4 +1,8 @@
export * from './adminview.js';
export * from './appsview.js';
export * from './baseview.js';
export * from './orgsetup.js';
export * from './orgview.js';
export * from './paddlesetup.js';
export * from './subscriptions.js';
export * from './usersview.js';
+508
View File
@@ -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');
}
}
+42 -25
View File
@@ -9,8 +9,9 @@ import {
} from '@design.estate/dees-element';
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 { IdpState } from '../../../states/idp.state.js';
declare global {
interface HTMLElementTagNameMap {
@@ -22,13 +23,13 @@ declare global {
export class PaddleSetupView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
sharedStyles,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
css`
:host {
display: block;
padding: 48px;
max-width: 900px;
margin: auto;
color: ${cssManager.bdTheme('#333', '#fff')};
margin: 0 auto;
}
`,
];
@@ -54,41 +55,57 @@ export class PaddleSetupView extends DeesElement {
Paddle takes care of tax compliance for us. This allows us to sell our products world wide
while Paddle makes sure any sales are in compliance with local laws.
</p>
<dees-button>Let's do it!</dees-button>
<dees-button @clicked=${() => this.openPaddle()}>Let's do it!</dees-button>
`;
}
/**
*
*/
public async firstUpdated() {
public async openPaddle() {
await this.domtoolsPromise;
const paddleButton = this.shadowRoot.querySelector('dees-button');
const openPaddle = async () => {
await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/paddle.js');
globalThis.Paddle.Setup({
vendor: 30954,
eventCallback: async (dataArg) => {
// The data.event will specify the event type
if (dataArg.event === 'Checkout.Complete') {
const data: plugins.idpInterfaces.data.IPaddleCheckoutData = dataArg.eventData;
const idpState = await IdpState.getSingletonInstance();
// Get user email - first try from state, then fetch directly
let userEmail = state.accountState.getState().user?.data?.email;
if (!userEmail) {
// State not loaded, fetch user directly
const whoIsResponse = await idpState.idpClient.whoIs().catch(() => null);
userEmail = whoIsResponse?.user?.data?.email;
}
if (!userEmail) {
console.error('Unable to get user email for Paddle checkout');
paddleButton.status = 'error';
paddleButton.text = 'Error: Not logged in';
return;
}
// Fetch Paddle config from backend
const configRequest = idpState.idpClient.typedsocket
.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPaddleConfig>('getPaddleConfig');
const { paddleToken, paddlePriceId } = await configRequest.fire({});
await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/v2/paddle.js');
globalThis.Paddle.Initialize({
token: paddleToken,
eventCallback: async (dataArg: any) => {
// Paddle Billing v2 event handling
if (dataArg.name === 'checkout.completed') {
const paddleIframe = document.body.querySelector('iframe');
if (paddleIframe) {
document.body.removeChild(paddleIframe);
}
paddleButton.status = 'pending';
paddleButton.text = 'Processing...';
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, data.checkout.id);
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, dataArg.data.transaction_id);
paddleButton.status = 'success';
paddleButton.text = 'Paddle connected!'
}
},
});
globalThis.Paddle.Checkout.open({
product: 561076,
email: 'phil@kunz.io',
});
};
paddleButton.addEventListener('clicked', async () => {
openPaddle();
items: [{ priceId: paddlePriceId, quantity: 1 }],
customer: { email: userEmail },
});
}
}
+11 -7
View File
@@ -8,7 +8,7 @@ import {
css,
} 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';
@@ -46,12 +46,12 @@ export class SubscriptionView extends DeesElement {
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
cardStyles,
typographyStyles,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
sharedStyles.cardStyles,
sharedStyles.typographyStyles,
css`
:host {
display: block;
padding: 48px;
max-width: 900px;
margin: 0 auto;
@@ -100,8 +100,12 @@ export class SubscriptionView extends DeesElement {
<h3>Paddle</h3>
<dees-button @click=${async () => {
await this.domtoolsPromise;
this.domtools.router.pushUrl(`/org/${state.accountState.getState().selectedOrg.data.slug}/paddlesetup`)
// Extract org slug from current URL: /account/org/{orgSlug}/billing
const pathParts = window.location.pathname.split('/');
const orgSlug = pathParts[3];
// Use parent's subrouter for proper navigation within account section
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${orgSlug}/paddlesetup`);
}}>Set up Paddle.com</dees-button>
<h3>Enterprise Billing</h3>
+941
View File
@@ -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',
});
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import {
query,
} from '@design.estate/dees-element';
import { commitinfo } from '../../dist_ts/00_commitinfo_data.js';
import { commitinfo } from '../../ts/00_commitinfo_data.js';
import { IdpState } from '../states/idp.state.js';
declare global {
+2 -4
View File
@@ -174,13 +174,11 @@ export class IdpLoginPrompt extends DeesElement {
const idpState = await IdpState.getSingletonInstance();
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
const loginRequestWithUsernameAndPassword =
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'/typedrequest',
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'loginWithEmailOrUsernameAndPassword'
);
const loginRequestWithEmail =
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
'/typedrequest',
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
'loginWithEmail'
);
+7 -14
View File
@@ -170,9 +170,9 @@ export class IdpRegistrationPrompt extends DeesElement {
private register = async (valueArg: { emailAddress: string }) => {
const registrationForm: DeesForm = this.shadowRoot.querySelector('#registrationForm');
registrationForm.setStatus('pending', 'registering...');
const idpState = await IdpState.getSingletonInstance();
const firstSignupRequest =
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
'/typedrequest',
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
'firstRegistrationRequest'
);
const response = await firstSignupRequest
@@ -207,21 +207,14 @@ export class IdpRegistrationPrompt extends DeesElement {
}
public async handleRefreshToken(refreshTokenArg: string, delayDispatchMillisArg = 0) {
// a refreshToken binds directly to a session.
// 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>(
'/typedrequest',
'refreshJwt'
);
const responseJwt = await refreshJwt.fire({
refreshToken: refreshTokenArg,
});
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.refreshJwt(refreshTokenArg);
if (responseJwt.jwt) {
if (jwt) {
this.domtools.convenience.smartdelay.delayFor(delayDispatchMillisArg).then(() => {
this.dispatchJwt(responseJwt.jwt);
this.dispatchJwt(jwt);
});
return responseJwt.jwt;
return jwt;
} else {
return null;
}
+6 -6
View File
@@ -488,15 +488,15 @@ export class IdpRegistrationStepper extends DeesElement {
username: this.storedData.email,
password: eventArg.detail.data.password,
});
this.storedData.refreshToken = loginResponse.refreshToken;
deesForm.setStatus('pending', 'Obtaining JWT...');
const jwtResponse = await idpState.idpClient.requests.obtainJwt.fire({
refreshToken: this.storedData.refreshToken,
});
const jwt = await idpState.idpClient.refreshJwt(loginResponse.refreshToken);
if (!jwt) {
deesForm.setStatus('error', 'Failed to establish a login session.');
return;
}
deesForm.setStatus('success', 'Ok! Lets Go!');
await idpState.idpClient.setJwt(jwtResponse.jwt);
idpState.domtools.router.pushUrl('/account');
}, { signal });
},
+1 -1
View File
@@ -21,7 +21,7 @@ declare global {
@customElement('idp-transfermanager')
export class IdpTransfermanager extends DeesElement {
public appData: plugins.idpInterfaces.data.IApp;
public appData: plugins.idpInterfaces.data.IAppLegacy;
public static styles = [
cssManager.defaultStyles,
+3 -5
View File
@@ -12,7 +12,7 @@ const run = async () => {
metaObject: {
title: 'idp.global',
description:
'the code that runs idp.global',
'Your permanent identity on the web',
canonicalDomain: 'https://idp.global',
ldCompany: {
name: 'Task Venture Capital GmbH',
@@ -29,9 +29,7 @@ const run = async () => {
description: 'work',
name: 'Task Venture Capital GmbH',
type: 'company',
facebookUrl: 'https://www.facebook.com/undefined variable',
twitterUrl: 'https://twitter.com/undefined variable',
website: 'https://Task Venture Capital GmbH',
website: 'https://task.vc',
phone: '+49 421 16767 548',
},
closedDate: null,
@@ -44,7 +42,7 @@ const run = async () => {
},
});
// const serviceWorker = await serviceworker.getServiceworkerClient();
await serviceworker.getServiceworkerClient();
const mainTemplate = html`
<style>
+83
View File
@@ -0,0 +1,83 @@
# `ts_web/` Web App Module
The `ts_web/` folder contains the frontend for `idp.global`: login, registration, account management, org management, billing, and admin UI.
It is built with `@design.estate/dees-element`, `@design.estate/dees-domtools`, and the shared `idp.global` client and interface packages.
## 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.
## What Lives Here
| Path | Purpose |
| --- | --- |
| `index.ts` | Frontend entrypoint and initial render |
| `views/viewcontainer.ts` | View switching for welcome, login, register, finishregistration, and account |
| `elements/` | Web components for prompts, layout, and account UI |
| `elements/account/views/` | Account subviews including org, apps, subscriptions, paddle setup, and admin |
| `states/` | App-level and account-level state containers |
## UI Surface
The module currently includes:
- a welcome page
- login and registration prompts
- a multi-step registration flow
- an account area with navigation
- organization selection and creation flows
- bulk member invitation UI
- app and subscription views
- a global admin view
## Routing
`IdpViewcontainer` switches between these frontend states:
| View | Route |
| --- | --- |
| `welcome` | `/` |
| `login` | `/login` |
| `register` | `/register` |
| `finishregistration` | `/finishregistration` |
| `account` | `/account` |
## Build And Run
From the repository root:
```bash
pnpm install
pnpm build
pnpm watch
```
`pnpm watch` rebuilds the frontend bundle from `ts_web/index.ts` into `dist_serve/bundle.js` while the backend serves the app.
## Notes
- The app metadata in `ts_web/index.ts` identifies the site as `idp.global`.
- The frontend uses the shared client package for auth state and backend communication.
- Account-related UI is split into reusable elements plus state containers in `states/`.
## 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.

Some files were not shown because too many files have changed in this diff Show More