Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddf4861e95 | |||
| b31a10b48b | |||
| 91f06ccae1 | |||
| e9eb9b4172 | |||
| a1a684ee81 | |||
| 6044928c70 | |||
| 3cd7499f3f | |||
| 29a21fd3b3 |
@@ -1,23 +1,10 @@
|
||||
{
|
||||
"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",
|
||||
"gitscope": "idp.global",
|
||||
"gitrepo": "idp.global",
|
||||
"gitrepo": "app",
|
||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||
"npmPackagename": "@idp.global/idp.global",
|
||||
"license": "MIT",
|
||||
@@ -45,11 +32,22 @@
|
||||
"user sessions"
|
||||
]
|
||||
},
|
||||
"services": ["mongodb", "minio"],
|
||||
"services": [
|
||||
"mongodb",
|
||||
"minio"
|
||||
],
|
||||
"release": {
|
||||
"registries": ["https://verdaccio.lossless.digital"],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
"targets": {
|
||||
"npm": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemaVersion": 2
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
@@ -58,12 +56,16 @@
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true
|
||||
"production": true,
|
||||
"includeFiles": [
|
||||
"./html/index.html",
|
||||
"./assets/**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/tswatch": {
|
||||
"preset": "service",
|
||||
"preset": "website",
|
||||
"server": {
|
||||
"enabled": false
|
||||
},
|
||||
@@ -71,7 +73,7 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"watch": "./ts/**/*",
|
||||
"command": "npm run startTs",
|
||||
"command": "pnpm run startTs",
|
||||
"restart": true,
|
||||
"debounce": 300,
|
||||
"runOnStart": true
|
||||
@@ -82,8 +84,32 @@
|
||||
"name": "website",
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"watchPatterns": ["./ts_web/**/*"]
|
||||
"watchPatterns": [
|
||||
"./ts_web/**/*"
|
||||
],
|
||||
"triggerReload": false
|
||||
},
|
||||
{
|
||||
"name": "html",
|
||||
"from": "./html/index.html",
|
||||
"to": "./dist_serve/index.html",
|
||||
"watchPatterns": [
|
||||
"./html/**/*"
|
||||
],
|
||||
"triggerReload": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/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"
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/idp.global/app"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-18 - 1.21.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- remove the legacy ts_idpclient package and switch app integrations to @idp.global/sdk (sdk)
|
||||
- Deletes the in-repo @idp.global/client implementation and publishing metadata under ts_idpclient.
|
||||
- Updates the web app to import the browser client from @idp.global/sdk/browser instead of the local build output.
|
||||
- Replaces local file dependencies for @idp.global/catalog and @idp.global/interfaces with published package versions and adds @idp.global/sdk as a runtime dependency.
|
||||
- Refreshes README and story references to document the shared SDK package and browser entrypoint.
|
||||
- Updates project release/config metadata to the newer schema and registry target format.
|
||||
|
||||
## 2026-04-20 - 1.21.0 - feat(reception)
|
||||
add passport device authentication flows and alert delivery management
|
||||
|
||||
- introduce passport device, challenge, and nonce models with typed request interfaces for enrollment, challenge approval, push token registration, and signed device requests
|
||||
- add alert and alert rule models plus alert manager endpoints for listing, resolving by hint, marking seen, and routing notifications to eligible recipients
|
||||
- send security and admin alerts for global admin dashboard access and global app credential regeneration
|
||||
- schedule housekeeping tasks to expire passport challenges and retry pending passport challenge and alert push deliveries
|
||||
- cover passport and alert workflows with new node tests
|
||||
|
||||
## 2026-04-20 - 1.20.0 - feat(auth)
|
||||
add abuse protection for login and OIDC flows with consent-based authorization handling
|
||||
|
||||
- introduces AbuseProtectionManager and AbuseWindow storage to rate limit password login, magic link, password reset, and OIDC token exchange attempts
|
||||
- adds housekeeping cleanup for expired abuse protection windows
|
||||
- adds typed OIDC prepare/complete authorization requests plus consent evaluation and redirect URL generation
|
||||
- updates the login prompt to support OIDC authorization continuation after user login or consent
|
||||
- includes tests for abuse protection behavior and OIDC authorization preparation/completion flows
|
||||
|
||||
## 2026-04-20 - 1.19.1 - fix(ts_interfaces)
|
||||
rename generated TypeScript interface files to remove the loint-reception prefix
|
||||
|
||||
|
||||
+1
-6
@@ -10,14 +10,9 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!--Lets make sure we recognize this as an PWA-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="manifest" href="/idp-manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||
|
||||
<!--Lets load standard fonts-->
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||
|
||||
|
||||
<!--Lets avoid a rescaling flicker due to default body margins-->
|
||||
<style>
|
||||
html {
|
||||
|
||||
+37
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@idp.global/idp.global",
|
||||
"version": "1.19.1",
|
||||
"version": "1.21.1",
|
||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
@@ -9,6 +9,7 @@
|
||||
"test": "pnpm run build && tstest test/",
|
||||
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle",
|
||||
"watch": "tswatch",
|
||||
"seed": "tsrun ts_seed/cli.ts",
|
||||
"start": "(node cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"buildDocs": "tsdoc"
|
||||
@@ -16,66 +17,70 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
"@api.global/typedrequest": "^3.3.1",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@api.global/typedsocket": "^4.1.3",
|
||||
"@consent.software/catalog": "^2.0.1",
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-domtools": "^2.5.4",
|
||||
"@design.estate/dees-domtools": "^2.5.6",
|
||||
"@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/smartcli": "^4.0.20",
|
||||
"@git.zone/tspublish": "^1.11.7",
|
||||
"@idp.global/catalog": "^1.1.1",
|
||||
"@idp.global/interfaces": "^1.0.1",
|
||||
"@idp.global/sdk": "^1.3.0",
|
||||
"@push.rocks/lik": "^6.4.1",
|
||||
"@push.rocks/qenv": "^6.1.4",
|
||||
"@push.rocks/smartcli": "^4.3.0",
|
||||
"@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/smartinteract": "^2.0.6",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartdelay": "^3.1.0",
|
||||
"@push.rocks/smartfile": "^13.1.3",
|
||||
"@push.rocks/smarthash": "^3.2.7",
|
||||
"@push.rocks/smartinteract": "^2.0.16",
|
||||
"@push.rocks/smartjson": "^6.0.1",
|
||||
"@push.rocks/smartjwt": "^2.2.2",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmail": "^2.2.0",
|
||||
"@push.rocks/smartmail": "^2.2.1",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.3.0",
|
||||
"@push.rocks/smartstate": "^2.3.1",
|
||||
"@push.rocks/smarttime": "^4.2.3",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smarturl": "^3.1.0",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@push.rocks/webjwt": "^1.0.9",
|
||||
"@push.rocks/websetup": "^3.0.15",
|
||||
"@push.rocks/webstore": "^2.0.21",
|
||||
"@serve.zone/platformclient": "^1.1.2",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@push.rocks/webjwt": "^1.0.10",
|
||||
"@push.rocks/websetup": "^3.0.20",
|
||||
"@push.rocks/webstore": "^2.0.22",
|
||||
"@serve.zone/platformclient": "^1.1.4",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@uptime.link/webwidget": "^1.2.6",
|
||||
"argon2": "^0.44.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@git.zone/tsbuild": "^4.4.1",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@types/node": "^25.6.0"
|
||||
"@types/node": "^25.9.0"
|
||||
},
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://code.foss.global/idp.global/idp.global.git"
|
||||
"url": "git+https://code.foss.global/idp.global/app.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://code.foss.global/idp.global/idp.global/issues"
|
||||
"url": "https://code.foss.global/idp.global/app/issues"
|
||||
},
|
||||
"homepage": "https://code.foss.global/idp.global/idp.global#readme",
|
||||
"homepage": "https://code.foss.global/idp.global/app#readme",
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
],
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_seed/**/*",
|
||||
"ts_web/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
@@ -83,7 +88,7 @@
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
],
|
||||
"keywords": [
|
||||
|
||||
Generated
+1587
-2788
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
# @idp.global/idp.global
|
||||
|
||||
Identity infrastructure for apps that need accounts, sessions, organizations, invites, admin tooling, and OpenID Connect in one TypeScript codebase.
|
||||
Identity infrastructure for apps that need accounts, sessions, organizations, invites, admin tooling, mobile passport approvals, security alerts, and OpenID Connect in one TypeScript codebase.
|
||||
|
||||
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.
|
||||
This repository ships the `idp.global` server, CLI, web UI, and tspublish submodules used by the hosted service. Shared public contracts live in `@idp.global/interfaces`; reusable browser/server SDK code lives in `@idp.global/sdk`.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -14,6 +14,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- 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.
|
||||
- Supports passport-style mobile device enrollment, signed approval challenges, push registration, security alerts, and NFC/location-backed identity proof flows.
|
||||
- Includes a reusable browser client and a terminal CLI for common account and org workflows.
|
||||
|
||||
## Monorepo Modules
|
||||
@@ -21,10 +22,10 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
| 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 |
|
||||
| `../interfaces/` | Shared request and data contracts published as `@idp.global/interfaces` |
|
||||
| `../sdk/` | Browser and server SDK published as `@idp.global/sdk` |
|
||||
|
||||
## Core Backend Pieces
|
||||
|
||||
@@ -41,6 +42,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- `BillingPlanManager` for Paddle-backed billing data.
|
||||
- `AppManager` and `AppConnectionManager` for app connections and admin app stats.
|
||||
- `ActivityLogManager` for audit-style activity entries.
|
||||
- `AlertManager` for passport alerts and organization/global alert rules.
|
||||
- `AbuseProtectionManager` for rate-limited sensitive flows such as OIDC token exchange.
|
||||
- `PassportManager` and `PassportPushManager` for trusted device enrollment, challenge approval, and push notification delivery.
|
||||
- `OidcManager` for the OIDC/OAuth provider surface.
|
||||
|
||||
## Quick Start
|
||||
@@ -67,7 +71,7 @@ export INSTANCE_NAME=idp-dev
|
||||
|
||||
Optional:
|
||||
|
||||
- `SERVEZONE_PLATFROM_AUTHORIZATION`
|
||||
- `SERVEZONE_PLATFORM_AUTHORIZATION`
|
||||
- `PADDLE_TOKEN`
|
||||
- `PADDLE_PRICE_ID`
|
||||
|
||||
@@ -85,6 +89,19 @@ pnpm watch
|
||||
|
||||
This starts the backend from `ts/` and rebuilds the frontend bundle from `ts_web/`. The service listens on port `2999`.
|
||||
|
||||
### Seed Development Data
|
||||
|
||||
```bash
|
||||
pnpm run seed
|
||||
```
|
||||
|
||||
The seed command starts an interactive CLI that writes to the configured local database. The default demo workspace creates a global admin, an organization, demo users, and global OAuth app records.
|
||||
|
||||
Default development credentials if accepted unchanged:
|
||||
|
||||
- Email: `admin@idp.global`
|
||||
- Password: `idp.global`
|
||||
|
||||
## Runtime Surface
|
||||
|
||||
### Web Routes
|
||||
@@ -93,9 +110,10 @@ This starts the backend from `ts/` and rebuilds the frontend bundle from `ts_web
|
||||
| --- | --- |
|
||||
| `/` | Welcome page |
|
||||
| `/login` | Login flow |
|
||||
| `/logout` | Logout flow |
|
||||
| `/register` | Registration flow |
|
||||
| `/finishregistration` | Multi-step registration completion |
|
||||
| `/account` | Signed-in account area |
|
||||
| `/account` | Signed-in account area and account subroutes |
|
||||
|
||||
### OIDC and OAuth Endpoints
|
||||
|
||||
@@ -110,12 +128,24 @@ This starts the backend from `ts/` and rebuilds the frontend bundle from `ts_web
|
||||
|
||||
Supported scopes in the OIDC manager include `openid`, `profile`, `email`, `organizations`, and `roles`.
|
||||
|
||||
## Passport And Mobile Approval Flow
|
||||
|
||||
`PassportManager` powers the trusted-device side of idp.global. A web session can create a passport enrollment challenge, the Swift app completes enrollment through a QR/NFC pairing payload, and later sign-in or identity checks can be approved by the paired device with signed challenge responses.
|
||||
|
||||
The typed request surface includes:
|
||||
|
||||
- `createPassportEnrollmentChallenge` and `completePassportEnrollment` for pairing a trusted device.
|
||||
- `getPassportDevices` and `revokePassportDevice` for account-level device management.
|
||||
- `createPassportChallenge`, `approvePassportChallenge`, `rejectPassportChallenge`, and `listPendingPassportChallenges` for approval flows.
|
||||
- `getPassportDashboard`, `listPassportAlerts`, and `markPassportAlertSeen` for mobile app dashboards and notifications.
|
||||
- `registerPassportPushToken` for push delivery setup.
|
||||
|
||||
## SDK Example
|
||||
|
||||
The browser SDK lives in `ts_idpclient/` and is published as `@idp.global/client`.
|
||||
Browser integrations should use the dedicated SDK browser entrypoint published by `@idp.global/sdk`.
|
||||
|
||||
```ts
|
||||
import { IdpClient } from '@idp.global/client';
|
||||
import { IdpClient } from '@idp.global/sdk/browser';
|
||||
|
||||
const idpClient = new IdpClient('https://idp.global');
|
||||
await idpClient.enableTypedSocket();
|
||||
@@ -153,10 +183,10 @@ The CLI stores credentials in `~/.idp-global/credentials.json` and reads `IDP_UR
|
||||
|
||||
## Shared Interfaces
|
||||
|
||||
`ts_interfaces/` exports the type contracts shared across the stack:
|
||||
The sibling `@idp.global/interfaces` package exports the type contracts shared across the stack:
|
||||
|
||||
- `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.
|
||||
- `data/*` for users, orgs, roles, JWTs, sessions, devices, billing plans, apps, passport records, alerts, and OIDC payloads.
|
||||
- `request/*` for auth, registration, user, org, invitation, app, admin, billing, JWT, passport, alert, and OIDC request contracts.
|
||||
- `tags/*` for shared tag exports.
|
||||
|
||||
## Frontend
|
||||
@@ -183,7 +213,7 @@ The CLI stores credentials in `~/.idp-global/credentials.json` and reads `IDP_UR
|
||||
- Package manager: `pnpm`
|
||||
- Main backend entrypoint: `ts/index.ts`
|
||||
- Frontend entrypoint: `ts_web/index.ts`
|
||||
- Browser SDK entrypoint: `ts_idpclient/index.ts`
|
||||
- Browser SDK entrypoint: `@idp.global/sdk/browser`
|
||||
- CLI entrypoint: `ts_idpcli/index.ts`
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
@@ -18,8 +18,8 @@ As a developer, I want comprehensive documentation for the IDP client SDK so tha
|
||||
- [ ] Interactive API playground/sandbox
|
||||
|
||||
## Technical Notes
|
||||
- `ts_idpclient/` contains the client SDK
|
||||
- README.md has basic usage but needs expansion
|
||||
- `@idp.global/sdk` contains the browser and server SDK surfaces
|
||||
- The SDK README has basic usage but needs expansion
|
||||
- Generate API docs from TypeScript using TypeDoc
|
||||
- Host documentation on dedicated site or GitHub pages
|
||||
- Consider OpenAPI/Swagger spec for REST endpoints
|
||||
|
||||
@@ -18,7 +18,7 @@ As a developer, I want to properly register my application with a unique App ID
|
||||
- [ ] Delete/deactivate applications
|
||||
|
||||
## Technical Notes
|
||||
- Current client has `id: ''` placeholder (TODO in code)
|
||||
- SDK clients should receive app identity from the registered application model instead of hard-coded placeholders
|
||||
- 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)
|
||||
@@ -41,4 +41,4 @@ The Apps system supports three types:
|
||||
- DEV-008: Submit App to AppStore
|
||||
|
||||
## Related TODOs
|
||||
- `ts_idpclient/classes.idpclient.ts:30` - `id: '', // TODO`
|
||||
- Keep app identity initialization aligned with the shared `@idp.global/sdk` client configuration.
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
This directory contains user stories for the idp.global Identity Provider platform, organized by persona.
|
||||
|
||||
## 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.
|
||||
|
||||
## Scope
|
||||
|
||||
These stories are planning and product-discovery notes for the app repository. They are not API documentation and should be read alongside the current source in `ts/`, `ts_web/`, `ts_idpcli/`, the sibling `@idp.global/interfaces` package, and `@idp.global/sdk`.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
@@ -89,4 +97,24 @@ Stories derived from code TODOs reference these files:
|
||||
- `ts/reception/classes.jwtmanager.ts:40,52`
|
||||
- `ts/reception/classes.loginsessionmanager.ts:229-238,256`
|
||||
- `ts/reception/classes.billingplan.ts:16`
|
||||
- `ts_idpclient/classes.idpclient.ts:30`
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import {
|
||||
AbuseProtectionManager,
|
||||
type IAbuseProtectionConfig,
|
||||
} from '../ts/reception/classes.abuseprotectionmanager.js';
|
||||
import { AbuseWindow } from '../ts/reception/classes.abusewindow.js';
|
||||
|
||||
const createTestAbuseProtectionManager = () => {
|
||||
const manager = new AbuseProtectionManager({
|
||||
db: { smartdataDb: {} },
|
||||
} as any);
|
||||
|
||||
const store = new Map<string, AbuseWindow>();
|
||||
const originalSave = AbuseWindow.prototype.save;
|
||||
const originalDelete = AbuseWindow.prototype.delete;
|
||||
|
||||
(AbuseWindow.prototype as AbuseWindow & { save: () => Promise<void> }).save = async function () {
|
||||
store.set(this.id, this);
|
||||
};
|
||||
(AbuseWindow.prototype as AbuseWindow & { delete: () => Promise<void> }).delete = async function () {
|
||||
store.delete(this.id);
|
||||
};
|
||||
|
||||
(manager as any).CAbuseWindow = {
|
||||
getInstance: async (queryArg) => store.get(queryArg.id) ?? null,
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
AbuseWindow.prototype.save = originalSave;
|
||||
AbuseWindow.prototype.delete = originalDelete;
|
||||
};
|
||||
|
||||
return {
|
||||
manager,
|
||||
store,
|
||||
restore,
|
||||
};
|
||||
};
|
||||
|
||||
const testConfig: IAbuseProtectionConfig = {
|
||||
maxAttempts: 2,
|
||||
windowMillis: 1_000,
|
||||
blockDurationMillis: 2_000,
|
||||
};
|
||||
|
||||
tap.test('blocks after too many attempts within the active window', async () => {
|
||||
const { manager, restore } = createTestAbuseProtectionManager();
|
||||
|
||||
try {
|
||||
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||
|
||||
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('resets attempts after the block and window have elapsed', async () => {
|
||||
const { manager, store, restore } = createTestAbuseProtectionManager();
|
||||
|
||||
try {
|
||||
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
|
||||
|
||||
const abuseWindow = Array.from(store.values())[0];
|
||||
abuseWindow.data.blockedUntil = Date.now() - 10;
|
||||
abuseWindow.data.windowStartedAt = Date.now() - testConfig.windowMillis - 10;
|
||||
abuseWindow.data.validUntil = Date.now() + 1_000;
|
||||
|
||||
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||
expect(abuseWindow.data.attemptCount).toEqual(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('clears stored attempts after a successful action', async () => {
|
||||
const { manager, store, restore } = createTestAbuseProtectionManager();
|
||||
|
||||
try {
|
||||
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||
expect(store.size).toEqual(1);
|
||||
|
||||
await manager.clearAttempts('passwordLogin', 'phil@example.com');
|
||||
expect(store.size).toEqual(0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,352 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { Alert } from '../ts/reception/classes.alert.js';
|
||||
import { AlertManager } from '../ts/reception/classes.alertmanager.js';
|
||||
import { AlertRule } from '../ts/reception/classes.alertrule.js';
|
||||
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||||
import { Role } from '../ts/reception/classes.role.js';
|
||||
import { User } from '../ts/reception/classes.user.js';
|
||||
|
||||
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||
};
|
||||
|
||||
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||
return Object.entries(queryArg).every(([keyArg, valueArg]) => getNestedValue(targetArg, keyArg) === valueArg);
|
||||
};
|
||||
|
||||
const createTestAlertManager = () => {
|
||||
const alerts = new Map<string, Alert>();
|
||||
const alertRules = new Map<string, AlertRule>();
|
||||
const users = new Map<string, User>();
|
||||
const roles = new Map<string, Role>();
|
||||
const passportDevices = new Map<string, PassportDevice>();
|
||||
const deliveredHints: string[] = [];
|
||||
|
||||
const manager = new AlertManager({
|
||||
db: { smartdataDb: {} },
|
||||
typedrouter: { addTypedRouter: () => undefined },
|
||||
jwtManager: {
|
||||
verifyJWTAndGetData: async (jwtArg: string) => ({
|
||||
data: {
|
||||
userId: jwtArg,
|
||||
},
|
||||
}),
|
||||
},
|
||||
userManager: {
|
||||
CUser: {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(users.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async () => Array.from(users.values()),
|
||||
},
|
||||
},
|
||||
roleManager: {
|
||||
CRole: {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(roles.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
},
|
||||
getAllRolesForOrg: async (organizationIdArg: string) =>
|
||||
Array.from(roles.values()).filter((roleArg) => roleArg.data.organizationId === organizationIdArg),
|
||||
},
|
||||
passportManager: {
|
||||
authenticatePassportDeviceRequest: async (requestArg: { deviceId: string }) => {
|
||||
return passportDevices.get(requestArg.deviceId)!;
|
||||
},
|
||||
getPassportDevicesForUser: async (userIdArg: string) =>
|
||||
Array.from(passportDevices.values()).filter(
|
||||
(deviceArg) => deviceArg.data.userId === userIdArg && deviceArg.data.status === 'active'
|
||||
),
|
||||
},
|
||||
passportPushManager: {
|
||||
deliverAlertHint: async (_passportDeviceArg: PassportDevice, alertArg: Alert) => {
|
||||
deliveredHints.push(alertArg.data.notification.hintId);
|
||||
alertArg.data.notification = {
|
||||
...alertArg.data.notification,
|
||||
status: 'sent',
|
||||
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||
deliveredAt: Date.now(),
|
||||
lastError: null,
|
||||
};
|
||||
await alertArg.save();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const originalAlertSave = Alert.prototype.save;
|
||||
const originalAlertDelete = Alert.prototype.delete;
|
||||
const originalAlertRuleSave = AlertRule.prototype.save;
|
||||
const originalAlertRuleDelete = AlertRule.prototype.delete;
|
||||
|
||||
(Alert.prototype as Alert & { save: () => Promise<void> }).save = async function () {
|
||||
alerts.set(this.id, this);
|
||||
};
|
||||
(Alert.prototype as Alert & { delete: () => Promise<void> }).delete = async function () {
|
||||
alerts.delete(this.id);
|
||||
};
|
||||
(AlertRule.prototype as AlertRule & { save: () => Promise<void> }).save = async function () {
|
||||
alertRules.set(this.id, this);
|
||||
};
|
||||
(AlertRule.prototype as AlertRule & { delete: () => Promise<void> }).delete = async function () {
|
||||
alertRules.delete(this.id);
|
||||
};
|
||||
|
||||
(manager as any).CAlert = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(alerts.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(alerts.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
},
|
||||
};
|
||||
(manager as any).CAlertRule = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(alertRules.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async () => Array.from(alertRules.values()),
|
||||
};
|
||||
|
||||
return {
|
||||
manager,
|
||||
alerts,
|
||||
alertRules,
|
||||
users,
|
||||
roles,
|
||||
passportDevices,
|
||||
deliveredHints,
|
||||
restore: () => {
|
||||
Alert.prototype.save = originalAlertSave;
|
||||
Alert.prototype.delete = originalAlertDelete;
|
||||
AlertRule.prototype.save = originalAlertRuleSave;
|
||||
AlertRule.prototype.delete = originalAlertRuleDelete;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const addUser = (
|
||||
usersArg: Map<string, User>,
|
||||
optionsArg: { id: string; email: string; isGlobalAdmin?: boolean }
|
||||
) => {
|
||||
const user = new User();
|
||||
user.id = optionsArg.id;
|
||||
user.data = {
|
||||
name: optionsArg.email,
|
||||
username: optionsArg.email,
|
||||
email: optionsArg.email,
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
isGlobalAdmin: optionsArg.isGlobalAdmin,
|
||||
};
|
||||
usersArg.set(user.id, user);
|
||||
return user;
|
||||
};
|
||||
|
||||
const addPassportDevice = (
|
||||
passportDevicesArg: Map<string, PassportDevice>,
|
||||
optionsArg: { id: string; userId: string; label: string }
|
||||
) => {
|
||||
const device = new PassportDevice();
|
||||
device.id = optionsArg.id;
|
||||
device.data = {
|
||||
userId: optionsArg.userId,
|
||||
label: optionsArg.label,
|
||||
platform: 'ios',
|
||||
status: 'active',
|
||||
publicKeyAlgorithm: 'p256',
|
||||
publicKeyX963Base64: 'public-key',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
pushRegistration: {
|
||||
provider: 'apns',
|
||||
token: `${optionsArg.id}-token`,
|
||||
topic: 'global.idp.swiftapp',
|
||||
environment: 'development',
|
||||
registeredAt: Date.now(),
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
};
|
||||
passportDevicesArg.set(device.id, device);
|
||||
return device;
|
||||
};
|
||||
|
||||
tap.test('creates global admin access alerts with the built-in fallback rule', async () => {
|
||||
const { manager, users, passportDevices, alerts, deliveredHints, restore } = createTestAlertManager();
|
||||
|
||||
try {
|
||||
addUser(users, { id: 'admin-1', email: 'admin-1@example.com', isGlobalAdmin: true });
|
||||
addPassportDevice(passportDevices, { id: 'device-1', userId: 'admin-1', label: 'Admin Phone' });
|
||||
|
||||
const createdAlerts = await manager.createAlertsForEvent({
|
||||
category: 'admin',
|
||||
eventType: 'global_admin_access',
|
||||
severity: 'high',
|
||||
title: 'Global admin console accessed',
|
||||
body: 'A global admin accessed the console.',
|
||||
actorUserId: 'admin-1',
|
||||
relatedEntityType: 'global-admin-console',
|
||||
});
|
||||
|
||||
expect(createdAlerts).toHaveLength(1);
|
||||
expect(alerts.size).toEqual(1);
|
||||
expect(createdAlerts[0].data.notification.status).toEqual('sent');
|
||||
expect(deliveredHints).toHaveLength(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('routes organization-scoped alerts to org admins by rule', async () => {
|
||||
const { manager, users, roles, passportDevices, restore } = createTestAlertManager();
|
||||
|
||||
try {
|
||||
addUser(users, { id: 'owner-1', email: 'owner@example.com' });
|
||||
addUser(users, { id: 'viewer-1', email: 'viewer@example.com' });
|
||||
addPassportDevice(passportDevices, { id: 'owner-device', userId: 'owner-1', label: 'Owner Phone' });
|
||||
|
||||
const ownerRole = new Role();
|
||||
ownerRole.id = 'role-owner';
|
||||
ownerRole.data = {
|
||||
userId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
roles: ['owner'],
|
||||
};
|
||||
roles.set(ownerRole.id, ownerRole);
|
||||
|
||||
const viewerRole = new Role();
|
||||
viewerRole.id = 'role-viewer';
|
||||
viewerRole.data = {
|
||||
userId: 'viewer-1',
|
||||
organizationId: 'org-1',
|
||||
roles: ['viewer'],
|
||||
};
|
||||
roles.set(viewerRole.id, viewerRole);
|
||||
|
||||
const rule = new AlertRule();
|
||||
rule.id = 'org-admin-rule';
|
||||
rule.data = {
|
||||
scope: 'organization',
|
||||
organizationId: 'org-1',
|
||||
eventType: 'org_security_notice',
|
||||
minimumSeverity: 'medium',
|
||||
recipientMode: 'org_admins',
|
||||
recipientUserIds: [],
|
||||
push: true,
|
||||
enabled: true,
|
||||
createdByUserId: 'owner-1',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await rule.save();
|
||||
|
||||
const createdAlerts = await manager.createAlertsForEvent({
|
||||
category: 'security',
|
||||
eventType: 'org_security_notice',
|
||||
severity: 'high',
|
||||
title: 'Organization security event',
|
||||
body: 'A sensitive organization event occurred.',
|
||||
actorUserId: 'viewer-1',
|
||||
organizationId: 'org-1',
|
||||
});
|
||||
|
||||
expect(createdAlerts).toHaveLength(1);
|
||||
expect(createdAlerts[0].data.recipientUserId).toEqual('owner-1');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('uses built-in organization fallback rules for app connection events', async () => {
|
||||
const { manager, users, roles, passportDevices, deliveredHints, restore } = createTestAlertManager();
|
||||
|
||||
try {
|
||||
addUser(users, { id: 'owner-1', email: 'owner@example.com' });
|
||||
addPassportDevice(passportDevices, { id: 'owner-device', userId: 'owner-1', label: 'Owner Phone' });
|
||||
|
||||
const ownerRole = new Role();
|
||||
ownerRole.id = 'role-owner';
|
||||
ownerRole.data = {
|
||||
userId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
roles: ['owner'],
|
||||
};
|
||||
roles.set(ownerRole.id, ownerRole);
|
||||
|
||||
const createdAlerts = await manager.createAlertsForEvent({
|
||||
category: 'admin',
|
||||
eventType: 'org_app_connected',
|
||||
severity: 'medium',
|
||||
title: 'Organization app connected',
|
||||
body: 'A new app was connected.',
|
||||
actorUserId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
relatedEntityId: 'app-1',
|
||||
relatedEntityType: 'global-app',
|
||||
});
|
||||
|
||||
expect(createdAlerts).toHaveLength(1);
|
||||
expect(createdAlerts[0].data.recipientUserId).toEqual('owner-1');
|
||||
expect(deliveredHints).toHaveLength(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('lists alerts, resolves hint lookups, and marks alerts seen', async () => {
|
||||
const { manager, alerts, restore } = createTestAlertManager();
|
||||
|
||||
try {
|
||||
const alert = new Alert();
|
||||
alert.id = 'alert-1';
|
||||
alert.data = {
|
||||
recipientUserId: 'user-1',
|
||||
category: 'security',
|
||||
eventType: 'global_admin_access',
|
||||
severity: 'high',
|
||||
title: 'Important alert',
|
||||
body: 'Please inspect this alert.',
|
||||
notification: {
|
||||
hintId: 'hint-1',
|
||||
status: 'sent',
|
||||
attemptCount: 1,
|
||||
createdAt: Date.now(),
|
||||
deliveredAt: Date.now(),
|
||||
seenAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
seenAt: null,
|
||||
dismissedAt: null,
|
||||
};
|
||||
await alert.save();
|
||||
|
||||
const listedAlerts = await manager.listAlertsForUser('user-1');
|
||||
expect(listedAlerts).toHaveLength(1);
|
||||
|
||||
const hintAlert = await manager.getAlertByHint('user-1', 'hint-1');
|
||||
expect(hintAlert?.id).toEqual('alert-1');
|
||||
|
||||
const seenAlert = await manager.markAlertSeen('user-1', 'hint-1');
|
||||
expect(seenAlert.data.notification.status).toEqual('seen');
|
||||
expect(seenAlert.data.seenAt).toBeGreaterThan(0);
|
||||
expect(alerts.get('alert-1')?.data.notification.status).toEqual('seen');
|
||||
|
||||
const dismissedAlert = await manager.dismissAlert('user-1', 'hint-1');
|
||||
expect(dismissedAlert.data.dismissedAt).toBeGreaterThan(0);
|
||||
|
||||
const defaultList = await manager.listAlertsForUser('user-1');
|
||||
expect(defaultList).toHaveLength(0);
|
||||
|
||||
const fullList = await manager.listAlertsForUser('user-1', true);
|
||||
expect(fullList).toHaveLength(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,168 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { AppConnection } from '../ts/reception/classes.appconnection.js';
|
||||
import { AppConnectionManager } from '../ts/reception/classes.appconnectionmanager.js';
|
||||
import { User } from '../ts/reception/classes.user.js';
|
||||
|
||||
const createTestAppConnectionManager = (optionsArg: {
|
||||
allowedScopes?: string[];
|
||||
grantedScopes?: string[];
|
||||
} = {}) => {
|
||||
const activities: Array<{ userId: string; action: string; description: string; metadata?: any }> = [];
|
||||
const alerts: Array<{ eventType: string; organizationId?: string; relatedEntityId?: string }> = [];
|
||||
|
||||
const user = new User();
|
||||
user.id = 'user-1';
|
||||
user.data = {
|
||||
name: 'Admin User',
|
||||
username: 'admin@example.com',
|
||||
email: 'admin@example.com',
|
||||
status: 'active',
|
||||
connectedOrgs: ['org-1'],
|
||||
};
|
||||
|
||||
const app = {
|
||||
id: 'app-1',
|
||||
type: 'global',
|
||||
data: {
|
||||
name: 'Finance App',
|
||||
oauthCredentials: {
|
||||
allowedScopes: optionsArg.allowedScopes || ['openid', 'roles', 'billing'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const organization = {
|
||||
id: 'org-1',
|
||||
data: {
|
||||
name: 'Lossless GmbH',
|
||||
slug: 'lossless',
|
||||
},
|
||||
checkIfUserIsAdmin: async () => true,
|
||||
};
|
||||
|
||||
const connection = new AppConnection();
|
||||
connection.id = 'connection-1';
|
||||
connection.data = {
|
||||
organizationId: organization.id,
|
||||
appId: app.id,
|
||||
appType: 'global',
|
||||
status: 'active',
|
||||
connectedAt: Date.now(),
|
||||
connectedByUserId: user.id,
|
||||
grantedScopes: optionsArg.grantedScopes || ['openid', 'roles', 'billing'],
|
||||
roleMappings: [],
|
||||
};
|
||||
connection.save = async () => undefined;
|
||||
|
||||
const reception = {
|
||||
db: { smartdataDb: {} },
|
||||
typedrouter: { addTypedRouter: () => undefined },
|
||||
organizationmanager: {
|
||||
COrganization: {
|
||||
getInstance: async () => organization,
|
||||
},
|
||||
getAvailableRoleKeys: async () => ['owner', 'admin', 'viewer', 'finance'],
|
||||
validateRoleKey: (roleKeyArg: string) => roleKeyArg.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
||||
},
|
||||
appManager: {
|
||||
getAppById: async () => app,
|
||||
},
|
||||
activityLogManager: {
|
||||
logActivity: async (userId: string, action: string, description: string, metadata?: any) => {
|
||||
activities.push({ userId, action, description, metadata });
|
||||
},
|
||||
},
|
||||
alertManager: {
|
||||
createAlertsForEvent: async (options: { eventType: string; organizationId?: string; relatedEntityId?: string }) => {
|
||||
alerts.push(options);
|
||||
return [];
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const manager = new AppConnectionManager(reception);
|
||||
(manager as any).CAppConnection = {
|
||||
getInstance: async () => connection,
|
||||
};
|
||||
|
||||
return {
|
||||
manager,
|
||||
user,
|
||||
connection,
|
||||
activities,
|
||||
alerts,
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('rejects app role mappings with unsupported app scopes', async () => {
|
||||
const { manager, user, connection, activities } = createTestAppConnectionManager({
|
||||
allowedScopes: ['openid', 'roles'],
|
||||
grantedScopes: ['openid', 'roles', 'billing'],
|
||||
});
|
||||
|
||||
await expect(manager.updateAppRoleMappings({
|
||||
user,
|
||||
organizationId: 'org-1',
|
||||
appId: 'app-1',
|
||||
roleMappings: [{
|
||||
orgRoleKey: 'finance',
|
||||
appRoles: [],
|
||||
permissions: [],
|
||||
scopes: ['billing'],
|
||||
}],
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(connection.data.roleMappings).toEqual([]);
|
||||
expect(activities).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('rejects app role mappings with ungranted connection scopes', async () => {
|
||||
const { manager, user, connection, activities } = createTestAppConnectionManager({
|
||||
allowedScopes: ['openid', 'roles', 'billing'],
|
||||
grantedScopes: ['openid', 'roles'],
|
||||
});
|
||||
|
||||
await expect(manager.updateAppRoleMappings({
|
||||
user,
|
||||
organizationId: 'org-1',
|
||||
appId: 'app-1',
|
||||
roleMappings: [{
|
||||
orgRoleKey: 'finance',
|
||||
appRoles: [],
|
||||
permissions: [],
|
||||
scopes: ['billing'],
|
||||
}],
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(connection.data.roleMappings).toEqual([]);
|
||||
expect(activities).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('updates app role mappings and writes audit activity', async () => {
|
||||
const { manager, user, connection, activities, alerts } = createTestAppConnectionManager();
|
||||
|
||||
await manager.updateAppRoleMappings({
|
||||
user,
|
||||
organizationId: 'org-1',
|
||||
appId: 'app-1',
|
||||
roleMappings: [{
|
||||
orgRoleKey: ' Finance ',
|
||||
appRoles: ['accountant', 'accountant', ''],
|
||||
permissions: ['invoices:read'],
|
||||
scopes: ['billing'],
|
||||
}],
|
||||
});
|
||||
|
||||
expect(connection.data.roleMappings).toEqual([{
|
||||
orgRoleKey: 'finance',
|
||||
appRoles: ['accountant'],
|
||||
permissions: ['invoices:read'],
|
||||
scopes: ['billing'],
|
||||
}]);
|
||||
expect(activities[0].action).toEqual('org_app_role_mappings_updated');
|
||||
expect(activities[0].metadata.targetId).toEqual(connection.id);
|
||||
expect(alerts[0].eventType).toEqual('org_app_role_mappings_updated');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,9 +1,25 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { App } from '../ts/reception/classes.app.js';
|
||||
import { AppConnection } from '../ts/reception/classes.appconnection.js';
|
||||
import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js';
|
||||
import { OidcAuthorizationCode } from '../ts/reception/classes.oidcauthorizationcode.js';
|
||||
import { OidcManager } from '../ts/reception/classes.oidcmanager.js';
|
||||
import { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
|
||||
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js';
|
||||
import { Role } from '../ts/reception/classes.role.js';
|
||||
import { User } from '../ts/reception/classes.user.js';
|
||||
|
||||
const createTestOidcManager = (receptionOverridesArg: Record<string, any> = {}) => {
|
||||
const oidcManager = new OidcManager({
|
||||
db: { smartdataDb: {} },
|
||||
typedrouter: { addTypedRouter: () => undefined },
|
||||
options: { baseUrl: 'https://idp.example' },
|
||||
...receptionOverridesArg,
|
||||
} as any);
|
||||
void oidcManager.stop();
|
||||
return oidcManager;
|
||||
};
|
||||
|
||||
tap.test('stores authorization codes as hashes and marks them used', async () => {
|
||||
const authCode = new OidcAuthorizationCode();
|
||||
@@ -73,4 +89,213 @@ tap.test('merges user consent scopes without duplicates', async () => {
|
||||
expect(saveCount).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('builds an OAuth redirect URL after successful authorization completion', async () => {
|
||||
const oidcManager = createTestOidcManager();
|
||||
|
||||
(oidcManager as any).findAppByClientId = async () => ({
|
||||
data: {
|
||||
name: 'Example App',
|
||||
appUrl: 'https://app.example',
|
||||
logoUrl: 'https://app.example/logo.png',
|
||||
oauthCredentials: {
|
||||
clientId: 'client-1',
|
||||
redirectUris: ['https://app.example/callback'],
|
||||
allowedScopes: ['openid', 'profile', 'email'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(oidcManager as any).generateAuthorizationCode = async () => 'generated-auth-code';
|
||||
(oidcManager as any).getUserConsent = async () => ({
|
||||
data: {
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
},
|
||||
});
|
||||
(oidcManager as any).upsertUserConsent = async () => undefined;
|
||||
|
||||
const result = await oidcManager.completeAuthorizationForUser('user-1', {
|
||||
clientId: 'client-1',
|
||||
redirectUri: 'https://app.example/callback',
|
||||
scope: 'openid profile email',
|
||||
state: 'xyz-state',
|
||||
codeChallenge: 'challenge',
|
||||
codeChallengeMethod: 'S256',
|
||||
nonce: 'nonce-1',
|
||||
consentApproved: true,
|
||||
});
|
||||
|
||||
expect(result.code).toEqual('generated-auth-code');
|
||||
expect(result.redirectUrl).toEqual(
|
||||
'https://app.example/callback?code=generated-auth-code&state=xyz-state'
|
||||
);
|
||||
|
||||
await oidcManager.stop();
|
||||
});
|
||||
|
||||
tap.test('prepares OAuth consent when scopes are not yet granted', async () => {
|
||||
const oidcManager = createTestOidcManager();
|
||||
|
||||
(oidcManager as any).findAppByClientId = async () => ({
|
||||
data: {
|
||||
name: 'Example App',
|
||||
appUrl: 'https://app.example',
|
||||
logoUrl: 'https://app.example/logo.png',
|
||||
oauthCredentials: {
|
||||
clientId: 'client-1',
|
||||
redirectUris: ['https://app.example/callback'],
|
||||
allowedScopes: ['openid', 'profile', 'email'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(oidcManager as any).getUserConsent = async () => ({
|
||||
data: {
|
||||
scopes: ['openid'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
|
||||
clientId: 'client-1',
|
||||
redirectUri: 'https://app.example/callback',
|
||||
scope: 'openid profile email',
|
||||
state: 'xyz-state',
|
||||
prompt: undefined,
|
||||
codeChallenge: undefined,
|
||||
codeChallengeMethod: undefined,
|
||||
nonce: undefined,
|
||||
});
|
||||
|
||||
expect(result.status).toEqual('consent_required');
|
||||
expect(result.requestedScopes.sort()).toEqual(['email', 'openid', 'profile']);
|
||||
expect(result.grantedScopes).toEqual(['openid']);
|
||||
|
||||
await oidcManager.stop();
|
||||
});
|
||||
|
||||
tap.test('prepares OAuth authorization as ready when consent already exists', async () => {
|
||||
const oidcManager = createTestOidcManager();
|
||||
|
||||
(oidcManager as any).findAppByClientId = async () => ({
|
||||
data: {
|
||||
name: 'Example App',
|
||||
appUrl: 'https://app.example',
|
||||
logoUrl: 'https://app.example/logo.png',
|
||||
oauthCredentials: {
|
||||
clientId: 'client-1',
|
||||
redirectUris: ['https://app.example/callback'],
|
||||
allowedScopes: ['openid', 'profile', 'email'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(oidcManager as any).getUserConsent = async () => ({
|
||||
data: {
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
|
||||
clientId: 'client-1',
|
||||
redirectUri: 'https://app.example/callback',
|
||||
scope: 'openid profile email',
|
||||
state: 'xyz-state',
|
||||
prompt: undefined,
|
||||
codeChallenge: undefined,
|
||||
codeChallengeMethod: undefined,
|
||||
nonce: undefined,
|
||||
});
|
||||
|
||||
expect(result.status).toEqual('ready');
|
||||
|
||||
await oidcManager.stop();
|
||||
});
|
||||
|
||||
tap.test('includes connected app role mappings in roles-scope claims', async () => {
|
||||
const user = new User();
|
||||
user.id = 'user-1';
|
||||
user.data = {
|
||||
name: 'Finance User',
|
||||
username: 'finance-user',
|
||||
email: 'finance@example.com',
|
||||
status: 'active',
|
||||
connectedOrgs: ['org-1'],
|
||||
};
|
||||
|
||||
const role = new Role();
|
||||
role.id = 'role-1';
|
||||
role.data = {
|
||||
userId: user.id,
|
||||
organizationId: 'org-1',
|
||||
roles: ['finance'],
|
||||
};
|
||||
|
||||
const app = new App();
|
||||
app.id = 'app-1';
|
||||
app.type = 'global';
|
||||
app.data = {
|
||||
name: 'Accounting',
|
||||
description: 'Accounting app',
|
||||
logoUrl: '',
|
||||
appUrl: 'https://accounting.example',
|
||||
category: 'finance',
|
||||
isActive: true,
|
||||
createdAt: Date.now(),
|
||||
createdByUserId: 'admin-1',
|
||||
oauthCredentials: {
|
||||
clientId: 'client-1',
|
||||
clientSecretHash: 'secret-hash',
|
||||
redirectUris: ['https://accounting.example/callback'],
|
||||
allowedScopes: ['openid', 'roles'],
|
||||
grantTypes: ['authorization_code'],
|
||||
},
|
||||
};
|
||||
|
||||
const connection = new AppConnection();
|
||||
connection.id = 'connection-1';
|
||||
connection.data = {
|
||||
organizationId: 'org-1',
|
||||
appId: app.id,
|
||||
appType: 'global',
|
||||
status: 'active',
|
||||
connectedAt: Date.now(),
|
||||
connectedByUserId: 'admin-1',
|
||||
grantedScopes: ['openid', 'roles'],
|
||||
roleMappings: [{
|
||||
orgRoleKey: 'finance',
|
||||
appRoles: ['accountant'],
|
||||
permissions: ['invoices:read'],
|
||||
scopes: ['billing'],
|
||||
}],
|
||||
};
|
||||
|
||||
const oidcManager = createTestOidcManager({
|
||||
userManager: {
|
||||
CUser: {
|
||||
getInstance: async () => user,
|
||||
},
|
||||
},
|
||||
roleManager: {
|
||||
getAllRolesForUser: async () => [role],
|
||||
},
|
||||
appManager: {
|
||||
CApp: {
|
||||
getInstances: async () => [app],
|
||||
},
|
||||
},
|
||||
appConnectionManager: {
|
||||
CAppConnection: {
|
||||
getInstances: async () => [connection],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const claims = await (oidcManager as any).getUserClaims(user.id, ['roles'], 'client-1');
|
||||
|
||||
expect(claims.app_roles).toEqual(['accountant']);
|
||||
expect(claims.app_permissions).toEqual(['invoices:read']);
|
||||
expect(claims.app_scopes).toEqual(['billing']);
|
||||
|
||||
await oidcManager.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { AppConnection } from '../ts/reception/classes.appconnection.js';
|
||||
import { BillingPlan } from '../ts/reception/classes.billingplan.js';
|
||||
import { Organization } from '../ts/reception/classes.organization.js';
|
||||
import { OrganizationManager } from '../ts/reception/classes.organizationmanager.js';
|
||||
import { Role } from '../ts/reception/classes.role.js';
|
||||
import { User } from '../ts/reception/classes.user.js';
|
||||
import { UserInvitation } from '../ts/reception/classes.userinvitation.js';
|
||||
|
||||
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||
};
|
||||
|
||||
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||
return Object.entries(queryArg).every(([keyArg, valueArg]) => {
|
||||
const currentValue = getNestedValue(targetArg, keyArg);
|
||||
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
|
||||
return Object.entries(valueArg).every(([nestedKeyArg, nestedValueArg]) => currentValue?.[nestedKeyArg] === nestedValueArg);
|
||||
}
|
||||
return currentValue === valueArg;
|
||||
});
|
||||
};
|
||||
|
||||
const attachPersistence = <TDoc extends { id: string; save?: () => Promise<void>; delete?: () => Promise<void> }>(
|
||||
docArg: TDoc,
|
||||
mapArg: Map<string, TDoc>
|
||||
) => {
|
||||
docArg.save = async () => {
|
||||
mapArg.set(docArg.id, docArg);
|
||||
};
|
||||
docArg.delete = async () => {
|
||||
mapArg.delete(docArg.id);
|
||||
};
|
||||
mapArg.set(docArg.id, docArg);
|
||||
return docArg;
|
||||
};
|
||||
|
||||
const createTestOrganizationManager = () => {
|
||||
const organizations = new Map<string, Organization>();
|
||||
const roles = new Map<string, Role>();
|
||||
const users = new Map<string, User>();
|
||||
const appConnections = new Map<string, AppConnection>();
|
||||
const invitations = new Map<string, UserInvitation>();
|
||||
const billingPlans = new Map<string, BillingPlan>();
|
||||
const activities: Array<{ userId: string; action: string; description: string }> = [];
|
||||
const alerts: Array<{ eventType: string; organizationId?: string }> = [];
|
||||
|
||||
const getInstancesFromMap = async <TDoc>(mapArg: Map<string, TDoc>, queryArg: Record<string, any> = {}) => {
|
||||
return Array.from(mapArg.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
};
|
||||
|
||||
const reception = {
|
||||
db: { smartdataDb: {} },
|
||||
typedrouter: { addTypedRouter: () => undefined },
|
||||
roleManager: {
|
||||
getRoleForUserAndOrg: async (userArg: User, organizationArg: Organization) => {
|
||||
return Array.from(roles.values()).find((roleArg) => roleArg.data.userId === userArg.id && roleArg.data.organizationId === organizationArg.id) || null;
|
||||
},
|
||||
getAllRolesForOrg: async (organizationIdArg: string) => {
|
||||
return Array.from(roles.values()).filter((roleArg) => roleArg.data.organizationId === organizationIdArg);
|
||||
},
|
||||
},
|
||||
userManager: {
|
||||
CUser: {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(users.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
},
|
||||
},
|
||||
activityLogManager: {
|
||||
logActivity: async (userId: string, action: string, description: string) => {
|
||||
activities.push({ userId, action, description });
|
||||
},
|
||||
},
|
||||
alertManager: {
|
||||
createAlertsForEvent: async (optionsArg: { eventType: string; organizationId?: string }) => {
|
||||
alerts.push(optionsArg);
|
||||
return [];
|
||||
},
|
||||
},
|
||||
appConnectionManager: {
|
||||
CAppConnection: {
|
||||
getInstances: async (queryArg: Record<string, any>) => getInstancesFromMap(appConnections, queryArg),
|
||||
},
|
||||
},
|
||||
userInvitationManager: {
|
||||
CUserInvitation: {
|
||||
getInstances: async (queryArg: Record<string, any>) => getInstancesFromMap(invitations, queryArg),
|
||||
},
|
||||
},
|
||||
billingPlanManager: {
|
||||
CBillingPlan: {
|
||||
getInstances: async (queryArg: Record<string, any>) => getInstancesFromMap(billingPlans, queryArg),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const manager = new OrganizationManager(reception);
|
||||
(manager as any).COrganization = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(organizations.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => getInstancesFromMap(organizations, queryArg),
|
||||
};
|
||||
|
||||
return {
|
||||
manager,
|
||||
organizations,
|
||||
roles,
|
||||
users,
|
||||
appConnections,
|
||||
invitations,
|
||||
billingPlans,
|
||||
activities,
|
||||
alerts,
|
||||
};
|
||||
};
|
||||
|
||||
const addUser = (usersArg: Map<string, User>, idArg: string, emailArg: string, connectedOrgsArg: string[] = []) => {
|
||||
const user = new User();
|
||||
user.id = idArg;
|
||||
user.data = {
|
||||
name: emailArg,
|
||||
username: emailArg,
|
||||
email: emailArg,
|
||||
status: 'active',
|
||||
connectedOrgs: connectedOrgsArg,
|
||||
};
|
||||
return attachPersistence(user, usersArg);
|
||||
};
|
||||
|
||||
const addOrganization = (organizationsArg: Map<string, Organization>) => {
|
||||
const organization = new Organization();
|
||||
organization.id = 'org-1';
|
||||
organization.data = {
|
||||
name: 'Lossless GmbH',
|
||||
slug: 'lossless',
|
||||
billingPlanId: 'billing-1',
|
||||
roleIds: ['role-owner', 'role-member'],
|
||||
};
|
||||
return attachPersistence(organization, organizationsArg);
|
||||
};
|
||||
|
||||
const addRole = (rolesArg: Map<string, Role>, idArg: string, userIdArg: string, rolesValueArg: string[]) => {
|
||||
const role = new Role();
|
||||
role.id = idArg;
|
||||
role.data = {
|
||||
userId: userIdArg,
|
||||
organizationId: 'org-1',
|
||||
roles: rolesValueArg,
|
||||
};
|
||||
return attachPersistence(role, rolesArg);
|
||||
};
|
||||
|
||||
tap.test('updates organization settings only with audited confirmation', async () => {
|
||||
const { manager, organizations, roles, users, activities, alerts } = createTestOrganizationManager();
|
||||
const owner = addUser(users, 'owner-1', 'owner@example.com', ['org-1']);
|
||||
addOrganization(organizations);
|
||||
addRole(roles, 'role-owner', owner.id, ['owner']);
|
||||
|
||||
await expect(manager.updateOrganizationWithAudit({
|
||||
user: owner,
|
||||
organizationId: 'org-1',
|
||||
name: 'Lossless Updated',
|
||||
slug: 'lossless-updated',
|
||||
confirmationText: 'wrong',
|
||||
})).rejects.toThrow();
|
||||
|
||||
const updatedOrganization = await manager.updateOrganizationWithAudit({
|
||||
user: owner,
|
||||
organizationId: 'org-1',
|
||||
name: 'Lossless Updated',
|
||||
slug: 'lossless-updated',
|
||||
confirmationText: 'lossless',
|
||||
});
|
||||
|
||||
expect(updatedOrganization.data.name).toEqual('Lossless Updated');
|
||||
expect(updatedOrganization.data.slug).toEqual('lossless-updated');
|
||||
expect(activities[0].action).toEqual('org_updated');
|
||||
expect(alerts[0].eventType).toEqual('org_updated');
|
||||
});
|
||||
|
||||
tap.test('deletes organization dependencies only with audited owner confirmation', async () => {
|
||||
const { manager, organizations, roles, users, appConnections, invitations, billingPlans, activities, alerts } = createTestOrganizationManager();
|
||||
const owner = addUser(users, 'owner-1', 'owner@example.com', ['org-1']);
|
||||
const member = addUser(users, 'member-1', 'member@example.com', ['org-1']);
|
||||
addOrganization(organizations);
|
||||
addRole(roles, 'role-owner', owner.id, ['owner']);
|
||||
addRole(roles, 'role-member', member.id, ['viewer']);
|
||||
|
||||
const appConnection = new AppConnection();
|
||||
appConnection.id = 'connection-1';
|
||||
appConnection.data = {
|
||||
organizationId: 'org-1',
|
||||
appId: 'app-1',
|
||||
appType: 'global',
|
||||
status: 'active',
|
||||
connectedAt: Date.now(),
|
||||
connectedByUserId: owner.id,
|
||||
grantedScopes: ['openid'],
|
||||
};
|
||||
attachPersistence(appConnection, appConnections);
|
||||
|
||||
const invitation = new UserInvitation();
|
||||
invitation.id = 'invitation-1';
|
||||
invitation.data = {
|
||||
email: 'invite@example.com',
|
||||
token: 'token',
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 1000,
|
||||
organizationRefs: [{
|
||||
organizationId: 'org-1',
|
||||
invitedByUserId: owner.id,
|
||||
invitedAt: Date.now(),
|
||||
roles: ['viewer'],
|
||||
}],
|
||||
};
|
||||
attachPersistence(invitation, invitations);
|
||||
|
||||
const billingPlan = new BillingPlan();
|
||||
billingPlan.id = 'billing-1';
|
||||
billingPlan.data.organizationId = 'org-1';
|
||||
attachPersistence(billingPlan, billingPlans);
|
||||
|
||||
await expect(manager.deleteOrganizationWithAudit({
|
||||
user: owner,
|
||||
organizationId: 'org-1',
|
||||
confirmationText: 'delete wrong',
|
||||
})).rejects.toThrow();
|
||||
|
||||
await manager.deleteOrganizationWithAudit({
|
||||
user: owner,
|
||||
organizationId: 'org-1',
|
||||
confirmationText: 'delete lossless',
|
||||
});
|
||||
|
||||
expect(organizations.size).toEqual(0);
|
||||
expect(roles.size).toEqual(0);
|
||||
expect(appConnections.size).toEqual(0);
|
||||
expect(billingPlans.size).toEqual(0);
|
||||
expect(invitation.data.status).toEqual('cancelled');
|
||||
expect(owner.data.connectedOrgs).toEqual([]);
|
||||
expect(member.data.connectedOrgs).toEqual([]);
|
||||
expect(activities[0].action).toEqual('org_deleted');
|
||||
expect(alerts[0].eventType).toEqual('org_deleted');
|
||||
});
|
||||
|
||||
tap.test('manages custom role definitions and cleans assignments and mappings on delete', async () => {
|
||||
const { manager, organizations, roles, users, appConnections } = createTestOrganizationManager();
|
||||
const owner = addUser(users, 'owner-1', 'owner@example.com', ['org-1']);
|
||||
const member = addUser(users, 'member-1', 'member@example.com', ['org-1']);
|
||||
const organization = addOrganization(organizations);
|
||||
addRole(roles, 'role-owner', owner.id, ['owner']);
|
||||
const memberRole = addRole(roles, 'role-member', member.id, ['viewer', 'finance']);
|
||||
|
||||
const roleDefinitions = await manager.upsertOrgRoleDefinition({
|
||||
user: owner,
|
||||
organizationId: organization.id,
|
||||
roleDefinition: {
|
||||
key: 'finance',
|
||||
name: 'Finance',
|
||||
description: 'Finance team access',
|
||||
},
|
||||
});
|
||||
expect(roleDefinitions).toHaveLength(1);
|
||||
expect(roleDefinitions[0].key).toEqual('finance');
|
||||
expect(await manager.assertRoleKeysAreValid(organization.id, ['finance'])).toEqual(['finance']);
|
||||
|
||||
const appConnection = new AppConnection();
|
||||
appConnection.id = 'connection-1';
|
||||
appConnection.data = {
|
||||
organizationId: organization.id,
|
||||
appId: 'app-1',
|
||||
appType: 'global',
|
||||
status: 'active',
|
||||
connectedAt: Date.now(),
|
||||
connectedByUserId: owner.id,
|
||||
grantedScopes: ['openid'],
|
||||
roleMappings: [{
|
||||
orgRoleKey: 'finance',
|
||||
appRoles: ['accountant'],
|
||||
permissions: ['invoices:read'],
|
||||
scopes: ['billing'],
|
||||
}],
|
||||
};
|
||||
attachPersistence(appConnection, appConnections);
|
||||
|
||||
await manager.deleteOrgRoleDefinition({
|
||||
user: owner,
|
||||
organizationId: organization.id,
|
||||
roleKey: 'finance',
|
||||
confirmationText: 'delete role finance',
|
||||
});
|
||||
|
||||
expect(organization.data.roleDefinitions).toEqual([]);
|
||||
expect(memberRole.data.roles).toEqual(['viewer']);
|
||||
expect(appConnection.data.roleMappings).toEqual([]);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,452 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { PassportChallenge } from '../ts/reception/classes.passportchallenge.js';
|
||||
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||||
import { PassportManager } from '../ts/reception/classes.passportmanager.js';
|
||||
import { PassportNonce } from '../ts/reception/classes.passportnonce.js';
|
||||
|
||||
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||
};
|
||||
|
||||
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||
return Object.entries(queryArg).every(([keyArg, valueArg]) => {
|
||||
return getNestedValue(targetArg, keyArg) === valueArg;
|
||||
});
|
||||
};
|
||||
|
||||
const createTestPassportManager = () => {
|
||||
const passportDevices = new Map<string, PassportDevice>();
|
||||
const passportChallenges = new Map<string, PassportChallenge>();
|
||||
const passportNonces = new Map<string, PassportNonce>();
|
||||
const activityLogCalls: Array<{
|
||||
userId: string;
|
||||
action: string;
|
||||
description: string;
|
||||
}> = [];
|
||||
const deliveredHintIds: string[] = [];
|
||||
|
||||
const manager = new PassportManager({
|
||||
db: { smartdataDb: {} },
|
||||
typedrouter: { addTypedRouter: () => undefined },
|
||||
options: { baseUrl: 'https://idp.global' },
|
||||
jwtManager: { verifyJWTAndGetData: async () => null },
|
||||
activityLogManager: {
|
||||
logActivity: async (userIdArg: string, actionArg: string, descriptionArg: string) => {
|
||||
activityLogCalls.push({
|
||||
userId: userIdArg,
|
||||
action: actionArg,
|
||||
description: descriptionArg,
|
||||
});
|
||||
},
|
||||
},
|
||||
passportPushManager: {
|
||||
deliverChallengeHint: async (_passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) => {
|
||||
deliveredHintIds.push(passportChallengeArg.data.notification!.hintId);
|
||||
passportChallengeArg.data.notification = {
|
||||
...passportChallengeArg.data.notification!,
|
||||
status: 'sent',
|
||||
attemptCount: passportChallengeArg.data.notification!.attemptCount + 1,
|
||||
deliveredAt: Date.now(),
|
||||
lastError: null,
|
||||
};
|
||||
await passportChallengeArg.save();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const originalPassportDeviceSave = PassportDevice.prototype.save;
|
||||
const originalPassportDeviceDelete = PassportDevice.prototype.delete;
|
||||
const originalPassportChallengeSave = PassportChallenge.prototype.save;
|
||||
const originalPassportChallengeDelete = PassportChallenge.prototype.delete;
|
||||
const originalPassportNonceSave = PassportNonce.prototype.save;
|
||||
const originalPassportNonceDelete = PassportNonce.prototype.delete;
|
||||
|
||||
(PassportDevice.prototype as PassportDevice & { save: () => Promise<void> }).save = async function () {
|
||||
passportDevices.set(this.id, this);
|
||||
};
|
||||
(PassportDevice.prototype as PassportDevice & { delete: () => Promise<void> }).delete = async function () {
|
||||
passportDevices.delete(this.id);
|
||||
};
|
||||
(PassportChallenge.prototype as PassportChallenge & { save: () => Promise<void> }).save = async function () {
|
||||
passportChallenges.set(this.id, this);
|
||||
};
|
||||
(PassportChallenge.prototype as PassportChallenge & { delete: () => Promise<void> }).delete = async function () {
|
||||
passportChallenges.delete(this.id);
|
||||
};
|
||||
(PassportNonce.prototype as PassportNonce & { save: () => Promise<void> }).save = async function () {
|
||||
passportNonces.set(this.id, this);
|
||||
};
|
||||
(PassportNonce.prototype as PassportNonce & { delete: () => Promise<void> }).delete = async function () {
|
||||
passportNonces.delete(this.id);
|
||||
};
|
||||
|
||||
(manager as any).CPassportDevice = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportDevices.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportDevices.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
},
|
||||
};
|
||||
|
||||
(manager as any).CPassportChallenge = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return (
|
||||
Array.from(passportChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null
|
||||
);
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
},
|
||||
};
|
||||
|
||||
(manager as any).CPassportNonce = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportNonces.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportNonces.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
manager,
|
||||
passportDevices,
|
||||
passportChallenges,
|
||||
passportNonces,
|
||||
activityLogCalls,
|
||||
deliveredHintIds,
|
||||
restore: () => {
|
||||
PassportDevice.prototype.save = originalPassportDeviceSave;
|
||||
PassportDevice.prototype.delete = originalPassportDeviceDelete;
|
||||
PassportChallenge.prototype.save = originalPassportChallengeSave;
|
||||
PassportChallenge.prototype.delete = originalPassportChallengeDelete;
|
||||
PassportNonce.prototype.save = originalPassportNonceSave;
|
||||
PassportNonce.prototype.delete = originalPassportNonceDelete;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRawPassportSigner = async () => {
|
||||
const subtle = plugins.crypto.webcrypto.subtle;
|
||||
const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
const publicKeyRaw = Buffer.from(await subtle.exportKey('raw', keyPair.publicKey)).toString('base64');
|
||||
|
||||
return {
|
||||
publicKeyX963Base64: publicKeyRaw,
|
||||
sign: async (payloadArg: string) => {
|
||||
const signature = await subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
keyPair.privateKey,
|
||||
Buffer.from(payloadArg, 'utf8')
|
||||
);
|
||||
return Buffer.from(signature).toString('base64');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createDerPassportSigner = () => {
|
||||
const keyPair = plugins.crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicJwk = keyPair.publicKey.export({ format: 'jwk' }) as JsonWebKey;
|
||||
const publicKeyX963Base64 = Buffer.concat([
|
||||
Buffer.from([4]),
|
||||
Buffer.from(publicJwk.x!, 'base64url'),
|
||||
Buffer.from(publicJwk.y!, 'base64url'),
|
||||
]).toString('base64');
|
||||
|
||||
return {
|
||||
publicKeyX963Base64,
|
||||
sign: (payloadArg: string) => {
|
||||
return plugins.crypto.sign('sha256', Buffer.from(payloadArg, 'utf8'), keyPair.privateKey).toString('base64');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createSignedDeviceRequest = async (
|
||||
managerArg: PassportManager,
|
||||
signerArg: { sign: (payloadArg: string) => Promise<string> | string },
|
||||
requestArg: {
|
||||
deviceId: string;
|
||||
action: string;
|
||||
signedFields?: string[];
|
||||
}
|
||||
) => {
|
||||
const baseRequest = {
|
||||
deviceId: requestArg.deviceId,
|
||||
timestamp: Date.now(),
|
||||
nonce: plugins.crypto.randomUUID(),
|
||||
};
|
||||
const payload = (managerArg as any).buildDeviceRequestSigningPayload(
|
||||
baseRequest,
|
||||
requestArg.action,
|
||||
requestArg.signedFields || []
|
||||
);
|
||||
|
||||
return {
|
||||
...baseRequest,
|
||||
signatureBase64: await signerArg.sign(payload),
|
||||
signatureFormat: 'raw' as const,
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('enrolls a passport device from a pairing challenge', async () => {
|
||||
const { manager, passportDevices, passportChallenges, activityLogCalls, restore } =
|
||||
createTestPassportManager();
|
||||
|
||||
try {
|
||||
const enrollment = await manager.createEnrollmentChallengeForUser('user-1', {
|
||||
deviceLabel: 'Phil iPhone',
|
||||
platform: 'ios',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const signer = await createRawPassportSigner();
|
||||
const signatureBase64 = await signer.sign(enrollment.signingPayload);
|
||||
|
||||
const passportDevice = await manager.completeEnrollment({
|
||||
pairingToken: enrollment.pairingToken,
|
||||
deviceLabel: 'Phil iPhone',
|
||||
platform: 'ios',
|
||||
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||
signatureBase64,
|
||||
signatureFormat: 'raw',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
appVersion: '1.0.0',
|
||||
});
|
||||
|
||||
expect(passportDevice.data.userId).toEqual('user-1');
|
||||
expect(passportDevice.data.label).toEqual('Phil iPhone');
|
||||
expect(passportDevices.size).toEqual(1);
|
||||
expect(passportChallenges.size).toEqual(1);
|
||||
expect(Array.from(passportChallenges.values())[0].data.status).toEqual('approved');
|
||||
expect(activityLogCalls[0].action).toEqual('passport_device_enrolled');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('creates and approves a passport challenge with DER signatures and evidence', async () => {
|
||||
const { manager, activityLogCalls, deliveredHintIds, restore } = createTestPassportManager();
|
||||
|
||||
try {
|
||||
const enrollment = await manager.createEnrollmentChallengeForUser('user-2', {
|
||||
deviceLabel: 'Office iPhone',
|
||||
platform: 'ios',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const signer = createDerPassportSigner();
|
||||
const passportDevice = await manager.completeEnrollment({
|
||||
pairingToken: enrollment.pairingToken,
|
||||
deviceLabel: 'Office iPhone',
|
||||
platform: 'ios',
|
||||
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||
signatureBase64: signer.sign(enrollment.signingPayload),
|
||||
signatureFormat: 'der',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const challengeResult = await manager.createPassportChallengeForUser('user-2', {
|
||||
type: 'physical_access',
|
||||
preferredDeviceId: passportDevice.id,
|
||||
audience: 'hq-door-a',
|
||||
notificationTitle: 'Office entry request',
|
||||
requireLocation: true,
|
||||
requireNfc: true,
|
||||
locationPolicy: {
|
||||
mode: 'geofence',
|
||||
label: 'HQ Berlin',
|
||||
latitude: 53.0793,
|
||||
longitude: 8.8017,
|
||||
radiusMeters: 80,
|
||||
maxAccuracyMeters: 25,
|
||||
},
|
||||
});
|
||||
|
||||
expect(deliveredHintIds).toHaveLength(1);
|
||||
expect(challengeResult.challenge.data.notification?.status).toEqual('sent');
|
||||
|
||||
await expect(
|
||||
manager.approvePassportChallenge({
|
||||
challengeId: challengeResult.challenge.id,
|
||||
deviceId: passportDevice.id,
|
||||
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||||
signatureFormat: 'der',
|
||||
location: {
|
||||
latitude: 53.5,
|
||||
longitude: 8.1,
|
||||
accuracyMeters: 12,
|
||||
capturedAt: Date.now(),
|
||||
},
|
||||
nfc: {
|
||||
readerId: 'door-reader-a',
|
||||
},
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
const approvedChallenge = await manager.approvePassportChallenge({
|
||||
challengeId: challengeResult.challenge.id,
|
||||
deviceId: passportDevice.id,
|
||||
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||||
signatureFormat: 'der',
|
||||
location: {
|
||||
latitude: 53.0793,
|
||||
longitude: 8.8017,
|
||||
accuracyMeters: 12,
|
||||
capturedAt: Date.now(),
|
||||
},
|
||||
nfc: {
|
||||
readerId: 'door-reader-a',
|
||||
},
|
||||
});
|
||||
|
||||
expect(approvedChallenge.data.status).toEqual('approved');
|
||||
expect(approvedChallenge.data.evidence?.signatureFormat).toEqual('der');
|
||||
expect(approvedChallenge.data.evidence?.location?.accuracyMeters).toEqual(12);
|
||||
expect(approvedChallenge.data.evidence?.locationEvaluation?.matched).toBeTrue();
|
||||
expect(approvedChallenge.data.evidence?.nfc?.readerId).toEqual('door-reader-a');
|
||||
expect(activityLogCalls.at(-1)?.action).toEqual('passport_challenge_approved');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('registers push tokens and loads pending challenges through signed device requests', async () => {
|
||||
const { manager, passportNonces, restore } = createTestPassportManager();
|
||||
|
||||
try {
|
||||
const enrollment = await manager.createEnrollmentChallengeForUser('user-3', {
|
||||
deviceLabel: 'Work iPhone',
|
||||
platform: 'ios',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: false,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const signer = await createRawPassportSigner();
|
||||
const passportDevice = await manager.completeEnrollment({
|
||||
pairingToken: enrollment.pairingToken,
|
||||
deviceLabel: 'Work iPhone',
|
||||
platform: 'ios',
|
||||
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||
signatureBase64: await signer.sign(enrollment.signingPayload),
|
||||
signatureFormat: 'raw',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: false,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pushRequest = await createSignedDeviceRequest(manager, signer, {
|
||||
deviceId: passportDevice.id,
|
||||
action: 'registerPassportPushToken',
|
||||
signedFields: [
|
||||
'provider=apns',
|
||||
'token=device-token-1',
|
||||
'topic=global.idp.swiftapp',
|
||||
'environment=development',
|
||||
],
|
||||
});
|
||||
|
||||
const registeredPassportDevice = await (manager as any).authenticatePassportDeviceRequest(
|
||||
{
|
||||
...pushRequest,
|
||||
},
|
||||
{
|
||||
action: 'registerPassportPushToken',
|
||||
signedFields: [
|
||||
'provider=apns',
|
||||
'token=device-token-1',
|
||||
'topic=global.idp.swiftapp',
|
||||
'environment=development',
|
||||
],
|
||||
}
|
||||
);
|
||||
registeredPassportDevice.data.pushRegistration = {
|
||||
provider: 'apns',
|
||||
token: 'device-token-1',
|
||||
topic: 'global.idp.swiftapp',
|
||||
environment: 'development',
|
||||
registeredAt: Date.now(),
|
||||
};
|
||||
await registeredPassportDevice.save();
|
||||
|
||||
const challengeResult = await manager.createPassportChallengeForUser('user-3', {
|
||||
type: 'authentication',
|
||||
preferredDeviceId: passportDevice.id,
|
||||
audience: 'office-saas',
|
||||
notificationTitle: 'Office sign-in verification',
|
||||
});
|
||||
|
||||
const listRequest = await createSignedDeviceRequest(manager, signer, {
|
||||
deviceId: passportDevice.id,
|
||||
action: 'listPendingPassportChallenges',
|
||||
});
|
||||
|
||||
const authenticatedDevice = await (manager as any).authenticatePassportDeviceRequest(listRequest, {
|
||||
action: 'listPendingPassportChallenges',
|
||||
});
|
||||
const pendingChallenges = await manager.listPendingChallengesForDevice(authenticatedDevice.id);
|
||||
expect(pendingChallenges).toHaveLength(1);
|
||||
expect(pendingChallenges[0].id).toEqual(challengeResult.challenge.id);
|
||||
|
||||
const hintId = challengeResult.challenge.data.notification!.hintId;
|
||||
const getRequest = await createSignedDeviceRequest(manager, signer, {
|
||||
deviceId: passportDevice.id,
|
||||
action: 'getPassportChallengeByHint',
|
||||
signedFields: [`hint_id=${hintId}`],
|
||||
});
|
||||
const hintChallenge = await manager.getPassportChallengeByHint(
|
||||
(
|
||||
await (manager as any).authenticatePassportDeviceRequest(getRequest, {
|
||||
action: 'getPassportChallengeByHint',
|
||||
signedFields: [`hint_id=${hintId}`],
|
||||
})
|
||||
).id,
|
||||
hintId
|
||||
);
|
||||
expect(hintChallenge?.id).toEqual(challengeResult.challenge.id);
|
||||
|
||||
const seenRequest = await createSignedDeviceRequest(manager, signer, {
|
||||
deviceId: passportDevice.id,
|
||||
action: 'markPassportChallengeSeen',
|
||||
signedFields: [`hint_id=${hintId}`],
|
||||
});
|
||||
await (manager as any).authenticatePassportDeviceRequest(seenRequest, {
|
||||
action: 'markPassportChallengeSeen',
|
||||
signedFields: [`hint_id=${hintId}`],
|
||||
});
|
||||
const seenChallenge = await manager.markPassportChallengeSeen(passportDevice.id, hintId);
|
||||
expect(seenChallenge.data.notification?.status).toEqual('seen');
|
||||
expect(passportNonces.size).toEqual(4);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.19.1',
|
||||
version: '1.21.1',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
+46
-1
@@ -2,6 +2,38 @@ import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { Reception } from './reception/classes.reception.js';
|
||||
|
||||
const manifestIconPng = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAABzklEQVR4nO3OMQ0AMAzAsEIc3aHrOOyJIuXw75lzN/mGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB+KGB6L2AK5GkZ1Ln/HeAAAAAElFTkSuQmCC',
|
||||
'base64'
|
||||
));
|
||||
const createManifestResponse = () => new Response(JSON.stringify({
|
||||
name: 'idp.global',
|
||||
short_name: 'idp.global',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
background_color: '#000000',
|
||||
theme_color: '#000000',
|
||||
icons: [],
|
||||
related_applications: [],
|
||||
scope: '/',
|
||||
lang: 'en',
|
||||
display_override: ['window-controls-overlay'],
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
|
||||
const createManifestIconResponse = () => new Response(manifestIconPng.slice(), {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': String(manifestIconPng.byteLength),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
});
|
||||
|
||||
export const runCli = async () => {
|
||||
const serviceQenv = new plugins.qenv.Qenv('./', './.nogit', false);
|
||||
|
||||
@@ -18,7 +50,7 @@ export const runCli = async () => {
|
||||
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:"],
|
||||
fontSrc: ["'self'", "data:", "https://assetbroker.lossless.one"],
|
||||
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"],
|
||||
},
|
||||
@@ -41,6 +73,19 @@ export const runCli = async () => {
|
||||
});
|
||||
});
|
||||
|
||||
typedserver.addRoute('/manifest.json', 'GET', async () => createManifestResponse());
|
||||
typedserver.addRoute('/manifest.json', 'HEAD', async () => createManifestResponse());
|
||||
typedserver.addRoute('/idp-manifest.json', 'GET', async () => createManifestResponse());
|
||||
typedserver.addRoute('/idp-manifest.json', 'HEAD', async () => createManifestResponse());
|
||||
typedserver.addRoute('/assetbroker/manifest/favicon.png', 'GET', async () => createManifestIconResponse());
|
||||
typedserver.addRoute('/assetbroker/manifest/icon-144x144.png', 'GET', async () => createManifestIconResponse());
|
||||
typedserver.addRoute('/assetbroker/manifest/icon-512x512.png', 'GET', async () => createManifestIconResponse());
|
||||
typedserver.addRoute('/assetbroker/manifest/icon-large.png', 'GET', async () => createManifestIconResponse());
|
||||
typedserver.addRoute('/assetbroker/manifest/favicon.png', 'HEAD', async () => createManifestIconResponse());
|
||||
typedserver.addRoute('/assetbroker/manifest/icon-144x144.png', 'HEAD', async () => createManifestIconResponse());
|
||||
typedserver.addRoute('/assetbroker/manifest/icon-512x512.png', 'HEAD', async () => createManifestIconResponse());
|
||||
typedserver.addRoute('/assetbroker/manifest/icon-large.png', 'HEAD', async () => createManifestIconResponse());
|
||||
|
||||
// OAuth Authorization endpoint
|
||||
typedserver.addRoute('/oauth/authorize', 'GET', async (ctx) => {
|
||||
return reception.oidcManager.handleAuthorize(ctx);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
export { crypto, path };
|
||||
|
||||
// Project scope
|
||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
||||
import * as idpInterfaces from '@idp.global/interfaces';
|
||||
export { idpInterfaces };
|
||||
|
||||
// @api.global scope
|
||||
|
||||
+17
-3
@@ -1,6 +1,6 @@
|
||||
# `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.
|
||||
The `ts/` folder contains the server runtime for `idp.global`: startup, website server wiring, typed routes, OIDC endpoints, passport approval APIs, alerting, and the core `Reception` managers.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -10,7 +10,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
- `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.
|
||||
- `reception/` contains the domain logic for users, sessions, orgs, roles, invites, apps, billing, passport devices, alerts, abuse protection, and OIDC.
|
||||
- `plugins.ts` centralizes external imports used by the backend.
|
||||
|
||||
## Startup Behavior
|
||||
@@ -32,7 +32,7 @@ export INSTANCE_NAME=idp-dev
|
||||
|
||||
Optional:
|
||||
|
||||
- `SERVEZONE_PLATFROM_AUTHORIZATION`
|
||||
- `SERVEZONE_PLATFORM_AUTHORIZATION`
|
||||
- `PADDLE_TOKEN`
|
||||
- `PADDLE_PRICE_ID`
|
||||
|
||||
@@ -51,8 +51,22 @@ Optional:
|
||||
| `AppManager` | Global app administration |
|
||||
| `AppConnectionManager` | App connection tracking |
|
||||
| `ActivityLogManager` | User activity logging |
|
||||
| `AlertManager` | Passport alerts and alert rule management |
|
||||
| `AbuseProtectionManager` | Attempt windows and temporary blocks for sensitive flows |
|
||||
| `PassportManager` | Trusted device enrollment, approval challenges, dashboard data, and signed device requests |
|
||||
| `PassportPushManager` | Push notification delivery hooks for passport challenges and alerts |
|
||||
| `OidcManager` | OIDC discovery, auth code flow, token exchange, userinfo, revoke |
|
||||
|
||||
## Passport Request Surface
|
||||
|
||||
The backend exposes signed-device workflows over the same `typedrequest` router as the rest of the service:
|
||||
|
||||
- enrollment: `createPassportEnrollmentChallenge`, `completePassportEnrollment`
|
||||
- devices: `getPassportDevices`, `revokePassportDevice`, `registerPassportPushToken`
|
||||
- challenges: `createPassportChallenge`, `approvePassportChallenge`, `rejectPassportChallenge`, `listPendingPassportChallenges`
|
||||
- dashboard and hints: `getPassportDashboard`, `getPassportChallengeByHint`, `markPassportChallengeSeen`
|
||||
- alerts: `listPassportAlerts`, `getPassportAlertByHint`, `markPassportAlertSeen`, `dismissPassportAlert`
|
||||
|
||||
## Local Development
|
||||
|
||||
From the repository root:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import { Reception } from './classes.reception.js';
|
||||
import { AbuseWindow } from './classes.abusewindow.js';
|
||||
|
||||
export interface IAbuseProtectionConfig {
|
||||
maxAttempts: number;
|
||||
windowMillis: number;
|
||||
blockDurationMillis: number;
|
||||
}
|
||||
|
||||
export class AbuseProtectionManager {
|
||||
public receptionRef: Reception;
|
||||
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
public CAbuseWindow = plugins.smartdata.setDefaultManagerForDoc(this, AbuseWindow);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
}
|
||||
|
||||
private normalizeIdentifier(identifierArg: string) {
|
||||
return identifierArg.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private hashIdentifier(identifierArg: string) {
|
||||
return plugins.smarthash.sha256FromStringSync(this.normalizeIdentifier(identifierArg));
|
||||
}
|
||||
|
||||
private createWindowId(actionArg: string, identifierArg: string) {
|
||||
return plugins.smarthash.sha256FromStringSync(
|
||||
`${actionArg}:${this.hashIdentifier(identifierArg)}`
|
||||
);
|
||||
}
|
||||
|
||||
private async getWindow(actionArg: string, identifierArg: string) {
|
||||
return this.CAbuseWindow.getInstance({
|
||||
id: this.createWindowId(actionArg, identifierArg),
|
||||
});
|
||||
}
|
||||
|
||||
public async consumeAttempt(
|
||||
actionArg: string,
|
||||
identifierArg: string,
|
||||
configArg: IAbuseProtectionConfig,
|
||||
errorTextArg = 'Too many attempts. Please wait before trying again.'
|
||||
) {
|
||||
const now = Date.now();
|
||||
let abuseWindow = await this.getWindow(actionArg, identifierArg);
|
||||
|
||||
if (!abuseWindow) {
|
||||
abuseWindow = new AbuseWindow();
|
||||
abuseWindow.id = this.createWindowId(actionArg, identifierArg);
|
||||
abuseWindow.data.action = actionArg;
|
||||
abuseWindow.data.identifierHash = this.hashIdentifier(identifierArg);
|
||||
abuseWindow.data.createdAt = now;
|
||||
}
|
||||
|
||||
if (abuseWindow.isBlocked(now)) {
|
||||
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
|
||||
}
|
||||
|
||||
if (abuseWindow.data.blockedUntil && abuseWindow.data.blockedUntil <= now) {
|
||||
abuseWindow.data.attemptCount = 0;
|
||||
abuseWindow.data.windowStartedAt = now;
|
||||
abuseWindow.data.blockedUntil = 0;
|
||||
}
|
||||
|
||||
if (
|
||||
!abuseWindow.data.windowStartedAt ||
|
||||
abuseWindow.data.windowStartedAt + configArg.windowMillis <= now
|
||||
) {
|
||||
abuseWindow.data.attemptCount = 0;
|
||||
abuseWindow.data.windowStartedAt = now;
|
||||
}
|
||||
|
||||
abuseWindow.data.attemptCount += 1;
|
||||
abuseWindow.data.updatedAt = now;
|
||||
abuseWindow.data.validUntil = now + configArg.windowMillis;
|
||||
|
||||
if (abuseWindow.data.attemptCount > configArg.maxAttempts) {
|
||||
abuseWindow.data.blockedUntil = now + configArg.blockDurationMillis;
|
||||
abuseWindow.data.validUntil = abuseWindow.data.blockedUntil;
|
||||
await abuseWindow.save();
|
||||
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
|
||||
}
|
||||
|
||||
await abuseWindow.save();
|
||||
}
|
||||
|
||||
public async clearAttempts(actionArg: string, identifierArg: string) {
|
||||
const abuseWindow = await this.getWindow(actionArg, identifierArg);
|
||||
if (!abuseWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
await abuseWindow.delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import type { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class AbuseWindow extends plugins.smartdata.SmartDataDbDoc<
|
||||
AbuseWindow,
|
||||
plugins.idpInterfaces.data.IAbuseWindow,
|
||||
AbuseProtectionManager
|
||||
> {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IAbuseWindow['data'] = {
|
||||
action: '',
|
||||
identifierHash: '',
|
||||
attemptCount: 0,
|
||||
windowStartedAt: 0,
|
||||
blockedUntil: 0,
|
||||
validUntil: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
public isBlocked(nowArg = Date.now()) {
|
||||
return this.data.blockedUntil > nowArg;
|
||||
}
|
||||
|
||||
public isExpired(nowArg = Date.now()) {
|
||||
return this.data.validUntil < nowArg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import type { AlertManager } from './classes.alertmanager.js';
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class Alert extends plugins.smartdata.SmartDataDbDoc<
|
||||
Alert,
|
||||
plugins.idpInterfaces.data.IAlert,
|
||||
AlertManager
|
||||
> {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IAlert['data'] = {
|
||||
recipientUserId: '',
|
||||
organizationId: undefined,
|
||||
category: 'security',
|
||||
eventType: '',
|
||||
severity: 'medium',
|
||||
title: '',
|
||||
body: '',
|
||||
actorUserId: undefined,
|
||||
relatedEntityId: undefined,
|
||||
relatedEntityType: undefined,
|
||||
notification: {
|
||||
hintId: '',
|
||||
status: 'pending',
|
||||
attemptCount: 0,
|
||||
createdAt: 0,
|
||||
deliveredAt: null,
|
||||
seenAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
createdAt: 0,
|
||||
seenAt: null,
|
||||
dismissedAt: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import { Alert } from './classes.alert.js';
|
||||
import { AlertRule } from './classes.alertrule.js';
|
||||
import type { Reception } from './classes.reception.js';
|
||||
|
||||
const severityOrder: Record<plugins.idpInterfaces.data.TAlertSeverity, number> = {
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
critical: 4,
|
||||
};
|
||||
|
||||
export class AlertManager {
|
||||
public receptionRef: Reception;
|
||||
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
public CAlert = plugins.smartdata.setDefaultManagerForDoc(this, Alert);
|
||||
public CAlertRule = plugins.smartdata.setDefaultManagerForDoc(this, AlertRule);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPassportAlerts>(
|
||||
'listPassportAlerts',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||
requestArg,
|
||||
{
|
||||
action: 'listPassportAlerts',
|
||||
}
|
||||
);
|
||||
const alerts = await this.listAlertsForUser(
|
||||
passportDevice.data.userId,
|
||||
!!requestArg.includeDismissed
|
||||
);
|
||||
return {
|
||||
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportAlertByHint>(
|
||||
'getPassportAlertByHint',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||
requestArg,
|
||||
{
|
||||
action: 'getPassportAlertByHint',
|
||||
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||
}
|
||||
);
|
||||
const alert = await this.getAlertByHint(passportDevice.data.userId, requestArg.hintId);
|
||||
return {
|
||||
alert: alert ? { id: alert.id, data: alert.data } : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportAlertSeen>(
|
||||
'markPassportAlertSeen',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||
requestArg,
|
||||
{
|
||||
action: 'markPassportAlertSeen',
|
||||
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||
}
|
||||
);
|
||||
await this.markAlertSeen(passportDevice.data.userId, requestArg.hintId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DismissPassportAlert>(
|
||||
'dismissPassportAlert',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||
requestArg,
|
||||
{
|
||||
action: 'dismissPassportAlert',
|
||||
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||
}
|
||||
);
|
||||
await this.dismissAlert(passportDevice.data.userId, requestArg.hintId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpsertAlertRule>(
|
||||
'upsertAlertRule',
|
||||
async (requestArg) => {
|
||||
const actorUserId = await this.verifyAlertRuleAccess(
|
||||
requestArg.jwt,
|
||||
requestArg.scope,
|
||||
requestArg.organizationId
|
||||
);
|
||||
const rule = requestArg.ruleId
|
||||
? await this.CAlertRule.getInstance({ id: requestArg.ruleId })
|
||||
: new AlertRule();
|
||||
if (!rule) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
|
||||
}
|
||||
|
||||
rule.id = rule.id || plugins.smartunique.shortId();
|
||||
rule.data = {
|
||||
scope: requestArg.scope,
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: requestArg.eventType,
|
||||
minimumSeverity: requestArg.minimumSeverity,
|
||||
recipientMode: requestArg.recipientMode,
|
||||
recipientUserIds: requestArg.recipientUserIds || [],
|
||||
push: requestArg.push,
|
||||
enabled: requestArg.enabled,
|
||||
createdByUserId: rule.data?.createdByUserId || actorUserId,
|
||||
createdAt: rule.data?.createdAt || Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await rule.save();
|
||||
|
||||
return {
|
||||
rule: {
|
||||
id: rule.id,
|
||||
data: rule.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAlertRules>(
|
||||
'getAlertRules',
|
||||
async (requestArg) => {
|
||||
await this.verifyAlertRuleAccess(requestArg.jwt, requestArg.scope || 'global', requestArg.organizationId);
|
||||
const rules = await this.CAlertRule.getInstances({});
|
||||
return {
|
||||
rules: rules
|
||||
.filter((ruleArg) => {
|
||||
if (requestArg.scope && ruleArg.data.scope !== requestArg.scope) {
|
||||
return false;
|
||||
}
|
||||
if (requestArg.organizationId && ruleArg.data.organizationId !== requestArg.organizationId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((ruleArg) => ({ id: ruleArg.id, data: ruleArg.data })),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteAlertRule>(
|
||||
'deleteAlertRule',
|
||||
async (requestArg) => {
|
||||
const rule = await this.CAlertRule.getInstance({ id: requestArg.ruleId });
|
||||
if (!rule) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
|
||||
}
|
||||
await this.verifyAlertRuleAccess(requestArg.jwt, rule.data.scope, rule.data.organizationId);
|
||||
await rule.delete();
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async verifyAlertRuleAccess(
|
||||
jwtArg: string,
|
||||
scopeArg: plugins.idpInterfaces.data.TAlertRuleScope,
|
||||
organizationIdArg?: string
|
||||
) {
|
||||
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
|
||||
if (!jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||
}
|
||||
|
||||
if (scopeArg === 'global') {
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({ id: jwt.data.userId });
|
||||
if (!user?.data?.isGlobalAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Global admin privileges required');
|
||||
}
|
||||
return jwt.data.userId;
|
||||
}
|
||||
|
||||
if (!organizationIdArg) {
|
||||
throw new plugins.typedrequest.TypedResponseError('organizationId is required');
|
||||
}
|
||||
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: jwt.data.userId,
|
||||
organizationId: organizationIdArg,
|
||||
},
|
||||
});
|
||||
if (!role || !role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required');
|
||||
}
|
||||
return jwt.data.userId;
|
||||
}
|
||||
|
||||
private async resolveGlobalAdminRecipients() {
|
||||
const users = await this.receptionRef.userManager.CUser.getInstances({});
|
||||
return users.filter((userArg) => !!userArg.data.isGlobalAdmin);
|
||||
}
|
||||
|
||||
private async resolveOrganizationAdminRecipients(organizationIdArg: string) {
|
||||
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organizationIdArg);
|
||||
const adminUserIds = [...new Set(
|
||||
roles
|
||||
.filter((roleArg) => roleArg.data.roles.some((roleNameArg) => ['owner', 'admin'].includes(roleNameArg)))
|
||||
.map((roleArg) => roleArg.data.userId)
|
||||
)];
|
||||
const users = await Promise.all(
|
||||
adminUserIds.map((userIdArg) => this.receptionRef.userManager.CUser.getInstance({ id: userIdArg }))
|
||||
);
|
||||
return users.filter(Boolean);
|
||||
}
|
||||
|
||||
private async resolveRuleRecipients(ruleArg: AlertRule) {
|
||||
switch (ruleArg.data.recipientMode) {
|
||||
case 'global_admins':
|
||||
return this.resolveGlobalAdminRecipients();
|
||||
case 'org_admins':
|
||||
if (!ruleArg.data.organizationId) {
|
||||
return [];
|
||||
}
|
||||
return this.resolveOrganizationAdminRecipients(ruleArg.data.organizationId);
|
||||
case 'specific_users':
|
||||
if (!ruleArg.data.recipientUserIds?.length) {
|
||||
return [];
|
||||
}
|
||||
const users = await Promise.all(
|
||||
ruleArg.data.recipientUserIds.map((userIdArg) =>
|
||||
this.receptionRef.userManager.CUser.getInstance({ id: userIdArg })
|
||||
)
|
||||
);
|
||||
return users.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
private async getMatchingRules(optionsArg: {
|
||||
eventType: string;
|
||||
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||
organizationId?: string;
|
||||
}) {
|
||||
const rules = await this.CAlertRule.getInstances({});
|
||||
const matchingRules = rules.filter((ruleArg) => {
|
||||
if (!ruleArg.data.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (ruleArg.data.eventType !== optionsArg.eventType) {
|
||||
return false;
|
||||
}
|
||||
if (ruleArg.data.scope === 'organization' && ruleArg.data.organizationId !== optionsArg.organizationId) {
|
||||
return false;
|
||||
}
|
||||
return severityOrder[optionsArg.severity] >= severityOrder[ruleArg.data.minimumSeverity];
|
||||
});
|
||||
|
||||
if (matchingRules.length > 0) {
|
||||
return matchingRules;
|
||||
}
|
||||
|
||||
if (optionsArg.eventType === 'global_admin_access') {
|
||||
return [this.createBuiltInRule('builtin-global-admin-access', {
|
||||
scope: 'global',
|
||||
eventType: 'global_admin_access',
|
||||
minimumSeverity: 'high',
|
||||
recipientMode: 'global_admins',
|
||||
})];
|
||||
}
|
||||
|
||||
if (optionsArg.eventType === 'global_app_credentials_regenerated') {
|
||||
return [this.createBuiltInRule('builtin-global-app-credentials-regenerated', {
|
||||
scope: 'global',
|
||||
eventType: 'global_app_credentials_regenerated',
|
||||
minimumSeverity: 'critical',
|
||||
recipientMode: 'global_admins',
|
||||
})];
|
||||
}
|
||||
|
||||
if (optionsArg.organizationId) {
|
||||
const organizationFallbackMap: Record<
|
||||
string,
|
||||
{
|
||||
minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||
}
|
||||
> = {
|
||||
org_app_connected: { minimumSeverity: 'medium' },
|
||||
org_app_disconnected: { minimumSeverity: 'medium' },
|
||||
org_invitation_created: { minimumSeverity: 'low' },
|
||||
org_invitation_resent: { minimumSeverity: 'low' },
|
||||
org_updated: { minimumSeverity: 'high' },
|
||||
org_deleted: { minimumSeverity: 'critical' },
|
||||
org_role_definition_updated: { minimumSeverity: 'medium' },
|
||||
org_role_definition_deleted: { minimumSeverity: 'high' },
|
||||
org_app_role_mappings_updated: { minimumSeverity: 'medium' },
|
||||
org_member_removed: { minimumSeverity: 'high' },
|
||||
org_member_roles_updated: { minimumSeverity: 'high' },
|
||||
org_ownership_transferred: { minimumSeverity: 'critical' },
|
||||
};
|
||||
const fallbackConfig = organizationFallbackMap[optionsArg.eventType];
|
||||
if (fallbackConfig) {
|
||||
return [this.createBuiltInRule(`builtin-${optionsArg.eventType}`, {
|
||||
scope: 'organization',
|
||||
organizationId: optionsArg.organizationId,
|
||||
eventType: optionsArg.eventType,
|
||||
minimumSeverity: fallbackConfig.minimumSeverity,
|
||||
recipientMode: 'org_admins',
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private createBuiltInRule(
|
||||
ruleIdArg: string,
|
||||
optionsArg: {
|
||||
scope: plugins.idpInterfaces.data.TAlertRuleScope;
|
||||
organizationId?: string;
|
||||
eventType: string;
|
||||
minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||
recipientMode: plugins.idpInterfaces.data.TAlertRuleRecipientMode;
|
||||
}
|
||||
) {
|
||||
const fallbackRule = new AlertRule();
|
||||
fallbackRule.id = ruleIdArg;
|
||||
fallbackRule.data = {
|
||||
scope: optionsArg.scope,
|
||||
organizationId: optionsArg.organizationId,
|
||||
eventType: optionsArg.eventType,
|
||||
minimumSeverity: optionsArg.minimumSeverity,
|
||||
recipientMode: optionsArg.recipientMode,
|
||||
recipientUserIds: [],
|
||||
push: true,
|
||||
enabled: true,
|
||||
createdByUserId: 'system',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
return fallbackRule;
|
||||
}
|
||||
|
||||
public async createAlertsForEvent(optionsArg: {
|
||||
category: plugins.idpInterfaces.data.TAlertCategory;
|
||||
eventType: string;
|
||||
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
actorUserId?: string;
|
||||
organizationId?: string;
|
||||
relatedEntityId?: string;
|
||||
relatedEntityType?: string;
|
||||
}) {
|
||||
const matchingRules = await this.getMatchingRules(optionsArg);
|
||||
if (matchingRules.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const recipientIds = new Set<string>();
|
||||
for (const rule of matchingRules) {
|
||||
const recipients = await this.resolveRuleRecipients(rule);
|
||||
for (const recipient of recipients) {
|
||||
recipientIds.add(recipient.id);
|
||||
}
|
||||
}
|
||||
|
||||
const createdAlerts: Alert[] = [];
|
||||
for (const recipientUserId of recipientIds) {
|
||||
const alert = new Alert();
|
||||
alert.id = plugins.smartunique.shortId();
|
||||
alert.data = {
|
||||
recipientUserId,
|
||||
organizationId: optionsArg.organizationId,
|
||||
category: optionsArg.category,
|
||||
eventType: optionsArg.eventType,
|
||||
severity: optionsArg.severity,
|
||||
title: optionsArg.title,
|
||||
body: optionsArg.body,
|
||||
actorUserId: optionsArg.actorUserId,
|
||||
relatedEntityId: optionsArg.relatedEntityId,
|
||||
relatedEntityType: optionsArg.relatedEntityType,
|
||||
notification: {
|
||||
hintId: plugins.crypto.randomUUID(),
|
||||
status: 'pending',
|
||||
attemptCount: 0,
|
||||
createdAt: Date.now(),
|
||||
deliveredAt: null,
|
||||
seenAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
seenAt: null,
|
||||
dismissedAt: null,
|
||||
};
|
||||
await alert.save();
|
||||
createdAlerts.push(alert);
|
||||
|
||||
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(recipientUserId);
|
||||
let delivered = false;
|
||||
for (const device of devices) {
|
||||
const result = await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
|
||||
delivered = delivered || result;
|
||||
}
|
||||
if (!delivered && devices.length === 0) {
|
||||
alert.data.notification = {
|
||||
...alert.data.notification,
|
||||
status: 'failed',
|
||||
attemptCount: alert.data.notification.attemptCount + 1,
|
||||
lastError: 'Recipient has no active passport device',
|
||||
};
|
||||
await alert.save();
|
||||
}
|
||||
}
|
||||
|
||||
return createdAlerts;
|
||||
}
|
||||
|
||||
public async listAlertsForUser(userIdArg: string, includeDismissedArg = false) {
|
||||
const alerts = await this.CAlert.getInstances({
|
||||
'data.recipientUserId': userIdArg,
|
||||
});
|
||||
return alerts
|
||||
.filter((alertArg) => includeDismissedArg || !alertArg.data.dismissedAt)
|
||||
.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
|
||||
}
|
||||
|
||||
public async getAlertByHint(userIdArg: string, hintIdArg: string) {
|
||||
return this.CAlert.getInstance({
|
||||
'data.recipientUserId': userIdArg,
|
||||
'data.notification.hintId': hintIdArg,
|
||||
});
|
||||
}
|
||||
|
||||
public async markAlertSeen(userIdArg: string, hintIdArg: string) {
|
||||
const alert = await this.getAlertByHint(userIdArg, hintIdArg);
|
||||
if (!alert) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Alert not found');
|
||||
}
|
||||
|
||||
alert.data.seenAt = Date.now();
|
||||
alert.data.notification = {
|
||||
...alert.data.notification,
|
||||
status: 'seen',
|
||||
seenAt: Date.now(),
|
||||
};
|
||||
await alert.save();
|
||||
return alert;
|
||||
}
|
||||
|
||||
public async dismissAlert(userIdArg: string, hintIdArg: string) {
|
||||
const alert = await this.getAlertByHint(userIdArg, hintIdArg);
|
||||
if (!alert) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Alert not found');
|
||||
}
|
||||
|
||||
alert.data.dismissedAt = Date.now();
|
||||
if (!alert.data.seenAt) {
|
||||
alert.data.seenAt = Date.now();
|
||||
}
|
||||
alert.data.notification = {
|
||||
...alert.data.notification,
|
||||
status: 'seen',
|
||||
seenAt: alert.data.notification.seenAt || Date.now(),
|
||||
};
|
||||
await alert.save();
|
||||
return alert;
|
||||
}
|
||||
|
||||
public async reDeliverPendingAlerts() {
|
||||
const alerts = await this.CAlert.getInstances({});
|
||||
for (const alert of alerts) {
|
||||
if (alert.data.notification.status === 'sent' || alert.data.notification.status === 'seen') {
|
||||
continue;
|
||||
}
|
||||
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(
|
||||
alert.data.recipientUserId
|
||||
);
|
||||
for (const device of devices) {
|
||||
await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import type { AlertManager } from './classes.alertmanager.js';
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class AlertRule extends plugins.smartdata.SmartDataDbDoc<
|
||||
AlertRule,
|
||||
plugins.idpInterfaces.data.IAlertRule,
|
||||
AlertManager
|
||||
> {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IAlertRule['data'] = {
|
||||
scope: 'global',
|
||||
organizationId: undefined,
|
||||
eventType: '',
|
||||
minimumSeverity: 'medium',
|
||||
recipientMode: 'global_admins',
|
||||
recipientUserIds: [],
|
||||
push: true,
|
||||
enabled: true,
|
||||
createdByUserId: '',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { Reception } from './classes.reception.js';
|
||||
import { AppConnection } from './classes.appconnection.js';
|
||||
import type { User } from './classes.user.js';
|
||||
|
||||
export class AppConnectionManager {
|
||||
public receptionRef: Reception;
|
||||
@@ -11,6 +12,29 @@ export class AppConnectionManager {
|
||||
|
||||
public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection);
|
||||
|
||||
private async emitOrganizationAlert(optionsArg: {
|
||||
organizationId: string;
|
||||
eventType: string;
|
||||
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
actorUserId: string;
|
||||
relatedEntityId?: string;
|
||||
relatedEntityType?: string;
|
||||
}) {
|
||||
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||
category: 'admin',
|
||||
organizationId: optionsArg.organizationId,
|
||||
eventType: optionsArg.eventType,
|
||||
severity: optionsArg.severity,
|
||||
title: optionsArg.title,
|
||||
body: optionsArg.body,
|
||||
actorUserId: optionsArg.actorUserId,
|
||||
relatedEntityId: optionsArg.relatedEntityId,
|
||||
relatedEntityType: optionsArg.relatedEntityType,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
@@ -127,10 +151,22 @@ export class AppConnectionManager {
|
||||
connectedAt: Date.now(),
|
||||
connectedByUserId: user.id,
|
||||
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
|
||||
roleMappings: [],
|
||||
};
|
||||
await connection.save();
|
||||
}
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: 'org_app_connected',
|
||||
severity: 'medium',
|
||||
title: 'Organization app connected',
|
||||
body: `${user.data.email} connected ${app.data.name} to this organization.`,
|
||||
actorUserId: user.id,
|
||||
relatedEntityId: app.id,
|
||||
relatedEntityType: 'global-app',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
connection: await connection.createSavableObject(),
|
||||
@@ -145,6 +181,17 @@ export class AppConnectionManager {
|
||||
|
||||
await connection.disconnect();
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: 'org_app_disconnected',
|
||||
severity: 'medium',
|
||||
title: 'Organization app disconnected',
|
||||
body: `${user.data.email} disconnected ${app.data.name} from this organization.`,
|
||||
actorUserId: user.id,
|
||||
relatedEntityId: app.id,
|
||||
relatedEntityType: 'global-app',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
connection: await connection.createSavableObject(),
|
||||
@@ -153,6 +200,116 @@ export class AppConnectionManager {
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateAppRoleMappings>(
|
||||
'updateAppRoleMappings',
|
||||
async (requestArg) => {
|
||||
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: jwtData.data.userId,
|
||||
});
|
||||
const connection = await this.updateAppRoleMappings({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
appId: requestArg.appId,
|
||||
roleMappings: requestArg.roleMappings,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
connection: await connection.createSavableObject(),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async updateAppRoleMappings(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
appId: string;
|
||||
roleMappings: plugins.idpInterfaces.data.IAppRoleMapping[];
|
||||
}) {
|
||||
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: optionsArg.organizationId,
|
||||
});
|
||||
if (!organization) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
if (!await organization.checkIfUserIsAdmin(optionsArg.user)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Only organization admins can manage app role mappings');
|
||||
}
|
||||
|
||||
const app = await this.receptionRef.appManager.getAppById(optionsArg.appId);
|
||||
if (!app) {
|
||||
throw new plugins.typedrequest.TypedResponseError('App not found');
|
||||
}
|
||||
|
||||
const connection = await this.CAppConnection.getInstance({
|
||||
'data.organizationId': optionsArg.organizationId,
|
||||
'data.appId': optionsArg.appId,
|
||||
});
|
||||
if (!connection || !connection.isActive()) {
|
||||
throw new plugins.typedrequest.TypedResponseError('App must be connected before role mappings can be configured');
|
||||
}
|
||||
|
||||
const availableRoleKeys = await this.receptionRef.organizationmanager.getAvailableRoleKeys(optionsArg.organizationId);
|
||||
const cleanMappings = (optionsArg.roleMappings || []).map((mappingArg) => ({
|
||||
orgRoleKey: this.receptionRef.organizationmanager.validateRoleKey(mappingArg.orgRoleKey),
|
||||
appRoles: this.cleanStringList(mappingArg.appRoles),
|
||||
permissions: this.cleanStringList(mappingArg.permissions),
|
||||
scopes: this.cleanStringList(mappingArg.scopes),
|
||||
})).filter((mappingArg) => mappingArg.appRoles.length || mappingArg.permissions.length || mappingArg.scopes.length);
|
||||
const invalidRoleKeys = cleanMappings
|
||||
.map((mappingArg) => mappingArg.orgRoleKey)
|
||||
.filter((roleKeyArg) => !availableRoleKeys.includes(roleKeyArg));
|
||||
if (invalidRoleKeys.length) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Unknown organization roles: ${[...new Set(invalidRoleKeys)].join(', ')}.`);
|
||||
}
|
||||
|
||||
const requestedScopes = cleanMappings.flatMap((mappingArg) => mappingArg.scopes);
|
||||
const allowedScopes = app.data.oauthCredentials?.allowedScopes || [];
|
||||
const grantedScopes = connection.data.grantedScopes || [];
|
||||
const unsupportedScopes = requestedScopes.filter((scopeArg) => !allowedScopes.includes(scopeArg));
|
||||
if (unsupportedScopes.length) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Unsupported app scopes: ${[...new Set(unsupportedScopes)].join(', ')}.`);
|
||||
}
|
||||
const ungrantedScopes = requestedScopes.filter((scopeArg) => !grantedScopes.includes(scopeArg));
|
||||
if (ungrantedScopes.length) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Scopes not granted to this connection: ${[...new Set(ungrantedScopes)].join(', ')}.`);
|
||||
}
|
||||
|
||||
connection.data.roleMappings = cleanMappings;
|
||||
await connection.save();
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'org_app_role_mappings_updated',
|
||||
`${optionsArg.user.data.email} updated ${cleanMappings.length} role mappings for ${app.data.name}.`,
|
||||
{
|
||||
targetId: connection.id,
|
||||
targetType: 'app-connection',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: optionsArg.organizationId,
|
||||
eventType: 'org_app_role_mappings_updated',
|
||||
severity: 'medium',
|
||||
title: 'Organization app role mappings updated',
|
||||
body: `${optionsArg.user.data.email} updated role mappings for ${app.data.name}.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: app.id,
|
||||
relatedEntityType: 'global-app',
|
||||
});
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private cleanStringList(valuesArg: string[]) {
|
||||
return [...new Set((valuesArg || [])
|
||||
.map((valueArg) => (valueArg || '').trim())
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -59,7 +59,20 @@ export class AppManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'getGlobalAppStats',
|
||||
async (requestArg) => {
|
||||
await this.verifyGlobalAdmin(requestArg.jwt);
|
||||
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: jwtData.data.userId,
|
||||
});
|
||||
|
||||
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||
category: 'admin',
|
||||
eventType: 'global_admin_access',
|
||||
severity: 'high',
|
||||
title: 'Global admin console accessed',
|
||||
body: `${user?.data?.email || 'A global admin'} accessed the global app administration dashboard.`,
|
||||
actorUserId: jwtData.data.userId,
|
||||
relatedEntityType: 'global-admin-console',
|
||||
});
|
||||
|
||||
// Get all global apps (including inactive)
|
||||
const globalApps = await this.CApp.getInstances({
|
||||
@@ -198,7 +211,7 @@ export class AppManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||
'regenerateAppCredentials',
|
||||
async (requestArg) => {
|
||||
await this.verifyGlobalAdmin(requestArg.jwt);
|
||||
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
||||
|
||||
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
||||
if (!app) {
|
||||
@@ -214,6 +227,17 @@ export class AppManager {
|
||||
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
|
||||
await app.save();
|
||||
|
||||
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||
category: 'security',
|
||||
eventType: 'global_app_credentials_regenerated',
|
||||
severity: 'critical',
|
||||
title: 'Global app credentials regenerated',
|
||||
body: `OAuth credentials for ${app.data.name} were regenerated.`,
|
||||
actorUserId: jwtData.data.userId,
|
||||
relatedEntityId: app.id,
|
||||
relatedEntityType: 'global-app',
|
||||
});
|
||||
|
||||
return {
|
||||
clientId,
|
||||
clientSecret, // Only shown once
|
||||
|
||||
@@ -74,6 +74,58 @@ export class ReceptionHousekeeping {
|
||||
'2 * * * * *'
|
||||
);
|
||||
|
||||
this.taskmanager.addAndScheduleTask(
|
||||
new plugins.taskbuffer.Task({
|
||||
name: 'expiredAbuseWindows',
|
||||
taskFunction: async () => {
|
||||
const expiredAbuseWindows =
|
||||
await this.receptionRef.abuseProtectionManager.CAbuseWindow.getInstances({
|
||||
data: {
|
||||
validUntil: {
|
||||
$lt: Date.now(),
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
for (const abuseWindow of expiredAbuseWindows) {
|
||||
await abuseWindow.delete();
|
||||
}
|
||||
},
|
||||
}),
|
||||
'2 * * * * *'
|
||||
);
|
||||
|
||||
this.taskmanager.addAndScheduleTask(
|
||||
new plugins.taskbuffer.Task({
|
||||
name: 'expiredPassportChallenges',
|
||||
taskFunction: async () => {
|
||||
await this.receptionRef.passportManager.cleanupExpiredChallenges();
|
||||
},
|
||||
}),
|
||||
'2 * * * * *'
|
||||
);
|
||||
|
||||
this.taskmanager.addAndScheduleTask(
|
||||
new plugins.taskbuffer.Task({
|
||||
name: 'redeliverPassportChallengeHints',
|
||||
taskFunction: async () => {
|
||||
await this.receptionRef.passportManager.reDeliverPendingChallengeHints();
|
||||
},
|
||||
}),
|
||||
'7 * * * * *'
|
||||
);
|
||||
|
||||
this.taskmanager.addAndScheduleTask(
|
||||
new plugins.taskbuffer.Task({
|
||||
name: 'redeliverAlertHints',
|
||||
taskFunction: async () => {
|
||||
await this.receptionRef.alertManager.reDeliverPendingAlerts();
|
||||
},
|
||||
}),
|
||||
'12 * * * * *'
|
||||
);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.taskmanager.start();
|
||||
logger.log('info', 'housekeeping started');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,34 @@ import { Reception } from './classes.reception.js';
|
||||
import { logger } from './logging.js';
|
||||
|
||||
export class LoginSessionManager {
|
||||
private readonly abuseProtectionConfigs = {
|
||||
passwordLogin: {
|
||||
maxAttempts: 5,
|
||||
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||
},
|
||||
emailLoginRequest: {
|
||||
maxAttempts: 5,
|
||||
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||
},
|
||||
emailLoginToken: {
|
||||
maxAttempts: 5,
|
||||
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||
},
|
||||
passwordResetRequest: {
|
||||
maxAttempts: 5,
|
||||
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||
},
|
||||
passwordResetCompletion: {
|
||||
maxAttempts: 5,
|
||||
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||
},
|
||||
};
|
||||
|
||||
// refs
|
||||
public receptionRef: Reception;
|
||||
public get db() {
|
||||
@@ -23,6 +51,14 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword',
|
||||
async (requestData) => {
|
||||
const loginIdentifier = requestData.username;
|
||||
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||
'passwordLogin',
|
||||
loginIdentifier,
|
||||
this.abuseProtectionConfigs.passwordLogin,
|
||||
'Too many login attempts. Please wait before trying again.'
|
||||
);
|
||||
|
||||
let user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
username: requestData.username,
|
||||
@@ -54,6 +90,11 @@ export class LoginSessionManager {
|
||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||
}
|
||||
|
||||
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||
'passwordLogin',
|
||||
loginIdentifier
|
||||
);
|
||||
|
||||
return {
|
||||
refreshToken,
|
||||
twoFaNeeded: false,
|
||||
@@ -69,6 +110,12 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||
'loginWithEmail',
|
||||
async (requestDataArg) => {
|
||||
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||
'emailLoginRequest',
|
||||
requestDataArg.email,
|
||||
this.abuseProtectionConfigs.emailLoginRequest,
|
||||
'Too many magic link requests. Please wait before trying again.'
|
||||
);
|
||||
logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`);
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
@@ -101,6 +148,12 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
||||
'loginWithEmailAfterEmailTokenAquired',
|
||||
async (requestArg) => {
|
||||
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||
'emailLoginToken',
|
||||
requestArg.email,
|
||||
this.abuseProtectionConfigs.emailLoginToken,
|
||||
'Too many magic link attempts. Please wait before trying again.'
|
||||
);
|
||||
const tokenObject = await this.consumeEmailActionToken(
|
||||
requestArg.email,
|
||||
requestArg.token,
|
||||
@@ -120,6 +173,10 @@ export class LoginSessionManager {
|
||||
if (!refreshToken) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||
}
|
||||
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||
'emailLoginToken',
|
||||
requestArg.email
|
||||
);
|
||||
return {
|
||||
refreshToken,
|
||||
};
|
||||
@@ -188,6 +245,12 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||
'resetPassword',
|
||||
async (requestDataArg) => {
|
||||
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||
'passwordResetRequest',
|
||||
requestDataArg.email,
|
||||
this.abuseProtectionConfigs.passwordResetRequest,
|
||||
'Too many password reset requests. Please wait before trying again.'
|
||||
);
|
||||
const emailOfPasswordToReset = requestDataArg.email;
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
@@ -216,6 +279,12 @@ export class LoginSessionManager {
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
||||
'setNewPassword',
|
||||
async (requestData) => {
|
||||
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||
'passwordResetCompletion',
|
||||
requestData.email,
|
||||
this.abuseProtectionConfigs.passwordResetCompletion,
|
||||
'Too many password change attempts. Please wait before trying again.'
|
||||
);
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: {
|
||||
email: requestData.email,
|
||||
@@ -253,6 +322,10 @@ export class LoginSessionManager {
|
||||
requestData.newPassword
|
||||
);
|
||||
await user.save();
|
||||
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||
'passwordResetCompletion',
|
||||
requestData.email
|
||||
);
|
||||
return {
|
||||
status: 'ok',
|
||||
};
|
||||
|
||||
@@ -11,11 +11,21 @@ import { OidcUserConsent } from './classes.oidcuserconsent.js';
|
||||
* for third-party client authentication.
|
||||
*/
|
||||
export class OidcManager {
|
||||
private readonly abuseProtectionConfig = {
|
||||
oidcTokenExchange: {
|
||||
maxAttempts: 10,
|
||||
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
|
||||
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||
},
|
||||
};
|
||||
|
||||
public receptionRef: Reception;
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
|
||||
this,
|
||||
OidcAuthorizationCode
|
||||
@@ -31,6 +41,35 @@ export class OidcManager {
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
|
||||
'prepareOidcAuthorization',
|
||||
async (requestArg) => {
|
||||
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||
if (!jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||
}
|
||||
|
||||
return this.prepareAuthorizationForUser(jwt.data.userId, requestArg);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
|
||||
'completeOidcAuthorization',
|
||||
async (requestArg) => {
|
||||
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||
if (!jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||
}
|
||||
|
||||
return this.completeAuthorizationForUser(jwt.data.userId, requestArg);
|
||||
}
|
||||
)
|
||||
);
|
||||
this.startCleanupTask();
|
||||
}
|
||||
|
||||
@@ -128,6 +167,10 @@ export class OidcManager {
|
||||
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
|
||||
}
|
||||
|
||||
if (prompt && !this.isSupportedPrompt(prompt)) {
|
||||
return this.errorResponse('invalid_request', 'Unsupported prompt value');
|
||||
}
|
||||
|
||||
// Validate code challenge method if present
|
||||
if (codeChallenge && codeChallengeMethod !== 'S256') {
|
||||
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
|
||||
@@ -169,6 +212,9 @@ export class OidcManager {
|
||||
if (nonce) {
|
||||
loginUrl.searchParams.set('nonce', nonce);
|
||||
}
|
||||
if (prompt) {
|
||||
loginUrl.searchParams.set('prompt', prompt);
|
||||
}
|
||||
|
||||
return Response.redirect(loginUrl.toString(), 302);
|
||||
}
|
||||
@@ -202,10 +248,71 @@ export class OidcManager {
|
||||
};
|
||||
|
||||
await authCode.save();
|
||||
await this.upsertUserConsent(userId, clientId, scopes);
|
||||
return code;
|
||||
}
|
||||
|
||||
public async prepareAuthorizationForUser(
|
||||
userIdArg: string,
|
||||
requestArg: Omit<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['request'], 'jwt'>
|
||||
): Promise<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response']> {
|
||||
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
|
||||
const consentState = await this.evaluateConsentRequirement(
|
||||
userIdArg,
|
||||
resolvedRequest.clientId,
|
||||
resolvedRequest.validScopes,
|
||||
resolvedRequest.prompt
|
||||
);
|
||||
|
||||
return {
|
||||
status: consentState.consentRequired ? ('consent_required' as const) : ('ready' as const),
|
||||
clientId: resolvedRequest.clientId,
|
||||
appName: resolvedRequest.app.data.name,
|
||||
appUrl: resolvedRequest.app.data.appUrl,
|
||||
logoUrl: resolvedRequest.app.data.logoUrl,
|
||||
requestedScopes: resolvedRequest.validScopes,
|
||||
grantedScopes: consentState.grantedScopes,
|
||||
};
|
||||
}
|
||||
|
||||
public async completeAuthorizationForUser(
|
||||
userIdArg: string,
|
||||
requestArg: Omit<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], 'jwt'>
|
||||
) {
|
||||
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
|
||||
const consentState = await this.evaluateConsentRequirement(
|
||||
userIdArg,
|
||||
resolvedRequest.clientId,
|
||||
resolvedRequest.validScopes,
|
||||
resolvedRequest.prompt
|
||||
);
|
||||
|
||||
if (consentState.consentRequired && !requestArg.consentApproved) {
|
||||
throw new Error('Consent required');
|
||||
}
|
||||
|
||||
if (requestArg.consentApproved) {
|
||||
await this.upsertUserConsent(userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes);
|
||||
}
|
||||
|
||||
const code = await this.generateAuthorizationCode(
|
||||
resolvedRequest.clientId,
|
||||
userIdArg,
|
||||
resolvedRequest.validScopes,
|
||||
resolvedRequest.redirectUri,
|
||||
resolvedRequest.codeChallenge,
|
||||
resolvedRequest.nonce
|
||||
);
|
||||
|
||||
const redirectUrl = new URL(resolvedRequest.redirectUri);
|
||||
redirectUrl.searchParams.set('code', code);
|
||||
redirectUrl.searchParams.set('state', resolvedRequest.state);
|
||||
|
||||
return {
|
||||
code,
|
||||
redirectUrl: redirectUrl.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the token endpoint request
|
||||
*/
|
||||
@@ -236,6 +343,13 @@ export class OidcManager {
|
||||
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
|
||||
}
|
||||
|
||||
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||
'oidcTokenExchange',
|
||||
clientId,
|
||||
this.abuseProtectionConfig.oidcTokenExchange,
|
||||
'Too many token endpoint attempts. Please wait before retrying.'
|
||||
);
|
||||
|
||||
// Find and validate app
|
||||
const app = await this.findAppByClientId(clientId);
|
||||
if (!app) {
|
||||
@@ -250,13 +364,20 @@ export class OidcManager {
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
if (grantType === 'authorization_code') {
|
||||
return this.handleAuthorizationCodeGrant(formData, app);
|
||||
response = await this.handleAuthorizationCodeGrant(formData, app);
|
||||
} else if (grantType === 'refresh_token') {
|
||||
return this.handleRefreshTokenGrant(formData, app);
|
||||
response = await this.handleRefreshTokenGrant(formData, app);
|
||||
} else {
|
||||
return this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
|
||||
response = this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
await this.receptionRef.abuseProtectionManager.clearAttempts('oidcTokenExchange', clientId);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -467,7 +588,7 @@ export class OidcManager {
|
||||
|
||||
// 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);
|
||||
const userInfo = await this.getUserClaims(userId, scopes, clientId);
|
||||
Object.assign(claims, userInfo);
|
||||
}
|
||||
|
||||
@@ -517,7 +638,7 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
// Get user claims based on token scopes
|
||||
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes);
|
||||
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes, tokenData.data.clientId);
|
||||
|
||||
return new Response(JSON.stringify(userInfo), {
|
||||
status: 200,
|
||||
@@ -530,7 +651,8 @@ export class OidcManager {
|
||||
*/
|
||||
private async getUserClaims(
|
||||
userId: string,
|
||||
scopes: plugins.idpInterfaces.data.TOidcScope[]
|
||||
scopes: plugins.idpInterfaces.data.TOidcScope[],
|
||||
clientId?: string
|
||||
): Promise<plugins.idpInterfaces.data.IUserInfoResponse> {
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({ id: userId });
|
||||
if (!user) {
|
||||
@@ -576,11 +698,52 @@ export class OidcManager {
|
||||
roles.push('admin');
|
||||
}
|
||||
claims.roles = roles;
|
||||
|
||||
if (clientId) {
|
||||
Object.assign(claims, await this.getMappedAppClaims(user, clientId));
|
||||
}
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
private async getMappedAppClaims(userArg: any, clientIdArg: string) {
|
||||
const app = await this.findAppByClientId(clientIdArg);
|
||||
if (!app) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||
'data.appId': app.id,
|
||||
'data.status': 'active',
|
||||
});
|
||||
const memberRoles = await this.receptionRef.roleManager.getAllRolesForUser(userArg);
|
||||
const appRoles = new Set<string>();
|
||||
const appPermissions = new Set<string>();
|
||||
const appScopes = new Set<string>();
|
||||
|
||||
for (const connection of connections) {
|
||||
const memberRole = memberRoles.find((roleArg) => roleArg.data.organizationId === connection.data.organizationId);
|
||||
if (!memberRole) {
|
||||
continue;
|
||||
}
|
||||
for (const mapping of connection.data.roleMappings || []) {
|
||||
if (!memberRole.data.roles.includes(mapping.orgRoleKey)) {
|
||||
continue;
|
||||
}
|
||||
for (const appRole of mapping.appRoles || []) appRoles.add(appRole);
|
||||
for (const permission of mapping.permissions || []) appPermissions.add(permission);
|
||||
for (const scope of mapping.scopes || []) appScopes.add(scope);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
app_roles: [...appRoles],
|
||||
app_permissions: [...appPermissions],
|
||||
app_scopes: [...appScopes],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the revocation endpoint
|
||||
*/
|
||||
@@ -625,6 +788,78 @@ export class OidcManager {
|
||||
return apps[0] || null;
|
||||
}
|
||||
|
||||
private isSupportedPrompt(promptArg: string): promptArg is 'none' | 'login' | 'consent' {
|
||||
return ['none', 'login', 'consent'].includes(promptArg);
|
||||
}
|
||||
|
||||
private async resolveAuthorizationRequest(
|
||||
requestArg: Pick<
|
||||
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
|
||||
'clientId' | 'redirectUri' | 'scope' | 'state' | 'prompt' | 'codeChallenge' | 'codeChallengeMethod' | 'nonce'
|
||||
>
|
||||
) {
|
||||
if (!requestArg.clientId || !requestArg.redirectUri || !requestArg.scope || !requestArg.state) {
|
||||
throw new Error('Missing required OAuth authorization parameters');
|
||||
}
|
||||
|
||||
if (requestArg.prompt && !this.isSupportedPrompt(requestArg.prompt)) {
|
||||
throw new Error('Unsupported prompt value');
|
||||
}
|
||||
|
||||
if (requestArg.codeChallenge && requestArg.codeChallengeMethod !== 'S256') {
|
||||
throw new Error('Only S256 code challenge method is supported');
|
||||
}
|
||||
|
||||
const app = await this.findAppByClientId(requestArg.clientId);
|
||||
if (!app) {
|
||||
throw new Error('Unknown client_id');
|
||||
}
|
||||
|
||||
if (!app.data.oauthCredentials.redirectUris.includes(requestArg.redirectUri)) {
|
||||
throw new Error('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
const requestedScopes = requestArg.scope
|
||||
.split(' ')
|
||||
.filter(Boolean) as plugins.idpInterfaces.data.TOidcScope[];
|
||||
const allowedScopes =
|
||||
app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[];
|
||||
const validScopes = requestedScopes.filter((scopeArg) => allowedScopes.includes(scopeArg));
|
||||
|
||||
if (!validScopes.includes('openid')) {
|
||||
throw new Error('openid scope is required');
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
clientId: requestArg.clientId,
|
||||
redirectUri: requestArg.redirectUri,
|
||||
state: requestArg.state,
|
||||
prompt: requestArg.prompt,
|
||||
codeChallenge: requestArg.codeChallenge,
|
||||
codeChallengeMethod: requestArg.codeChallengeMethod,
|
||||
nonce: requestArg.nonce,
|
||||
validScopes,
|
||||
};
|
||||
}
|
||||
|
||||
private async evaluateConsentRequirement(
|
||||
userIdArg: string,
|
||||
clientIdArg: string,
|
||||
scopesArg: plugins.idpInterfaces.data.TOidcScope[],
|
||||
promptArg?: 'none' | 'login' | 'consent'
|
||||
) {
|
||||
const existingConsent = await this.getUserConsent(userIdArg, clientIdArg);
|
||||
const grantedScopes = existingConsent?.data.scopes || [];
|
||||
const missingScopes = scopesArg.filter((scopeArg) => !grantedScopes.includes(scopeArg));
|
||||
|
||||
return {
|
||||
grantedScopes,
|
||||
missingScopes,
|
||||
consentRequired: promptArg === 'consent' || missingScopes.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createOpaqueToken(byteLength = 32): string {
|
||||
return plugins.crypto.randomBytes(byteLength).toString('base64url');
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
|
||||
slug: slugNameArg,
|
||||
billingPlanId: null,
|
||||
roleIds: [],
|
||||
roleDefinitions: [],
|
||||
}
|
||||
await newOrg.save();
|
||||
return newOrg;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Organization } from './classes.organization.js';
|
||||
import { User } from './classes.user.js';
|
||||
|
||||
export class OrganizationManager {
|
||||
public static readonly platformRoleKeys = ['owner', 'admin', 'editor', 'viewer', 'guest', 'outlaw'];
|
||||
|
||||
public receptionRef: Reception;
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
@@ -93,6 +95,476 @@ export class OrganizationManager {
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateOrganization>(
|
||||
'updateOrganization',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
const organization = await this.updateOrganizationWithAudit({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
name: requestArg.name,
|
||||
slug: requestArg.slug,
|
||||
confirmationText: requestArg.confirmationText,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
organization: await organization.createSavableObject(),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteOrganization>(
|
||||
'deleteOrganization',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.deleteOrganizationWithAudit({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
confirmationText: requestArg.confirmationText,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedOrganizationId: requestArg.organizationId,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgRoleDefinitions>(
|
||||
'getOrgRoleDefinitions',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
const organization = await this.getOrganizationOrThrow(requestArg.organizationId);
|
||||
await this.getRoleOrThrow(user, organization);
|
||||
return {
|
||||
roleDefinitions: this.getCustomRoleDefinitions(organization),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpsertOrgRoleDefinition>(
|
||||
'upsertOrgRoleDefinition',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
const roleDefinitions = await this.upsertOrgRoleDefinition({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
roleDefinition: requestArg.roleDefinition,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
roleDefinitions,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteOrgRoleDefinition>(
|
||||
'deleteOrgRoleDefinition',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
const roleDefinitions = await this.deleteOrgRoleDefinition({
|
||||
user,
|
||||
organizationId: requestArg.organizationId,
|
||||
roleKey: requestArg.roleKey,
|
||||
confirmationText: requestArg.confirmationText,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
roleDefinitions,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private getCustomRoleDefinitions(organizationArg: Organization) {
|
||||
return organizationArg.data.roleDefinitions || [];
|
||||
}
|
||||
|
||||
private normalizeRoleKey(roleKeyArg: string) {
|
||||
return (roleKeyArg || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
public validateRoleKey(roleKeyArg: string) {
|
||||
const roleKey = this.normalizeRoleKey(roleKeyArg);
|
||||
if (!roleKey || roleKey.length < 2 || roleKey.length > 64) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Role key must be between 2 and 64 characters.');
|
||||
}
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(roleKey)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Role key may only contain lowercase letters, numbers, and single dashes.');
|
||||
}
|
||||
return roleKey;
|
||||
}
|
||||
|
||||
public async getAvailableRoleKeys(organizationIdArg: string) {
|
||||
const organization = await this.getOrganizationOrThrow(organizationIdArg);
|
||||
return [
|
||||
...OrganizationManager.platformRoleKeys,
|
||||
...this.getCustomRoleDefinitions(organization).map((roleDefinitionArg) => roleDefinitionArg.key),
|
||||
];
|
||||
}
|
||||
|
||||
public async assertRoleKeysAreValid(organizationIdArg: string, roleKeysArg: string[]) {
|
||||
const normalizedRoleKeys = [...new Set((roleKeysArg || []).map((roleKeyArg) => this.validateRoleKey(roleKeyArg)))];
|
||||
if (!normalizedRoleKeys.length) {
|
||||
throw new plugins.typedrequest.TypedResponseError('At least one role is required.');
|
||||
}
|
||||
const availableRoleKeys = await this.getAvailableRoleKeys(organizationIdArg);
|
||||
const invalidRoleKeys = normalizedRoleKeys.filter((roleKeyArg) => !availableRoleKeys.includes(roleKeyArg));
|
||||
if (invalidRoleKeys.length) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Unknown organization roles: ${invalidRoleKeys.join(', ')}.`);
|
||||
}
|
||||
return normalizedRoleKeys;
|
||||
}
|
||||
|
||||
private normalizeSlug(slugArg: string) {
|
||||
return (slugArg || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
private validateSlug(slugArg: string) {
|
||||
const slug = this.normalizeSlug(slugArg);
|
||||
if (!slug || slug.length < 3 || slug.length > 64) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization slug must be between 3 and 64 characters.');
|
||||
}
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization slug may only contain lowercase letters, numbers, and single dashes.');
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
private assertConfirmation(confirmationTextArg: string, expectedTextArg: string) {
|
||||
if ((confirmationTextArg || '').trim() !== expectedTextArg) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Confirmation text must be exactly "${expectedTextArg}".`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrganizationOrThrow(organizationIdArg: string) {
|
||||
const organization = await this.COrganization.getInstance({
|
||||
id: organizationIdArg,
|
||||
});
|
||||
if (!organization) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found.');
|
||||
}
|
||||
return organization;
|
||||
}
|
||||
|
||||
private async getRoleOrThrow(userArg: User, organizationArg: Organization) {
|
||||
const role = await this.receptionRef.roleManager.getRoleForUserAndOrg(userArg, organizationArg);
|
||||
if (!role) {
|
||||
throw new plugins.typedrequest.TypedResponseError('User not authorized for this organization.');
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
private async verifyAdmin(userArg: User, organizationArg: Organization) {
|
||||
const role = await this.getRoleOrThrow(userArg, organizationArg);
|
||||
if (!role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required.');
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
private async verifyOwner(userArg: User, organizationArg: Organization) {
|
||||
const role = await this.getRoleOrThrow(userArg, organizationArg);
|
||||
if (!role.data.roles.includes('owner')) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization owner privileges required.');
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
private async emitOrganizationAlert(optionsArg: {
|
||||
organizationId: string;
|
||||
eventType: string;
|
||||
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
actorUserId: string;
|
||||
relatedEntityId?: string;
|
||||
relatedEntityType?: string;
|
||||
}) {
|
||||
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||
category: 'admin',
|
||||
organizationId: optionsArg.organizationId,
|
||||
eventType: optionsArg.eventType,
|
||||
severity: optionsArg.severity,
|
||||
title: optionsArg.title,
|
||||
body: optionsArg.body,
|
||||
actorUserId: optionsArg.actorUserId,
|
||||
relatedEntityId: optionsArg.relatedEntityId,
|
||||
relatedEntityType: optionsArg.relatedEntityType,
|
||||
});
|
||||
}
|
||||
|
||||
public async upsertOrgRoleDefinition(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
roleDefinition: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
}) {
|
||||
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||
await this.verifyAdmin(optionsArg.user, organization);
|
||||
const roleKey = this.validateRoleKey(optionsArg.roleDefinition.key);
|
||||
if (OrganizationManager.platformRoleKeys.includes(roleKey)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Platform roles cannot be redefined by an organization.');
|
||||
}
|
||||
|
||||
const roleName = (optionsArg.roleDefinition.name || '').trim();
|
||||
if (!roleName) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Role name is required.');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const roleDefinitions = this.getCustomRoleDefinitions(organization);
|
||||
const existingRoleDefinition = roleDefinitions.find((roleDefinitionArg) => roleDefinitionArg.key === roleKey);
|
||||
if (existingRoleDefinition) {
|
||||
existingRoleDefinition.name = roleName;
|
||||
existingRoleDefinition.description = optionsArg.roleDefinition.description?.trim() || '';
|
||||
existingRoleDefinition.updatedAt = now;
|
||||
} else {
|
||||
roleDefinitions.push({
|
||||
key: roleKey,
|
||||
name: roleName,
|
||||
description: optionsArg.roleDefinition.description?.trim() || '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
organization.data.roleDefinitions = roleDefinitions.sort((leftArg, rightArg) => leftArg.name.localeCompare(rightArg.name));
|
||||
await organization.save();
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'role_changed',
|
||||
`${optionsArg.user.data.email} ${existingRoleDefinition ? 'updated' : 'created'} organization role ${roleKey}.`,
|
||||
{
|
||||
targetId: organization.id,
|
||||
targetType: 'organization-role',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: organization.id,
|
||||
eventType: 'org_role_definition_updated',
|
||||
severity: 'medium',
|
||||
title: 'Organization role definition updated',
|
||||
body: `${optionsArg.user.data.email} ${existingRoleDefinition ? 'updated' : 'created'} organization role ${roleKey}.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: roleKey,
|
||||
relatedEntityType: 'organization-role',
|
||||
});
|
||||
|
||||
return organization.data.roleDefinitions;
|
||||
}
|
||||
|
||||
public async deleteOrgRoleDefinition(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
roleKey: string;
|
||||
confirmationText: string;
|
||||
}) {
|
||||
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||
await this.verifyAdmin(optionsArg.user, organization);
|
||||
const roleKey = this.validateRoleKey(optionsArg.roleKey);
|
||||
if (OrganizationManager.platformRoleKeys.includes(roleKey)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Platform roles cannot be deleted.');
|
||||
}
|
||||
this.assertConfirmation(optionsArg.confirmationText, `delete role ${roleKey}`);
|
||||
|
||||
const roleDefinitions = this.getCustomRoleDefinitions(organization);
|
||||
if (!roleDefinitions.some((roleDefinitionArg) => roleDefinitionArg.key === roleKey)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization role definition not found.');
|
||||
}
|
||||
|
||||
organization.data.roleDefinitions = roleDefinitions.filter((roleDefinitionArg) => roleDefinitionArg.key !== roleKey);
|
||||
await organization.save();
|
||||
|
||||
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organization.id);
|
||||
for (const role of roles) {
|
||||
if (role.data.roles.includes(roleKey)) {
|
||||
role.data.roles = role.data.roles.filter((roleKeyArg) => roleKeyArg !== roleKey);
|
||||
if (!role.data.roles.length) {
|
||||
role.data.roles = ['viewer'];
|
||||
}
|
||||
await role.save();
|
||||
}
|
||||
}
|
||||
|
||||
const appConnections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||
'data.organizationId': organization.id,
|
||||
});
|
||||
for (const connection of appConnections) {
|
||||
if (connection.data.roleMappings?.some((mappingArg) => mappingArg.orgRoleKey === roleKey)) {
|
||||
connection.data.roleMappings = connection.data.roleMappings.filter((mappingArg) => mappingArg.orgRoleKey !== roleKey);
|
||||
await connection.save();
|
||||
}
|
||||
}
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'role_changed',
|
||||
`${optionsArg.user.data.email} deleted organization role ${roleKey}.`,
|
||||
{
|
||||
targetId: organization.id,
|
||||
targetType: 'organization-role',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: organization.id,
|
||||
eventType: 'org_role_definition_deleted',
|
||||
severity: 'high',
|
||||
title: 'Organization role definition deleted',
|
||||
body: `${optionsArg.user.data.email} deleted organization role ${roleKey}. Member assignments and app mappings were cleaned up.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: roleKey,
|
||||
relatedEntityType: 'organization-role',
|
||||
});
|
||||
|
||||
return organization.data.roleDefinitions;
|
||||
}
|
||||
|
||||
public async updateOrganizationWithAudit(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
confirmationText: string;
|
||||
}) {
|
||||
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||
await this.verifyAdmin(optionsArg.user, organization);
|
||||
this.assertConfirmation(optionsArg.confirmationText, organization.data.slug);
|
||||
|
||||
const previousName = organization.data.name;
|
||||
const previousSlug = organization.data.slug;
|
||||
const nextName = typeof optionsArg.name === 'string' ? optionsArg.name.trim() : previousName;
|
||||
const nextSlug = typeof optionsArg.slug === 'string' ? this.validateSlug(optionsArg.slug) : previousSlug;
|
||||
|
||||
if (!nextName) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization name is required.');
|
||||
}
|
||||
|
||||
if (nextSlug !== previousSlug) {
|
||||
const existingOrganization = await this.COrganization.getInstance({
|
||||
data: {
|
||||
slug: nextSlug,
|
||||
},
|
||||
});
|
||||
if (existingOrganization && existingOrganization.id !== organization.id) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization slug is already in use.');
|
||||
}
|
||||
}
|
||||
|
||||
organization.data.name = nextName;
|
||||
organization.data.slug = nextSlug;
|
||||
await organization.save();
|
||||
|
||||
const changes = [
|
||||
previousName !== nextName ? `name "${previousName}" -> "${nextName}"` : '',
|
||||
previousSlug !== nextSlug ? `slug "${previousSlug}" -> "${nextSlug}"` : '',
|
||||
].filter(Boolean).join(', ') || 'no field changes';
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'org_updated',
|
||||
`Organization ${previousName} updated: ${changes}.`,
|
||||
{
|
||||
targetId: organization.id,
|
||||
targetType: 'organization',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: organization.id,
|
||||
eventType: 'org_updated',
|
||||
severity: 'high',
|
||||
title: 'Organization settings updated',
|
||||
body: `${optionsArg.user.data.email} updated ${previousName}: ${changes}.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: organization.id,
|
||||
relatedEntityType: 'organization',
|
||||
});
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
public async deleteOrganizationWithAudit(optionsArg: {
|
||||
user: User;
|
||||
organizationId: string;
|
||||
confirmationText: string;
|
||||
}) {
|
||||
const organization = await this.getOrganizationOrThrow(optionsArg.organizationId);
|
||||
await this.verifyOwner(optionsArg.user, organization);
|
||||
this.assertConfirmation(optionsArg.confirmationText, `delete ${organization.data.slug}`);
|
||||
|
||||
const organizationName = organization.data.name;
|
||||
const organizationSlug = organization.data.slug;
|
||||
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organization.id);
|
||||
const appConnections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||
'data.organizationId': organization.id,
|
||||
});
|
||||
const invitations = await this.receptionRef.userInvitationManager.CUserInvitation.getInstances({});
|
||||
const billingPlans = await this.receptionRef.billingPlanManager.CBillingPlan.getInstances({
|
||||
'data.organizationId': organization.id,
|
||||
});
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
optionsArg.user.id,
|
||||
'org_deleted',
|
||||
`Organization ${organizationName} (${organizationSlug}) deleted.`,
|
||||
{
|
||||
targetId: organization.id,
|
||||
targetType: 'organization',
|
||||
}
|
||||
);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: organization.id,
|
||||
eventType: 'org_deleted',
|
||||
severity: 'critical',
|
||||
title: 'Organization deleted',
|
||||
body: `${optionsArg.user.data.email} deleted ${organizationName}. ${roles.length} memberships and ${appConnections.length} app connections were removed.`,
|
||||
actorUserId: optionsArg.user.id,
|
||||
relatedEntityId: organization.id,
|
||||
relatedEntityType: 'organization',
|
||||
});
|
||||
|
||||
for (const connection of appConnections) {
|
||||
await connection.delete();
|
||||
}
|
||||
|
||||
for (const invitation of invitations) {
|
||||
if (invitation.data.organizationRefs.some((refArg) => refArg.organizationId === organization.id)) {
|
||||
await invitation.removeOrganization(organization.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const billingPlan of billingPlans) {
|
||||
await billingPlan.delete();
|
||||
}
|
||||
|
||||
for (const role of roles) {
|
||||
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: role.data.userId,
|
||||
});
|
||||
if (memberUser?.data.connectedOrgs) {
|
||||
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
||||
(organizationIdArg) => organizationIdArg !== organization.id
|
||||
);
|
||||
await memberUser.save();
|
||||
}
|
||||
await role.delete();
|
||||
}
|
||||
|
||||
await organization.delete();
|
||||
}
|
||||
|
||||
public async getAllOrganizationsForUser(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import type { PassportManager } from './classes.passportmanager.js';
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class PassportChallenge extends plugins.smartdata.SmartDataDbDoc<
|
||||
PassportChallenge,
|
||||
plugins.idpInterfaces.data.IPassportChallenge,
|
||||
PassportManager
|
||||
> {
|
||||
public static hashToken(tokenArg: string) {
|
||||
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||
}
|
||||
|
||||
@plugins.smartdata.unI()
|
||||
public id: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IPassportChallenge['data'] = {
|
||||
userId: '',
|
||||
deviceId: null,
|
||||
type: 'device_enrollment',
|
||||
status: 'pending',
|
||||
tokenHash: null,
|
||||
challenge: '',
|
||||
metadata: {
|
||||
originHost: undefined,
|
||||
audience: undefined,
|
||||
notificationTitle: undefined,
|
||||
deviceLabel: undefined,
|
||||
requireLocation: false,
|
||||
requireNfc: false,
|
||||
locationPolicy: undefined,
|
||||
requestedCapabilities: undefined,
|
||||
},
|
||||
evidence: undefined,
|
||||
notification: undefined,
|
||||
createdAt: 0,
|
||||
expiresAt: 0,
|
||||
completedAt: null,
|
||||
};
|
||||
|
||||
public isExpired(nowArg = Date.now()) {
|
||||
return this.data.expiresAt < nowArg;
|
||||
}
|
||||
|
||||
public async markApproved(
|
||||
evidenceArg?: plugins.idpInterfaces.data.IPassportChallenge['data']['evidence']
|
||||
) {
|
||||
this.data.status = 'approved';
|
||||
this.data.completedAt = Date.now();
|
||||
this.data.evidence = evidenceArg;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
public async markExpired() {
|
||||
this.data.status = 'expired';
|
||||
await this.save();
|
||||
}
|
||||
|
||||
public async markRejected() {
|
||||
this.data.status = 'rejected';
|
||||
this.data.completedAt = Date.now();
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import type { PassportManager } from './classes.passportmanager.js';
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class PassportDevice extends plugins.smartdata.SmartDataDbDoc<
|
||||
PassportDevice,
|
||||
plugins.idpInterfaces.data.IPassportDevice,
|
||||
PassportManager
|
||||
> {
|
||||
@plugins.smartdata.unI()
|
||||
public id: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IPassportDevice['data'] = {
|
||||
userId: '',
|
||||
label: '',
|
||||
platform: 'unknown',
|
||||
status: 'active',
|
||||
publicKeyAlgorithm: 'p256',
|
||||
publicKeyX963Base64: '',
|
||||
capabilities: {
|
||||
gps: false,
|
||||
nfc: false,
|
||||
push: false,
|
||||
},
|
||||
pushRegistration: undefined,
|
||||
appVersion: undefined,
|
||||
createdAt: 0,
|
||||
lastSeenAt: undefined,
|
||||
lastChallengeAt: undefined,
|
||||
};
|
||||
|
||||
public isActive() {
|
||||
return this.data.status === 'active';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import { PassportChallenge } from './classes.passportchallenge.js';
|
||||
import { PassportDevice } from './classes.passportdevice.js';
|
||||
import { PassportNonce } from './classes.passportnonce.js';
|
||||
import { logger } from './logging.js';
|
||||
import { Reception } from './classes.reception.js';
|
||||
|
||||
export class PassportManager {
|
||||
private readonly enrollmentChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||
minutes: 10,
|
||||
});
|
||||
|
||||
private readonly assertionChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||
minutes: 5,
|
||||
});
|
||||
|
||||
private readonly deviceRequestWindowMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||
minutes: 5,
|
||||
});
|
||||
|
||||
public receptionRef: Reception;
|
||||
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public CPassportDevice = plugins.smartdata.setDefaultManagerForDoc(this, PassportDevice);
|
||||
public CPassportChallenge = plugins.smartdata.setDefaultManagerForDoc(this, PassportChallenge);
|
||||
public CPassportNonce = plugins.smartdata.setDefaultManagerForDoc(this, PassportNonce);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportEnrollmentChallenge>(
|
||||
'createPassportEnrollmentChallenge',
|
||||
async (requestArg) => {
|
||||
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||
const enrollmentChallenge = await this.createEnrollmentChallengeForUser(userId, {
|
||||
deviceLabel: requestArg.deviceLabel,
|
||||
platform: requestArg.platform,
|
||||
appVersion: requestArg.appVersion,
|
||||
capabilities: requestArg.capabilities,
|
||||
});
|
||||
|
||||
return {
|
||||
challengeId: enrollmentChallenge.challenge.id,
|
||||
pairingToken: enrollmentChallenge.pairingToken,
|
||||
pairingPayload: enrollmentChallenge.pairingPayload,
|
||||
signingPayload: enrollmentChallenge.signingPayload,
|
||||
expiresAt: enrollmentChallenge.challenge.data.expiresAt,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompletePassportEnrollment>(
|
||||
'completePassportEnrollment',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.completeEnrollment({
|
||||
pairingToken: requestArg.pairingToken,
|
||||
deviceLabel: requestArg.deviceLabel,
|
||||
platform: requestArg.platform,
|
||||
publicKeyX963Base64: requestArg.publicKeyX963Base64,
|
||||
signatureBase64: requestArg.signatureBase64,
|
||||
signatureFormat: requestArg.signatureFormat,
|
||||
appVersion: requestArg.appVersion,
|
||||
capabilities: requestArg.capabilities,
|
||||
});
|
||||
|
||||
return {
|
||||
device: {
|
||||
id: passportDevice.id,
|
||||
data: passportDevice.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDevices>(
|
||||
'getPassportDevices',
|
||||
async (requestArg) => {
|
||||
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||
const devices = await this.getPassportDevicesForUser(userId);
|
||||
return {
|
||||
devices: devices.map((deviceArg) => ({
|
||||
id: deviceArg.id,
|
||||
data: deviceArg.data,
|
||||
})),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokePassportDevice>(
|
||||
'revokePassportDevice',
|
||||
async (requestArg) => {
|
||||
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||
await this.revokePassportDeviceForUser(userId, requestArg.deviceId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportChallenge>(
|
||||
'createPassportChallenge',
|
||||
async (requestArg) => {
|
||||
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||
const challengeResult = await this.createPassportChallengeForUser(userId, {
|
||||
type: requestArg.type,
|
||||
preferredDeviceId: requestArg.preferredDeviceId,
|
||||
audience: requestArg.audience,
|
||||
notificationTitle: requestArg.notificationTitle,
|
||||
requireLocation: requestArg.requireLocation,
|
||||
requireNfc: requestArg.requireNfc,
|
||||
});
|
||||
|
||||
return {
|
||||
challengeId: challengeResult.challenge.id,
|
||||
challenge: challengeResult.challenge.data.challenge,
|
||||
signingPayload: challengeResult.signingPayload,
|
||||
deviceId: challengeResult.challenge.data.deviceId!,
|
||||
expiresAt: challengeResult.challenge.data.expiresAt,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDashboard>(
|
||||
'getPassportDashboard',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||
action: 'getPassportDashboard',
|
||||
});
|
||||
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: passportDevice.data.userId,
|
||||
});
|
||||
const organizations = user
|
||||
? await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user)
|
||||
: [];
|
||||
const devices = await this.getPassportDevicesForUser(passportDevice.data.userId);
|
||||
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
|
||||
const alerts = await this.receptionRef.alertManager.listAlertsForUser(passportDevice.data.userId);
|
||||
|
||||
return {
|
||||
profile: {
|
||||
userId: passportDevice.data.userId,
|
||||
name: user?.data?.name || user?.data?.email || 'Passport User',
|
||||
handle: user?.data?.username || user?.data?.email || passportDevice.data.userId,
|
||||
organizations: organizations.map((organizationArg) => ({
|
||||
id: organizationArg.id,
|
||||
name: organizationArg.data.name,
|
||||
})),
|
||||
deviceCount: devices.length,
|
||||
recoverySummary: 'Recovery workflows are not configured yet for this passport.',
|
||||
},
|
||||
devices: devices.map((deviceArg) => ({ id: deviceArg.id, data: deviceArg.data })),
|
||||
challenges: challenges.map((challengeArg) => ({
|
||||
challenge: { id: challengeArg.id, data: challengeArg.data },
|
||||
signingPayload: this.buildChallengeSigningPayload(challengeArg),
|
||||
})),
|
||||
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ApprovePassportChallenge>(
|
||||
'approvePassportChallenge',
|
||||
async (requestArg) => {
|
||||
const passportChallenge = await this.approvePassportChallenge({
|
||||
challengeId: requestArg.challengeId,
|
||||
deviceId: requestArg.deviceId,
|
||||
signatureBase64: requestArg.signatureBase64,
|
||||
signatureFormat: requestArg.signatureFormat,
|
||||
location: requestArg.location,
|
||||
nfc: requestArg.nfc,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
challenge: {
|
||||
id: passportChallenge.id,
|
||||
data: passportChallenge.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RejectPassportChallenge>(
|
||||
'rejectPassportChallenge',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||
action: 'rejectPassportChallenge',
|
||||
signedFields: [`challenge_id=${requestArg.challengeId}`],
|
||||
});
|
||||
const challenge = await this.rejectPassportChallenge(passportDevice.id, requestArg.challengeId);
|
||||
return {
|
||||
success: true,
|
||||
challenge: {
|
||||
id: challenge.id,
|
||||
data: challenge.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegisterPassportPushToken>(
|
||||
'registerPassportPushToken',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||
action: 'registerPassportPushToken',
|
||||
signedFields: [
|
||||
`provider=${requestArg.provider}`,
|
||||
`token=${requestArg.token}`,
|
||||
`topic=${requestArg.topic}`,
|
||||
`environment=${requestArg.environment}`,
|
||||
],
|
||||
});
|
||||
|
||||
passportDevice.data.pushRegistration = {
|
||||
provider: requestArg.provider,
|
||||
token: requestArg.token,
|
||||
topic: requestArg.topic,
|
||||
environment: requestArg.environment,
|
||||
registeredAt: Date.now(),
|
||||
lastDeliveredAt: passportDevice.data.pushRegistration?.lastDeliveredAt,
|
||||
lastError: undefined,
|
||||
};
|
||||
passportDevice.data.lastSeenAt = Date.now();
|
||||
await passportDevice.save();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPendingPassportChallenges>(
|
||||
'listPendingPassportChallenges',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||
action: 'listPendingPassportChallenges',
|
||||
});
|
||||
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
|
||||
return {
|
||||
challenges: challenges.map((challengeArg) => ({
|
||||
id: challengeArg.id,
|
||||
data: challengeArg.data,
|
||||
})),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportChallengeByHint>(
|
||||
'getPassportChallengeByHint',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||
action: 'getPassportChallengeByHint',
|
||||
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||
});
|
||||
const passportChallenge = await this.getPassportChallengeByHint(passportDevice.id, requestArg.hintId);
|
||||
|
||||
return {
|
||||
challenge: passportChallenge
|
||||
? {
|
||||
challenge: {
|
||||
id: passportChallenge.id,
|
||||
data: passportChallenge.data,
|
||||
},
|
||||
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportChallengeSeen>(
|
||||
'markPassportChallengeSeen',
|
||||
async (requestArg) => {
|
||||
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||
action: 'markPassportChallengeSeen',
|
||||
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||
});
|
||||
await this.markPassportChallengeSeen(passportDevice.id, requestArg.hintId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getAuthenticatedUserId(jwtArg: string) {
|
||||
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
|
||||
if (!jwt) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||
}
|
||||
|
||||
return jwt.data.userId;
|
||||
}
|
||||
|
||||
private getOriginHost() {
|
||||
return new URL(this.receptionRef.options.baseUrl).host;
|
||||
}
|
||||
|
||||
private createOpaqueToken(prefixArg: string) {
|
||||
return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||
}
|
||||
|
||||
private buildDeviceRequestSigningPayload(
|
||||
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
|
||||
actionArg: string,
|
||||
signedFieldsArg: string[] = []
|
||||
) {
|
||||
return [
|
||||
'purpose=passport-device-request',
|
||||
`origin=${this.getOriginHost()}`,
|
||||
`action=${actionArg}`,
|
||||
`device_id=${requestArg.deviceId}`,
|
||||
`timestamp=${requestArg.timestamp}`,
|
||||
`nonce=${requestArg.nonce}`,
|
||||
...signedFieldsArg,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private async consumePassportNonce(deviceIdArg: string, nonceArg: string, timestampArg: number) {
|
||||
const now = Date.now();
|
||||
if (Math.abs(now - timestampArg) > this.deviceRequestWindowMillis) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport device request timestamp expired');
|
||||
}
|
||||
|
||||
const existingNonce = await this.CPassportNonce.getInstance({
|
||||
id: PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`),
|
||||
});
|
||||
if (existingNonce && !existingNonce.isExpired(now)) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport device request replay detected');
|
||||
}
|
||||
|
||||
const passportNonce = existingNonce || new PassportNonce();
|
||||
passportNonce.id = PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`);
|
||||
passportNonce.data = {
|
||||
deviceId: deviceIdArg,
|
||||
nonceHash: PassportNonce.hashNonce(nonceArg),
|
||||
createdAt: now,
|
||||
expiresAt: now + this.deviceRequestWindowMillis,
|
||||
};
|
||||
await passportNonce.save();
|
||||
}
|
||||
|
||||
public async authenticatePassportDeviceRequest(
|
||||
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
|
||||
optionsArg: {
|
||||
action: string;
|
||||
signedFields?: string[];
|
||||
}
|
||||
) {
|
||||
const passportDevice = await this.CPassportDevice.getInstance({
|
||||
id: requestArg.deviceId,
|
||||
'data.status': 'active',
|
||||
});
|
||||
if (!passportDevice) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||
}
|
||||
|
||||
const verified = this.verifyPassportSignature(
|
||||
passportDevice.data.publicKeyX963Base64,
|
||||
requestArg.signatureBase64,
|
||||
requestArg.signatureFormat || 'raw',
|
||||
this.buildDeviceRequestSigningPayload(
|
||||
requestArg,
|
||||
optionsArg.action,
|
||||
optionsArg.signedFields || []
|
||||
)
|
||||
);
|
||||
if (!verified) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport device signature invalid');
|
||||
}
|
||||
|
||||
await this.consumePassportNonce(requestArg.deviceId, requestArg.nonce, requestArg.timestamp);
|
||||
passportDevice.data.lastSeenAt = Date.now();
|
||||
await passportDevice.save();
|
||||
return passportDevice;
|
||||
}
|
||||
|
||||
private normalizeCapabilities(
|
||||
capabilitiesArg?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>
|
||||
): plugins.idpInterfaces.data.IPassportCapabilities {
|
||||
return {
|
||||
gps: !!capabilitiesArg?.gps,
|
||||
nfc: !!capabilitiesArg?.nfc,
|
||||
push: !!capabilitiesArg?.push,
|
||||
};
|
||||
}
|
||||
|
||||
private buildEnrollmentSigningPayload(pairingTokenArg: string, challengeArg: PassportChallenge) {
|
||||
return [
|
||||
'purpose=passport-enrollment',
|
||||
`origin=${this.getOriginHost()}`,
|
||||
`token=${pairingTokenArg}`,
|
||||
`challenge=${challengeArg.data.challenge}`,
|
||||
`challenge_id=${challengeArg.id}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private buildChallengeSigningPayload(challengeArg: PassportChallenge) {
|
||||
return [
|
||||
'purpose=passport-challenge',
|
||||
`origin=${this.getOriginHost()}`,
|
||||
`challenge=${challengeArg.data.challenge}`,
|
||||
`challenge_id=${challengeArg.id}`,
|
||||
`type=${challengeArg.data.type}`,
|
||||
`device_id=${challengeArg.data.deviceId || ''}`,
|
||||
`audience=${challengeArg.data.metadata.audience || ''}`,
|
||||
`require_location=${challengeArg.data.metadata.requireLocation}`,
|
||||
`require_nfc=${challengeArg.data.metadata.requireNfc}`,
|
||||
`location_policy=${challengeArg.data.metadata.locationPolicy ? JSON.stringify(challengeArg.data.metadata.locationPolicy) : ''}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private evaluateLocationPolicy(
|
||||
locationPolicyArg: plugins.idpInterfaces.data.IPassportLocationPolicy,
|
||||
locationEvidenceArg: plugins.idpInterfaces.data.IPassportLocationEvidence
|
||||
) {
|
||||
const earthRadiusMeters = 6371000;
|
||||
const latitude1 = (locationPolicyArg.latitude * Math.PI) / 180;
|
||||
const latitude2 = (locationEvidenceArg.latitude * Math.PI) / 180;
|
||||
const deltaLatitude = ((locationEvidenceArg.latitude - locationPolicyArg.latitude) * Math.PI) / 180;
|
||||
const deltaLongitude = ((locationEvidenceArg.longitude - locationPolicyArg.longitude) * Math.PI) / 180;
|
||||
|
||||
const haversine =
|
||||
Math.sin(deltaLatitude / 2) * Math.sin(deltaLatitude / 2) +
|
||||
Math.cos(latitude1) * Math.cos(latitude2) * Math.sin(deltaLongitude / 2) * Math.sin(deltaLongitude / 2);
|
||||
const distanceMeters = 2 * earthRadiusMeters * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
|
||||
|
||||
const accuracyAccepted =
|
||||
!locationPolicyArg.maxAccuracyMeters ||
|
||||
locationEvidenceArg.accuracyMeters <= locationPolicyArg.maxAccuracyMeters;
|
||||
const withinGeofence = distanceMeters <= locationPolicyArg.radiusMeters;
|
||||
|
||||
return {
|
||||
matched: accuracyAccepted && withinGeofence,
|
||||
distanceMeters,
|
||||
accuracyAccepted,
|
||||
evaluatedAt: Date.now(),
|
||||
reason: !accuracyAccepted
|
||||
? `Accuracy ${locationEvidenceArg.accuracyMeters}m exceeds allowed ${locationPolicyArg.maxAccuracyMeters}m`
|
||||
: !withinGeofence
|
||||
? `Location is ${Math.round(distanceMeters)}m away from ${locationPolicyArg.label || 'required area'}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private createPairingPayload(
|
||||
pairingTokenArg: string,
|
||||
challengeArg: PassportChallenge,
|
||||
deviceLabelArg: string
|
||||
) {
|
||||
const searchParams = new URLSearchParams({
|
||||
token: pairingTokenArg,
|
||||
challenge: challengeArg.data.challenge,
|
||||
challenge_id: challengeArg.id,
|
||||
origin: this.getOriginHost(),
|
||||
device: deviceLabelArg,
|
||||
});
|
||||
return `idp.global://pair?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
private createP256JwkFromX963(publicKeyX963Base64Arg: string) {
|
||||
const rawPublicKey = Buffer.from(publicKeyX963Base64Arg, 'base64');
|
||||
if (rawPublicKey.length !== 65 || rawPublicKey[0] !== 4) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Invalid passport public key');
|
||||
}
|
||||
|
||||
return {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
x: rawPublicKey.subarray(1, 33).toString('base64url'),
|
||||
y: rawPublicKey.subarray(33, 65).toString('base64url'),
|
||||
ext: true,
|
||||
} as JsonWebKey;
|
||||
}
|
||||
|
||||
private verifyPassportSignature(
|
||||
publicKeyX963Base64Arg: string,
|
||||
signatureBase64Arg: string,
|
||||
signatureFormatArg: plugins.idpInterfaces.data.TPassportSignatureFormat,
|
||||
payloadArg: string
|
||||
) {
|
||||
const publicKey = plugins.crypto.createPublicKey({
|
||||
key: this.createP256JwkFromX963(publicKeyX963Base64Arg),
|
||||
format: 'jwk',
|
||||
});
|
||||
|
||||
const signature = Buffer.from(signatureBase64Arg, 'base64');
|
||||
const payload = Buffer.from(payloadArg, 'utf8');
|
||||
|
||||
return signatureFormatArg === 'raw'
|
||||
? plugins.crypto.verify('sha256', payload, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature)
|
||||
: plugins.crypto.verify('sha256', payload, publicKey, signature);
|
||||
}
|
||||
|
||||
public async createEnrollmentChallengeForUser(
|
||||
userIdArg: string,
|
||||
optionsArg: {
|
||||
deviceLabel: string;
|
||||
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
|
||||
appVersion?: string;
|
||||
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
|
||||
}
|
||||
) {
|
||||
const pairingToken = this.createOpaqueToken('passport_pair_');
|
||||
const passportChallenge = new PassportChallenge();
|
||||
passportChallenge.id = plugins.smartunique.shortId();
|
||||
passportChallenge.data = {
|
||||
userId: userIdArg,
|
||||
deviceId: null,
|
||||
type: 'device_enrollment',
|
||||
status: 'pending',
|
||||
tokenHash: PassportChallenge.hashToken(pairingToken),
|
||||
challenge: this.createOpaqueToken('challenge_'),
|
||||
metadata: {
|
||||
originHost: this.getOriginHost(),
|
||||
deviceLabel: optionsArg.deviceLabel,
|
||||
requireLocation: false,
|
||||
requireNfc: false,
|
||||
locationPolicy: undefined,
|
||||
requestedCapabilities: this.normalizeCapabilities(optionsArg.capabilities),
|
||||
},
|
||||
evidence: undefined,
|
||||
notification: undefined,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + this.enrollmentChallengeMillis,
|
||||
completedAt: null,
|
||||
};
|
||||
await passportChallenge.save();
|
||||
|
||||
return {
|
||||
challenge: passportChallenge,
|
||||
pairingToken,
|
||||
pairingPayload: this.createPairingPayload(
|
||||
pairingToken,
|
||||
passportChallenge,
|
||||
optionsArg.deviceLabel
|
||||
),
|
||||
signingPayload: this.buildEnrollmentSigningPayload(pairingToken, passportChallenge),
|
||||
};
|
||||
}
|
||||
|
||||
public async completeEnrollment(optionsArg: {
|
||||
pairingToken: string;
|
||||
deviceLabel: string;
|
||||
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
|
||||
publicKeyX963Base64: string;
|
||||
signatureBase64: string;
|
||||
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
|
||||
appVersion?: string;
|
||||
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
|
||||
}) {
|
||||
const passportChallenge = await this.CPassportChallenge.getInstance({
|
||||
'data.tokenHash': PassportChallenge.hashToken(optionsArg.pairingToken),
|
||||
'data.type': 'device_enrollment',
|
||||
'data.status': 'pending',
|
||||
});
|
||||
|
||||
if (!passportChallenge) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Pairing token not found');
|
||||
}
|
||||
|
||||
if (passportChallenge.isExpired()) {
|
||||
await passportChallenge.markExpired();
|
||||
throw new plugins.typedrequest.TypedResponseError('Pairing token expired');
|
||||
}
|
||||
|
||||
const existingPassportDevice = await this.CPassportDevice.getInstance({
|
||||
'data.publicKeyX963Base64': optionsArg.publicKeyX963Base64,
|
||||
'data.status': 'active',
|
||||
});
|
||||
if (existingPassportDevice) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport device already enrolled');
|
||||
}
|
||||
|
||||
const verified = this.verifyPassportSignature(
|
||||
optionsArg.publicKeyX963Base64,
|
||||
optionsArg.signatureBase64,
|
||||
optionsArg.signatureFormat || 'raw',
|
||||
this.buildEnrollmentSigningPayload(optionsArg.pairingToken, passportChallenge)
|
||||
);
|
||||
|
||||
if (!verified) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
|
||||
}
|
||||
|
||||
const passportDevice = new PassportDevice();
|
||||
passportDevice.id = plugins.smartunique.shortId();
|
||||
passportDevice.data = {
|
||||
userId: passportChallenge.data.userId,
|
||||
label: optionsArg.deviceLabel,
|
||||
platform: optionsArg.platform,
|
||||
status: 'active',
|
||||
publicKeyAlgorithm: 'p256',
|
||||
publicKeyX963Base64: optionsArg.publicKeyX963Base64,
|
||||
capabilities: this.normalizeCapabilities(
|
||||
optionsArg.capabilities || passportChallenge.data.metadata.requestedCapabilities
|
||||
),
|
||||
pushRegistration: undefined,
|
||||
appVersion: optionsArg.appVersion,
|
||||
createdAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
lastChallengeAt: undefined,
|
||||
};
|
||||
await passportDevice.save();
|
||||
|
||||
passportChallenge.data.deviceId = passportDevice.id;
|
||||
passportChallenge.data.tokenHash = null;
|
||||
await passportChallenge.markApproved({
|
||||
signatureFormat: optionsArg.signatureFormat || 'raw',
|
||||
});
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
passportChallenge.data.userId,
|
||||
'passport_device_enrolled',
|
||||
`Enrolled passport device ${passportDevice.data.label}`,
|
||||
{
|
||||
targetId: passportDevice.id,
|
||||
targetType: 'passport-device',
|
||||
}
|
||||
);
|
||||
|
||||
return passportDevice;
|
||||
}
|
||||
|
||||
public async getPassportDevicesForUser(userIdArg: string) {
|
||||
const devices = await this.CPassportDevice.getInstances({
|
||||
'data.userId': userIdArg,
|
||||
'data.status': 'active',
|
||||
});
|
||||
|
||||
return devices.sort(
|
||||
(leftArg, rightArg) =>
|
||||
(rightArg.data.lastSeenAt || rightArg.data.createdAt) -
|
||||
(leftArg.data.lastSeenAt || leftArg.data.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
public async revokePassportDeviceForUser(userIdArg: string, deviceIdArg: string) {
|
||||
const passportDevice = await this.CPassportDevice.getInstance({
|
||||
id: deviceIdArg,
|
||||
'data.userId': userIdArg,
|
||||
'data.status': 'active',
|
||||
});
|
||||
|
||||
if (!passportDevice) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||
}
|
||||
|
||||
passportDevice.data.status = 'revoked';
|
||||
await passportDevice.save();
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
userIdArg,
|
||||
'passport_device_revoked',
|
||||
`Revoked passport device ${passportDevice.data.label}`,
|
||||
{
|
||||
targetId: passportDevice.id,
|
||||
targetType: 'passport-device',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async createPassportChallengeForUser(
|
||||
userIdArg: string,
|
||||
optionsArg: {
|
||||
type?: Exclude<plugins.idpInterfaces.data.TPassportChallengeType, 'device_enrollment'>;
|
||||
preferredDeviceId?: string;
|
||||
audience?: string;
|
||||
notificationTitle?: string;
|
||||
requireLocation?: boolean;
|
||||
requireNfc?: boolean;
|
||||
locationPolicy?: plugins.idpInterfaces.data.IPassportLocationPolicy;
|
||||
}
|
||||
) {
|
||||
const passportDevices = await this.getPassportDevicesForUser(userIdArg);
|
||||
if (passportDevices.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No passport device enrolled');
|
||||
}
|
||||
|
||||
const targetDevice = optionsArg.preferredDeviceId
|
||||
? passportDevices.find((deviceArg) => deviceArg.id === optionsArg.preferredDeviceId)
|
||||
: passportDevices[0];
|
||||
|
||||
if (!targetDevice) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Target passport device not found');
|
||||
}
|
||||
|
||||
const passportChallenge = new PassportChallenge();
|
||||
passportChallenge.id = plugins.smartunique.shortId();
|
||||
passportChallenge.data = {
|
||||
userId: userIdArg,
|
||||
deviceId: targetDevice.id,
|
||||
type: optionsArg.type || 'step_up',
|
||||
status: 'pending',
|
||||
tokenHash: null,
|
||||
challenge: this.createOpaqueToken('passport_challenge_'),
|
||||
metadata: {
|
||||
originHost: this.getOriginHost(),
|
||||
audience: optionsArg.audience,
|
||||
notificationTitle: optionsArg.notificationTitle,
|
||||
deviceLabel: targetDevice.data.label,
|
||||
requireLocation: !!optionsArg.requireLocation || !!optionsArg.locationPolicy,
|
||||
requireNfc: !!optionsArg.requireNfc,
|
||||
locationPolicy: optionsArg.locationPolicy,
|
||||
},
|
||||
evidence: undefined,
|
||||
notification: {
|
||||
hintId: plugins.crypto.randomUUID(),
|
||||
status: 'pending',
|
||||
attemptCount: 0,
|
||||
createdAt: Date.now(),
|
||||
deliveredAt: null,
|
||||
seenAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + this.assertionChallengeMillis,
|
||||
completedAt: null,
|
||||
};
|
||||
await passportChallenge.save();
|
||||
|
||||
targetDevice.data.lastChallengeAt = Date.now();
|
||||
await targetDevice.save();
|
||||
|
||||
await this.receptionRef.passportPushManager.deliverChallengeHint(targetDevice, passportChallenge);
|
||||
|
||||
return {
|
||||
challenge: passportChallenge,
|
||||
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
|
||||
};
|
||||
}
|
||||
|
||||
public async approvePassportChallenge(optionsArg: {
|
||||
challengeId: string;
|
||||
deviceId: string;
|
||||
signatureBase64: string;
|
||||
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
|
||||
location?: plugins.idpInterfaces.data.IPassportLocationEvidence;
|
||||
nfc?: plugins.idpInterfaces.data.IPassportNfcEvidence;
|
||||
}) {
|
||||
const passportChallenge = await this.CPassportChallenge.getInstance({
|
||||
id: optionsArg.challengeId,
|
||||
'data.status': 'pending',
|
||||
});
|
||||
if (!passportChallenge) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
|
||||
}
|
||||
|
||||
if (passportChallenge.isExpired()) {
|
||||
await passportChallenge.markExpired();
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
|
||||
}
|
||||
|
||||
if (passportChallenge.data.deviceId && passportChallenge.data.deviceId !== optionsArg.deviceId) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport challenge not assigned to this device');
|
||||
}
|
||||
|
||||
const passportDevice = await this.CPassportDevice.getInstance({
|
||||
id: optionsArg.deviceId,
|
||||
'data.status': 'active',
|
||||
});
|
||||
if (!passportDevice) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||
}
|
||||
|
||||
if (passportDevice.data.userId !== passportChallenge.data.userId) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport device user mismatch');
|
||||
}
|
||||
|
||||
if (passportChallenge.data.metadata.requireLocation && !optionsArg.location) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Location evidence required');
|
||||
}
|
||||
|
||||
if (passportChallenge.data.metadata.requireNfc && !optionsArg.nfc) {
|
||||
throw new plugins.typedrequest.TypedResponseError('NFC evidence required');
|
||||
}
|
||||
|
||||
const verified = this.verifyPassportSignature(
|
||||
passportDevice.data.publicKeyX963Base64,
|
||||
optionsArg.signatureBase64,
|
||||
optionsArg.signatureFormat || 'raw',
|
||||
this.buildChallengeSigningPayload(passportChallenge)
|
||||
);
|
||||
if (!verified) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
|
||||
}
|
||||
|
||||
const locationEvaluation =
|
||||
passportChallenge.data.metadata.locationPolicy && optionsArg.location
|
||||
? this.evaluateLocationPolicy(passportChallenge.data.metadata.locationPolicy, optionsArg.location)
|
||||
: undefined;
|
||||
|
||||
if (passportChallenge.data.metadata.locationPolicy && !locationEvaluation?.matched) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
locationEvaluation?.reason || 'Location evidence did not satisfy the office policy'
|
||||
);
|
||||
}
|
||||
|
||||
await passportChallenge.markApproved({
|
||||
signatureFormat: optionsArg.signatureFormat || 'raw',
|
||||
location: optionsArg.location,
|
||||
locationEvaluation,
|
||||
nfc: optionsArg.nfc,
|
||||
});
|
||||
|
||||
passportDevice.data.lastSeenAt = Date.now();
|
||||
await passportDevice.save();
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
passportChallenge.data.userId,
|
||||
'passport_challenge_approved',
|
||||
`Approved passport challenge ${passportChallenge.data.type}`,
|
||||
{
|
||||
targetId: passportChallenge.id,
|
||||
targetType: 'passport-challenge',
|
||||
}
|
||||
);
|
||||
|
||||
return passportChallenge;
|
||||
}
|
||||
|
||||
public async rejectPassportChallenge(deviceIdArg: string, challengeIdArg: string) {
|
||||
const passportChallenge = await this.CPassportChallenge.getInstance({
|
||||
id: challengeIdArg,
|
||||
'data.deviceId': deviceIdArg,
|
||||
'data.status': 'pending',
|
||||
});
|
||||
if (!passportChallenge) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
|
||||
}
|
||||
|
||||
if (passportChallenge.isExpired()) {
|
||||
await passportChallenge.markExpired();
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
|
||||
}
|
||||
|
||||
await passportChallenge.markRejected();
|
||||
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
passportChallenge.data.userId,
|
||||
'passport_challenge_rejected',
|
||||
`Rejected passport challenge ${passportChallenge.data.type}`,
|
||||
{
|
||||
targetId: passportChallenge.id,
|
||||
targetType: 'passport-challenge',
|
||||
}
|
||||
);
|
||||
|
||||
return passportChallenge;
|
||||
}
|
||||
|
||||
public async listPendingChallengesForDevice(deviceIdArg: string) {
|
||||
const passportChallenges = await this.CPassportChallenge.getInstances({
|
||||
'data.deviceId': deviceIdArg,
|
||||
'data.status': 'pending',
|
||||
});
|
||||
return passportChallenges.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
|
||||
}
|
||||
|
||||
public async getPassportChallengeByHint(deviceIdArg: string, hintIdArg: string) {
|
||||
return this.CPassportChallenge.getInstance({
|
||||
'data.deviceId': deviceIdArg,
|
||||
'data.status': 'pending',
|
||||
'data.notification.hintId': hintIdArg,
|
||||
});
|
||||
}
|
||||
|
||||
public async markPassportChallengeSeen(deviceIdArg: string, hintIdArg: string) {
|
||||
const passportChallenge = await this.getPassportChallengeByHint(deviceIdArg, hintIdArg);
|
||||
if (!passportChallenge) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
|
||||
}
|
||||
|
||||
passportChallenge.data.notification = {
|
||||
...passportChallenge.data.notification!,
|
||||
status: 'seen',
|
||||
seenAt: Date.now(),
|
||||
};
|
||||
await passportChallenge.save();
|
||||
return passportChallenge;
|
||||
}
|
||||
|
||||
public async cleanupExpiredChallenges() {
|
||||
const passportChallenges = await this.CPassportChallenge.getInstances({});
|
||||
for (const passportChallenge of passportChallenges) {
|
||||
if (passportChallenge.data.status === 'pending' && passportChallenge.isExpired()) {
|
||||
await passportChallenge.markExpired();
|
||||
}
|
||||
}
|
||||
|
||||
const passportNonces = await this.CPassportNonce.getInstances({});
|
||||
for (const passportNonce of passportNonces) {
|
||||
if (passportNonce.isExpired()) {
|
||||
await passportNonce.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async reDeliverPendingChallengeHints() {
|
||||
const passportChallenges = await this.CPassportChallenge.getInstances({
|
||||
'data.status': 'pending',
|
||||
});
|
||||
for (const passportChallenge of passportChallenges) {
|
||||
if (!passportChallenge.data.notification || passportChallenge.data.notification.status === 'sent') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!passportChallenge.data.deviceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const passportDevice = await this.CPassportDevice.getInstance({
|
||||
id: passportChallenge.data.deviceId,
|
||||
'data.status': 'active',
|
||||
});
|
||||
if (!passportDevice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.receptionRef.passportPushManager.deliverChallengeHint(passportDevice, passportChallenge);
|
||||
} catch (errorArg) {
|
||||
logger.log('warn', `passport hint redelivery failed: ${(errorArg as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import type { PassportManager } from './classes.passportmanager.js';
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class PassportNonce extends plugins.smartdata.SmartDataDbDoc<
|
||||
PassportNonce,
|
||||
plugins.idpInterfaces.data.IPassportNonce,
|
||||
PassportManager
|
||||
> {
|
||||
public static hashNonce(nonceArg: string) {
|
||||
return plugins.smarthash.sha256FromStringSync(nonceArg);
|
||||
}
|
||||
|
||||
@plugins.smartdata.unI()
|
||||
public id: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IPassportNonce['data'] = {
|
||||
deviceId: '',
|
||||
nonceHash: '',
|
||||
createdAt: 0,
|
||||
expiresAt: 0,
|
||||
};
|
||||
|
||||
public isExpired(nowArg = Date.now()) {
|
||||
return this.data.expiresAt < nowArg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import { Alert } from './classes.alert.js';
|
||||
import { logger } from './logging.js';
|
||||
import { PassportChallenge } from './classes.passportchallenge.js';
|
||||
import { PassportDevice } from './classes.passportdevice.js';
|
||||
import type { Reception } from './classes.reception.js';
|
||||
|
||||
interface IApnsConfig {
|
||||
keyId: string;
|
||||
teamId: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export class PassportPushManager {
|
||||
public receptionRef: Reception;
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
}
|
||||
|
||||
private async getApnsConfig(): Promise<IApnsConfig | null> {
|
||||
try {
|
||||
return {
|
||||
keyId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_KEY_ID'),
|
||||
teamId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_TEAM_ID'),
|
||||
privateKey: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_PRIVATE_KEY'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private base64UrlEncode(valueArg: string | Buffer) {
|
||||
return Buffer.from(valueArg).toString('base64url');
|
||||
}
|
||||
|
||||
private createApnsJwt(configArg: IApnsConfig) {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const header = this.base64UrlEncode(
|
||||
JSON.stringify({ alg: 'ES256', kid: configArg.keyId, typ: 'JWT' })
|
||||
);
|
||||
const payload = this.base64UrlEncode(JSON.stringify({ iss: configArg.teamId, iat: nowSeconds }));
|
||||
const unsignedToken = `${header}.${payload}`;
|
||||
const signature = plugins.crypto.sign('sha256', Buffer.from(unsignedToken, 'utf8'), {
|
||||
key: configArg.privateKey.replace(/\\n/g, '\n'),
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
return `${unsignedToken}.${this.base64UrlEncode(signature)}`;
|
||||
}
|
||||
|
||||
private async deliverApnsPayload(
|
||||
passportDeviceArg: PassportDevice,
|
||||
payloadArg: Record<string, any>
|
||||
) {
|
||||
if (!passportDeviceArg.data.pushRegistration) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
text: async () => 'Passport device has no push registration',
|
||||
};
|
||||
}
|
||||
|
||||
const apnsConfig = await this.getApnsConfig();
|
||||
if (!apnsConfig) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
text: async () => 'APNs push transport is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
const pushRegistration = passportDeviceArg.data.pushRegistration;
|
||||
const apnsHost =
|
||||
pushRegistration.environment === 'production'
|
||||
? 'https://api.push.apple.com'
|
||||
: 'https://api.sandbox.push.apple.com';
|
||||
const authorizationToken = this.createApnsJwt(apnsConfig);
|
||||
return fetch(`${apnsHost}/3/device/${pushRegistration.token}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `bearer ${authorizationToken}`,
|
||||
'apns-topic': pushRegistration.topic,
|
||||
'apns-push-type': 'alert',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payloadArg),
|
||||
}).catch((errorArg: Error) => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
text: async () => errorArg.message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async deliverChallengeHint(passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) {
|
||||
if (!passportDeviceArg.data.pushRegistration) {
|
||||
passportChallengeArg.data.notification = {
|
||||
...passportChallengeArg.data.notification,
|
||||
status: 'failed',
|
||||
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||
lastError: 'Passport device has no push registration',
|
||||
};
|
||||
await passportChallengeArg.save();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await this.getApnsConfig())) {
|
||||
passportChallengeArg.data.notification = {
|
||||
...passportChallengeArg.data.notification,
|
||||
status: 'failed',
|
||||
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||
lastError: 'APNs push transport is not configured',
|
||||
};
|
||||
await passportChallengeArg.save();
|
||||
logger.log('warn', 'passport push delivery skipped because APNs is not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await this.deliverApnsPayload(passportDeviceArg, {
|
||||
aps: {
|
||||
alert: {
|
||||
title: passportChallengeArg.data.metadata.notificationTitle || 'idp.global challenge',
|
||||
body: `Open idp.global to review your ${passportChallengeArg.data.type} request.`,
|
||||
},
|
||||
sound: 'default',
|
||||
},
|
||||
kind: 'passport_challenge',
|
||||
hintId: passportChallengeArg.data.notification?.hintId,
|
||||
challengeId: passportChallengeArg.id,
|
||||
severity:
|
||||
passportChallengeArg.data.type === 'physical_access' ? 'high' : passportChallengeArg.data.type,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
if (response.ok) {
|
||||
passportDeviceArg.data.pushRegistration.lastDeliveredAt = Date.now();
|
||||
passportDeviceArg.data.pushRegistration.lastError = undefined;
|
||||
passportChallengeArg.data.notification = {
|
||||
...passportChallengeArg.data.notification,
|
||||
status: 'sent',
|
||||
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||
deliveredAt: Date.now(),
|
||||
lastError: null,
|
||||
};
|
||||
await passportDeviceArg.save();
|
||||
await passportChallengeArg.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
passportDeviceArg.data.pushRegistration.lastError = responseText || `APNs error ${response.status}`;
|
||||
passportChallengeArg.data.notification = {
|
||||
...passportChallengeArg.data.notification,
|
||||
status: 'failed',
|
||||
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||
lastError: responseText || `APNs error ${response.status}`,
|
||||
};
|
||||
await passportDeviceArg.save();
|
||||
await passportChallengeArg.save();
|
||||
logger.log('warn', `passport push delivery failed: ${responseText || response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
public async deliverAlertHint(passportDeviceArg: PassportDevice, alertArg: Alert) {
|
||||
if (!passportDeviceArg.data.pushRegistration) {
|
||||
alertArg.data.notification = {
|
||||
...alertArg.data.notification,
|
||||
status: 'failed',
|
||||
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||
lastError: 'Passport device has no push registration',
|
||||
};
|
||||
await alertArg.save();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await this.getApnsConfig())) {
|
||||
alertArg.data.notification = {
|
||||
...alertArg.data.notification,
|
||||
status: 'failed',
|
||||
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||
lastError: 'APNs push transport is not configured',
|
||||
};
|
||||
await alertArg.save();
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await this.deliverApnsPayload(passportDeviceArg, {
|
||||
aps: {
|
||||
alert: {
|
||||
title: alertArg.data.title,
|
||||
body: alertArg.data.body,
|
||||
},
|
||||
sound: 'default',
|
||||
},
|
||||
kind: 'passport_alert',
|
||||
hintId: alertArg.data.notification.hintId,
|
||||
alertId: alertArg.id,
|
||||
severity: alertArg.data.severity,
|
||||
eventType: alertArg.data.eventType,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
if (response.ok) {
|
||||
passportDeviceArg.data.pushRegistration.lastDeliveredAt = Date.now();
|
||||
passportDeviceArg.data.pushRegistration.lastError = undefined;
|
||||
alertArg.data.notification = {
|
||||
...alertArg.data.notification,
|
||||
status: 'sent',
|
||||
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||
deliveredAt: Date.now(),
|
||||
lastError: null,
|
||||
};
|
||||
await passportDeviceArg.save();
|
||||
await alertArg.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
passportDeviceArg.data.pushRegistration.lastError = responseText || `APNs error ${response.status}`;
|
||||
alertArg.data.notification = {
|
||||
...alertArg.data.notification,
|
||||
status: 'failed',
|
||||
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||
lastError: responseText || `APNs error ${response.status}`,
|
||||
};
|
||||
await passportDeviceArg.save();
|
||||
await alertArg.save();
|
||||
logger.log('warn', `passport alert push delivery failed: ${responseText || response.status}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
||||
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
||||
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
||||
import { OidcManager } from './classes.oidcmanager.js';
|
||||
import { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
|
||||
import { AlertManager } from './classes.alertmanager.js';
|
||||
import { PassportManager } from './classes.passportmanager.js';
|
||||
import { PassportPushManager } from './classes.passportpushmanager.js';
|
||||
|
||||
export interface IReceptionOptions {
|
||||
/**
|
||||
@@ -47,7 +51,11 @@ export class Reception {
|
||||
public appManager = new AppManager(this);
|
||||
public appConnectionManager = new AppConnectionManager(this);
|
||||
public activityLogManager = new ActivityLogManager(this);
|
||||
public alertManager = new AlertManager(this);
|
||||
public userInvitationManager = new UserInvitationManager(this);
|
||||
public abuseProtectionManager = new AbuseProtectionManager(this);
|
||||
public passportPushManager = new PassportPushManager(this);
|
||||
public passportManager = new PassportManager(this);
|
||||
public oidcManager = new OidcManager(this);
|
||||
housekeeping = new ReceptionHousekeeping(this);
|
||||
|
||||
@@ -64,13 +72,15 @@ export class Reception {
|
||||
* starts the reception instance
|
||||
*/
|
||||
public async start() {
|
||||
await this.szPlatformClient.init(await this.serviceQenv.getEnvVarOnDemand('SERVEZONE_PLATFROM_AUTHORIZATION'));
|
||||
const serveZoneAuthorization = await this.serviceQenv.getEnvVarOnDemand('SERVEZONE_PLATFORM_AUTHORIZATION');
|
||||
await this.szPlatformClient.init(serveZoneAuthorization || 'test');
|
||||
logger.log('info', 'starting reception');
|
||||
logger.log('info', 'adding typedrouter to website server');
|
||||
this.options.websiteServer.typedrouter.addTypedRouter(this.typedrouter);
|
||||
logger.log('info', 'starting database');
|
||||
await this.db.start();
|
||||
await this.jwtManager.start();
|
||||
await this.housekeeping.start();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,36 @@ export class UserInvitationManager {
|
||||
|
||||
public CUserInvitation = plugins.smartdata.setDefaultManagerForDoc(this, UserInvitation);
|
||||
|
||||
private async emitOrganizationAlert(optionsArg: {
|
||||
organizationId: string;
|
||||
eventType: string;
|
||||
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
actorUserId: string;
|
||||
relatedEntityId?: string;
|
||||
relatedEntityType?: string;
|
||||
}) {
|
||||
await this.receptionRef.alertManager.createAlertsForEvent({
|
||||
category: 'admin',
|
||||
organizationId: optionsArg.organizationId,
|
||||
eventType: optionsArg.eventType,
|
||||
severity: optionsArg.severity,
|
||||
title: optionsArg.title,
|
||||
body: optionsArg.body,
|
||||
actorUserId: optionsArg.actorUserId,
|
||||
relatedEntityId: optionsArg.relatedEntityId,
|
||||
relatedEntityType: optionsArg.relatedEntityType,
|
||||
});
|
||||
}
|
||||
|
||||
private async getOrganizationName(organizationIdArg: string) {
|
||||
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: organizationIdArg,
|
||||
});
|
||||
return organization?.data.name || 'this organization';
|
||||
}
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
@@ -29,6 +59,10 @@ export class UserInvitationManager {
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
const roles = await this.receptionRef.organizationmanager.assertRoleKeysAreValid(
|
||||
requestArg.organizationId,
|
||||
requestArg.roles
|
||||
);
|
||||
|
||||
const email = requestArg.email.toLowerCase().trim();
|
||||
|
||||
@@ -56,7 +90,7 @@ export class UserInvitationManager {
|
||||
action: 'create',
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
roles: requestArg.roles,
|
||||
roles,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
@@ -73,23 +107,36 @@ export class UserInvitationManager {
|
||||
let isNew = false;
|
||||
if (invitation) {
|
||||
// Add org to existing invitation
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, requestArg.roles);
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, roles);
|
||||
} else {
|
||||
// Create new invitation
|
||||
invitation = await UserInvitation.createNewInvitation(
|
||||
email,
|
||||
requestArg.organizationId,
|
||||
user.id,
|
||||
requestArg.roles
|
||||
roles
|
||||
);
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||
// Send invitation email
|
||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: 'org_invitation_created',
|
||||
severity: 'low',
|
||||
title: 'Organization invitation created',
|
||||
body: `${user.data.email} invited ${email} to ${await this.getOrganizationName(
|
||||
requestArg.organizationId
|
||||
)}.`,
|
||||
actorUserId: user.id,
|
||||
relatedEntityId: invitation.id,
|
||||
relatedEntityType: 'invitation',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invitation: await invitation.createSavableObject(),
|
||||
isNew,
|
||||
};
|
||||
@@ -189,6 +236,17 @@ export class UserInvitationManager {
|
||||
await invitation.regenerateToken();
|
||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: 'org_invitation_resent',
|
||||
severity: 'low',
|
||||
title: 'Organization invitation resent',
|
||||
body: `${user.data.email} resent an invitation to ${invitation.data.email}.`,
|
||||
actorUserId: user.id,
|
||||
relatedEntityId: invitation.id,
|
||||
relatedEntityType: 'invitation',
|
||||
});
|
||||
|
||||
return { success: true, message: 'Invitation resent.' };
|
||||
}
|
||||
)
|
||||
@@ -231,10 +289,12 @@ export class UserInvitationManager {
|
||||
|
||||
await role.delete();
|
||||
|
||||
// Remove org from user's connectedOrgs
|
||||
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
const removedUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: requestArg.userId,
|
||||
});
|
||||
|
||||
// Remove org from user's connectedOrgs
|
||||
const memberUser = removedUser;
|
||||
if (memberUser && memberUser.data.connectedOrgs) {
|
||||
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
||||
orgId => orgId !== requestArg.organizationId
|
||||
@@ -242,6 +302,19 @@ export class UserInvitationManager {
|
||||
await memberUser.save();
|
||||
}
|
||||
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: 'org_member_removed',
|
||||
severity: 'high',
|
||||
title: 'Organization member removed',
|
||||
body: `${user.data.email} removed ${removedUser?.data?.email || requestArg.userId} from ${await this.getOrganizationName(
|
||||
requestArg.organizationId
|
||||
)}.`,
|
||||
actorUserId: user.id,
|
||||
relatedEntityId: requestArg.userId,
|
||||
relatedEntityType: 'user',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
@@ -254,6 +327,10 @@ export class UserInvitationManager {
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
const roles = await this.receptionRef.organizationmanager.assertRoleKeysAreValid(
|
||||
requestArg.organizationId,
|
||||
requestArg.roles
|
||||
);
|
||||
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
@@ -267,7 +344,7 @@ export class UserInvitationManager {
|
||||
}
|
||||
|
||||
// If removing owner role, check we're not removing the last owner
|
||||
if (role.data.roles.includes('owner') && !requestArg.roles.includes('owner')) {
|
||||
if (role.data.roles.includes('owner') && !roles.includes('owner')) {
|
||||
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||
data: { organizationId: requestArg.organizationId },
|
||||
});
|
||||
@@ -280,9 +357,23 @@ export class UserInvitationManager {
|
||||
}
|
||||
}
|
||||
|
||||
role.data.roles = requestArg.roles;
|
||||
role.data.roles = roles;
|
||||
await role.save();
|
||||
|
||||
const updatedUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: requestArg.userId,
|
||||
});
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: 'org_member_roles_updated',
|
||||
severity: 'high',
|
||||
title: 'Organization member roles updated',
|
||||
body: `${user.data.email} changed roles for ${updatedUser?.data?.email || requestArg.userId} to ${roles.join(', ')}.`,
|
||||
actorUserId: user.id,
|
||||
relatedEntityId: requestArg.userId,
|
||||
relatedEntityType: 'user',
|
||||
});
|
||||
|
||||
return { success: true, role: await role.createSavableObject() };
|
||||
}
|
||||
)
|
||||
@@ -308,6 +399,18 @@ export class UserInvitationManager {
|
||||
);
|
||||
}
|
||||
|
||||
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: requestArg.organizationId,
|
||||
});
|
||||
if (!organization) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found.');
|
||||
}
|
||||
if ((requestArg.confirmationText || '').trim() !== `transfer ${organization.data.slug}`) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
`Confirmation text must be exactly "transfer ${organization.data.slug}".`
|
||||
);
|
||||
}
|
||||
|
||||
// Get new owner's role
|
||||
const newOwnerRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
@@ -332,6 +435,29 @@ export class UserInvitationManager {
|
||||
}
|
||||
await currentUserRole.save();
|
||||
|
||||
const newOwner = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: requestArg.newOwnerId,
|
||||
});
|
||||
await this.receptionRef.activityLogManager.logActivity(
|
||||
user.id,
|
||||
'org_ownership_transferred',
|
||||
`${user.data.email} transferred ownership of ${organization.data.name} to ${newOwner?.data?.email || requestArg.newOwnerId}.`,
|
||||
{
|
||||
targetId: requestArg.organizationId,
|
||||
targetType: 'organization',
|
||||
}
|
||||
);
|
||||
await this.emitOrganizationAlert({
|
||||
organizationId: requestArg.organizationId,
|
||||
eventType: 'org_ownership_transferred',
|
||||
severity: 'critical',
|
||||
title: 'Organization ownership transferred',
|
||||
body: `${user.data.email} transferred ownership to ${newOwner?.data?.email || requestArg.newOwnerId}.`,
|
||||
actorUserId: user.id,
|
||||
relatedEntityId: requestArg.newOwnerId,
|
||||
relatedEntityType: 'user',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export { typedrequest, typedsocket };
|
||||
|
||||
// local
|
||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
||||
// idp.global scope
|
||||
import * as idpInterfaces from '@idp.global/interfaces';
|
||||
|
||||
export { idpInterfaces };
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
import { IdpRequests } from './classes.idprequests.js';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export class IdpClient {
|
||||
// INSTANCE PRIVATE
|
||||
private helpers = {
|
||||
async extractDataFromJwtString(jwtString: string): Promise<plugins.idpInterfaces.data.IJwt> {
|
||||
return plugins.webjwt.getDataFromJwtString(jwtString);
|
||||
},
|
||||
};
|
||||
|
||||
// INSTANCE PUBLIC
|
||||
|
||||
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.IAppLegacy) {
|
||||
if (receptionBaseUrlArg.endsWith('/')) {
|
||||
receptionBaseUrlArg = receptionBaseUrlArg.slice(0, -1);
|
||||
}
|
||||
if (!receptionBaseUrlArg.endsWith('/typedrequest')) {
|
||||
receptionBaseUrlArg = `${receptionBaseUrlArg}/typedrequest`;
|
||||
}
|
||||
this.parsedReceptionUrl = plugins.smarturl.Smarturl.createFromUrl(receptionBaseUrlArg);
|
||||
console.log(`reception client connecting to ${this.parsedReceptionUrl.toString()}`);
|
||||
if (!appDataArg) {
|
||||
appDataArg = {
|
||||
id: '', // TODO
|
||||
appUrl: `https://${window.location.host}/`,
|
||||
description: '',
|
||||
logoUrl: '',
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
this.appData = appDataArg;
|
||||
}
|
||||
|
||||
public requests = new IdpRequests(this);
|
||||
|
||||
public checkWetherOnReceptionDomain() {
|
||||
return plugins.smarturl.Smarturl.createFromUrl(window.location.href).hostname ===
|
||||
this.parsedReceptionUrl.hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* app data can be transferred when redirecting to the sso domain using query params
|
||||
* this message retrieves the app data when on the sso domain
|
||||
*/
|
||||
public async getAppDataOnSsoDomain() {
|
||||
if (!window.location.href.startsWith('https://sso.workspace.global/')) {
|
||||
console.error('You are trying to access SSO appData on a non sso domain.');
|
||||
return null;
|
||||
}
|
||||
const appDataString = plugins.smarturl.Smarturl.createFromUrl(window.location.href).searchParams
|
||||
.appdata;
|
||||
if (!appDataString) {
|
||||
console.error('no appdata query arg detected');
|
||||
return null;
|
||||
}
|
||||
const appData = plugins.smartjson.parseBase64(appDataString);
|
||||
return appData;
|
||||
}
|
||||
|
||||
public async setJwt(jwtStringArg: string) {
|
||||
await this.storeJwt(jwtStringArg);
|
||||
}
|
||||
|
||||
public async setRefreshToken(refreshTokenArg: string) {
|
||||
await this.storeRefreshToken(refreshTokenArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* a typedsocket for going reactive
|
||||
*/
|
||||
public typedsocket!: plugins.typedsocket.TypedSocket;
|
||||
|
||||
/**
|
||||
* a typed router to go reactive
|
||||
*/
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public statusObservable =
|
||||
new plugins.smartrx.rxjs.Subject<plugins.idpInterfaces.data.TLoginStatus>();
|
||||
|
||||
public ssoStore = new plugins.webstore.WebStore({
|
||||
storeName: 'idpglobalStore',
|
||||
dbName: 'main',
|
||||
});
|
||||
|
||||
public async storeJwt(jwtString: string) {
|
||||
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');
|
||||
}
|
||||
|
||||
public async deleteRefreshToken() {
|
||||
await this.ssoStore.delete('idpRefreshToken');
|
||||
}
|
||||
|
||||
public async clearAuthState() {
|
||||
await Promise.all([this.deleteJwt(), this.deleteRefreshToken()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* performs jwt housekeeping
|
||||
* only call if jwt is present
|
||||
* @returns
|
||||
*/
|
||||
public async performJwtHousekeeping() {
|
||||
let jwt = await this.getJwt();
|
||||
if (!jwt) {
|
||||
return null;
|
||||
}
|
||||
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
|
||||
if (extractedJwt.data.refreshFrom < Date.now() && Date.now() < extractedJwt.data.validUntil) {
|
||||
jwt = await this.refreshJwt();
|
||||
} else if (Date.now() > extractedJwt.data.validUntil) {
|
||||
await this.deleteJwt();
|
||||
jwt = await this.refreshJwt();
|
||||
}
|
||||
return jwt;
|
||||
}
|
||||
|
||||
public async refreshJwt(refreshTokenArg?: string): Promise<string | null> {
|
||||
const refreshToken = refreshTokenArg || (await this.getRefreshToken());
|
||||
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.typedsocketDeferred.promise;
|
||||
const refreshJwtReq =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
const response = await refreshJwtReq
|
||||
.fire({
|
||||
refreshToken,
|
||||
})
|
||||
.catch(async () => {
|
||||
await this.clearAuthState();
|
||||
return null;
|
||||
});
|
||||
|
||||
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 response.jwt;
|
||||
}
|
||||
|
||||
/**
|
||||
* can be used to switch between pages
|
||||
*/
|
||||
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 =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
const response = await getTransferToken.fire({
|
||||
refreshToken,
|
||||
appData: appDataArg || this.appData,
|
||||
});
|
||||
return response.transferToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* gets a transfer token and switches to a location
|
||||
*/
|
||||
public async getTransferTokenAndSwitchToLocation(newLocationArg: string): Promise<void> {
|
||||
const transferToken = await this.getTransferToken();
|
||||
if (!transferToken) {
|
||||
alert('failed to get transfer token!');
|
||||
}
|
||||
const urlInstance = plugins.smarturl.Smarturl.createFromUrl(newLocationArg, {
|
||||
searchParams: {
|
||||
transfertoken: transferToken,
|
||||
},
|
||||
});
|
||||
const transferUrl = urlInstance.toString();
|
||||
window.location.href = transferUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* processes a transfer token
|
||||
*/
|
||||
public async processTransferToken(): Promise<boolean> {
|
||||
const href = window.location.href;
|
||||
const url = plugins.smarturl.Smarturl.createFromUrl(href);
|
||||
const transferToken = url.searchParams['transfertoken'];
|
||||
if (transferToken) {
|
||||
await this.typedsocketDeferred.promise;
|
||||
const getTransferToken =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
const response = await getTransferToken.fire({
|
||||
transferToken,
|
||||
appData: this.appData,
|
||||
});
|
||||
if (response.refreshToken) {
|
||||
await this.refreshJwt(response.refreshToken);
|
||||
} else {
|
||||
globalThis.alert?.('transfer token invalid');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Login Status stuff
|
||||
public async checkJwtPresent() {
|
||||
const jwt = await this.performJwtHousekeeping();
|
||||
if (jwt) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* determines if the user is logged in
|
||||
* accepts boolean to optionally require login
|
||||
* @param requireLoginArg
|
||||
* @returns
|
||||
*/
|
||||
public async determineLoginStatus(requireLoginArg: boolean = false): Promise<boolean> {
|
||||
const jwtPresent = await this.checkJwtPresent();
|
||||
if (jwtPresent) {
|
||||
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
|
||||
return true;
|
||||
} else {
|
||||
if (requireLoginArg) {
|
||||
const urlInstance = plugins.smarturl.Smarturl.createFromUrl(
|
||||
this.parsedReceptionUrl.clone().set('path', '/login').toString(),
|
||||
{
|
||||
searchParams: {
|
||||
appdata: plugins.smartjson.stringifyBase64(this.appData),
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!globalThis.location.href.startsWith(this.parsedReceptionUrl.toString())) {
|
||||
globalThis.location.href = urlInstance.toString();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* logs out the current user
|
||||
*/
|
||||
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.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 =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.ILogoutRequest>(
|
||||
'logout'
|
||||
);
|
||||
await logoutTr.fire({
|
||||
refreshToken,
|
||||
});
|
||||
await this.clearAuthState();
|
||||
const appData = await this.getAppDataOnSsoDomain();
|
||||
if (appData) {
|
||||
console.log(`redirecting to app after logout: ${appData.appUrl}`);
|
||||
window.location.href = appData.appUrl;
|
||||
} else {
|
||||
console.error('no appData provided. Not redirecting after logout.');
|
||||
}
|
||||
if (window.location.href.startsWith(idpLogoutUrl.origin)) {
|
||||
window.location.href = this.parsedReceptionUrl.origin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public typedsocketDeferred = plugins.smartpromise.defer();
|
||||
public async enableTypedSocket() {
|
||||
if (this.typedsocketDeferred.claimed) {
|
||||
return this.typedsocketDeferred.promise;
|
||||
}
|
||||
this.typedsocketDeferred.claim();
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
this.typedrouter,
|
||||
this.parsedReceptionUrl.toString()
|
||||
);
|
||||
this.typedsocketDeferred.resolve(this.typedsocket);
|
||||
return this.typedsocketDeferred.promise;
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.typedsocket?.stop();
|
||||
}
|
||||
|
||||
// ==================================
|
||||
// Organization and Settings stuff
|
||||
// ==================================
|
||||
public async createOrganization(
|
||||
orgNameArg: string,
|
||||
orgSlugArg: string,
|
||||
modeArg: 'checkAvailability' | 'manifest'
|
||||
) {
|
||||
await this.typedsocketDeferred.promise;
|
||||
const validateOrg =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateOrganization>(
|
||||
'createOrganization'
|
||||
);
|
||||
const response = await validateOrg.fire({
|
||||
jwt: await this.getJwt(),
|
||||
action: modeArg,
|
||||
organizationName: orgNameArg,
|
||||
organizationSlug: orgSlugArg,
|
||||
userId: (await this.getJwtData()).id,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* gets the current OrganizationRoles
|
||||
*/
|
||||
public async getRolesAndOrganizations() {
|
||||
console.log('idpclient: getting roles and orgs...');
|
||||
await this.typedsocketDeferred.promise;
|
||||
const rolesAndOrganizationsForUserId =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetRolesAndOrganizationsForUserId>(
|
||||
'getRolesAndOrganizationsForUserId'
|
||||
);
|
||||
const response = await rolesAndOrganizationsForUserId.fire({
|
||||
jwt: await this.getJwt(),
|
||||
userId: (await this.getJwtData()).id,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the PaddleCheckoutId for an organization.
|
||||
*/
|
||||
public async updatePaddleCheckoutId(orgIdArg: string, checkoutIdArg: string) {
|
||||
await this.typedsocketDeferred.promise;
|
||||
const updateBillingPlan =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdatePaymentMethod>(
|
||||
'updatePaymentMethod'
|
||||
);
|
||||
const response = await updateBillingPlan.fire({
|
||||
jwtString: await this.getJwt(),
|
||||
orgId: orgIdArg,
|
||||
paddle: {
|
||||
checkoutId: checkoutIdArg,
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
public async whoIs() {
|
||||
await this.typedsocketDeferred.promise;
|
||||
const whoIs =
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_WhoIs>(
|
||||
'whoIs'
|
||||
);
|
||||
const response = await whoIs.fire({
|
||||
jwt: await this.getJwt(),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
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;
|
||||
constructor(idpClientArg: IdpClient) {
|
||||
this.idpClientArg = idpClientArg;
|
||||
}
|
||||
|
||||
public get afterRegistrationEmailClicked () {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||
'afterRegistrationEmailClicked'
|
||||
);
|
||||
}
|
||||
|
||||
public get setData() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||
'setDataForRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
public get mobileNumberVerification () {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||
'mobileVerificationForRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public get finishRegistration() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||
'finishRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
public get loginWithUserNameAndPassword () {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword'
|
||||
);
|
||||
}
|
||||
|
||||
public get obtainJwt () {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
}
|
||||
|
||||
public get obtainOneTimeToken () {
|
||||
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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './classes.idpclient.js';
|
||||
@@ -1,26 +0,0 @@
|
||||
// losslessone_private scope
|
||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
||||
|
||||
export { idpInterfaces };
|
||||
|
||||
// apiglobal scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export { typedrequest, typedsocket };
|
||||
|
||||
// pushrocks scope
|
||||
import * as smartjson from '@push.rocks/smartjson';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smarttime from '@push.rocks/smarttime';
|
||||
import * as smarturl from '@push.rocks/smarturl';
|
||||
import * as webjwt from '@push.rocks/webjwt';
|
||||
import * as webstore from '@push.rocks/webstore';
|
||||
|
||||
export { smartjson, smartpromise, smartrx, smarttime, smarturl, webjwt, webstore };
|
||||
|
||||
// @tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
export { tsclass };
|
||||
@@ -1,156 +0,0 @@
|
||||
# @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.
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "@idp.global/client",
|
||||
"order": 3
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@losslessone_private/loint-reception',
|
||||
version: '1.0.122',
|
||||
description: 'an interface package for the reception service at Lossless'
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export type TActivityAction =
|
||||
| 'login'
|
||||
| 'logout'
|
||||
| 'session_created'
|
||||
| 'session_revoked'
|
||||
| 'org_created'
|
||||
| 'org_joined'
|
||||
| 'org_left'
|
||||
| 'role_changed'
|
||||
| 'profile_updated'
|
||||
| 'app_connected'
|
||||
| 'app_disconnected';
|
||||
|
||||
export interface IActivityLog {
|
||||
id: string;
|
||||
data: {
|
||||
userId: string;
|
||||
action: TActivityAction;
|
||||
timestamp: number;
|
||||
metadata: {
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
targetId?: string;
|
||||
targetType?: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// 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
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* should be unique
|
||||
*/
|
||||
name: string;
|
||||
description: string;
|
||||
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'];
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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[];
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
export type TSupportedCurrency = 'EUR';
|
||||
|
||||
export interface IBillableItem {
|
||||
name: string;
|
||||
monthlyPrice: number;
|
||||
currency: TSupportedCurrency;
|
||||
from: number;
|
||||
to: number;
|
||||
factoredOn30DayMonth: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface IBillingPlan {
|
||||
id: string;
|
||||
data: {
|
||||
type: 'Paddle' | 'AppSumo' | 'FairUsageFree' | 'Enterprise' | 'Internal' | 'Testing';
|
||||
proEnabled: boolean;
|
||||
organizationId: string;
|
||||
lastProcessed: number;
|
||||
seats: number;
|
||||
status: 'active' | 'activeOverdue' | 'pausedOverdue' | 'inactive' | 'suspended';
|
||||
paddleData?: {
|
||||
checkoutId: string;
|
||||
};
|
||||
alternativePaymentData?: {
|
||||
enterprise: boolean;
|
||||
appSumoCode: string;
|
||||
};
|
||||
nextBilling: {
|
||||
items: Array<IBillableItem>;
|
||||
method: 'paddle';
|
||||
ontrack: boolean;
|
||||
errorText?: string;
|
||||
selectedBillingDate: number;
|
||||
};
|
||||
billingEvents: Array<{
|
||||
timestamp: number;
|
||||
amount: number;
|
||||
currency: TSupportedCurrency;
|
||||
billedItems: Array<IBillableItem>;
|
||||
checkoutLink?: string;
|
||||
}>;
|
||||
communications: Array<any>;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
export interface IDevice extends plugins.tsclass.network.IDevice {}
|
||||
@@ -1,12 +0,0 @@
|
||||
export type TEmailActionTokenAction = 'emailLogin' | 'passwordReset';
|
||||
|
||||
export interface IEmailActionToken {
|
||||
id: string;
|
||||
data: {
|
||||
email: string;
|
||||
action: TEmailActionTokenAction;
|
||||
tokenHash: string;
|
||||
validUntil: number;
|
||||
createdAt: number;
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export * from './loint-reception.activity.js';
|
||||
export * from './loint-reception.app.js';
|
||||
export * from './loint-reception.emailactiontoken.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';
|
||||
export * from './loint-reception.loginsession.js';
|
||||
export * from './loint-reception.organization.js';
|
||||
export * from './loint-reception.paddlecheckoutdata.js';
|
||||
export * from './loint-reception.registrationsession.js';
|
||||
export * from './loint-reception.role.js';
|
||||
export * from './loint-reception.user.js';
|
||||
export * from './loint-reception.userinvitation.js';
|
||||
@@ -1,43 +0,0 @@
|
||||
export type TLoginStatus = 'loggedIn' | 'loggedOut' | 'invalidated' | 'not found' | 'transfer';
|
||||
export type TLoginAction = 'login' | 'logout' | 'manage';
|
||||
|
||||
export interface IJwt {
|
||||
id: string;
|
||||
blocked: boolean;
|
||||
data: {
|
||||
/**
|
||||
* the user id of the jwt
|
||||
*/
|
||||
userId: string;
|
||||
|
||||
/**
|
||||
* the login session backing this jwt
|
||||
*/
|
||||
sessionId?: string;
|
||||
|
||||
/**
|
||||
* the latest point of
|
||||
*/
|
||||
validUntil: number;
|
||||
/**
|
||||
* hold off from refreshing before
|
||||
*/
|
||||
refreshFrom: number;
|
||||
/**
|
||||
* an interval in millis to recheck token invalidation
|
||||
*/
|
||||
refreshEvery: number;
|
||||
|
||||
/**
|
||||
* legacy field kept for compatibility with already-issued jwt documents
|
||||
*/
|
||||
refreshToken?: string;
|
||||
|
||||
/**
|
||||
* just for looks/debugging
|
||||
*/
|
||||
justForLooks: {
|
||||
validUntilIsoString: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
export interface ILoginSession {
|
||||
id: string;
|
||||
data: {
|
||||
userId: string | null;
|
||||
validUntil: number;
|
||||
invalidated: boolean;
|
||||
/**
|
||||
* 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 | 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;
|
||||
};
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
id: string;
|
||||
data: {
|
||||
/** Hashed authorization code string */
|
||||
codeHash: 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;
|
||||
/** Creation timestamp */
|
||||
issuedAt: number;
|
||||
/** Whether the code has been used (single-use) */
|
||||
used: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Access Token (opaque or JWT)
|
||||
*/
|
||||
export interface IOidcAccessToken {
|
||||
id: string;
|
||||
data: {
|
||||
/** The access token string 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 {
|
||||
id: string;
|
||||
data: {
|
||||
/** The refresh token string 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 {
|
||||
id: string;
|
||||
data: {
|
||||
/** User who gave consent */
|
||||
userId: string;
|
||||
/** OAuth client ID */
|
||||
clientId: string;
|
||||
/** Scopes the user consented to */
|
||||
scopes: TOidcScope[];
|
||||
/** When consent was granted */
|
||||
grantedAt: number;
|
||||
/** When consent was last updated */
|
||||
updatedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC Discovery Document (OpenID Provider Configuration)
|
||||
*/
|
||||
export interface IOidcDiscoveryDocument {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint: string;
|
||||
jwks_uri: string;
|
||||
revocation_endpoint: string;
|
||||
scopes_supported: TOidcScope[];
|
||||
response_types_supported: string[];
|
||||
grant_types_supported: string[];
|
||||
subject_types_supported: string[];
|
||||
id_token_signing_alg_values_supported: string[];
|
||||
token_endpoint_auth_methods_supported: string[];
|
||||
code_challenge_methods_supported: string[];
|
||||
claims_supported: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Web Key Set (JWKS) response
|
||||
*/
|
||||
export interface IJwks {
|
||||
keys: IJwk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Web Key (RSA public key)
|
||||
*/
|
||||
export interface IJwk {
|
||||
kty: 'RSA';
|
||||
use: 'sig';
|
||||
alg: 'RS256';
|
||||
kid: string;
|
||||
n: string; // RSA modulus (base64url encoded)
|
||||
e: string; // RSA exponent (base64url encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* ID Token claims (JWT payload)
|
||||
*/
|
||||
export interface IIdTokenClaims {
|
||||
/** Issuer (idp.global URL) */
|
||||
iss: string;
|
||||
/** Subject (user ID) */
|
||||
sub: string;
|
||||
/** Audience (client ID) */
|
||||
aud: string;
|
||||
/** Expiration time (Unix timestamp) */
|
||||
exp: number;
|
||||
/** Issued at (Unix timestamp) */
|
||||
iat: number;
|
||||
/** Authentication time (Unix timestamp) */
|
||||
auth_time?: number;
|
||||
/** Nonce (if provided in authorization request) */
|
||||
nonce?: string;
|
||||
/** Access token hash (for hybrid flows) */
|
||||
at_hash?: string;
|
||||
|
||||
// Profile scope claims
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
picture?: string;
|
||||
|
||||
// Email scope claims
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
|
||||
// Custom claims for organizations scope
|
||||
organizations?: IOrganizationClaim[];
|
||||
|
||||
// Custom claims for roles scope
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization claim in ID token / userinfo
|
||||
*/
|
||||
export interface IOrganizationClaim {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UserInfo endpoint response
|
||||
*/
|
||||
export interface IUserInfoResponse {
|
||||
/** Subject (user ID) - always included */
|
||||
sub: string;
|
||||
|
||||
// Profile scope
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
picture?: string;
|
||||
|
||||
// Email scope
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
|
||||
// Organizations scope (custom)
|
||||
organizations?: IOrganizationClaim[];
|
||||
|
||||
// Roles scope (custom)
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Token endpoint response
|
||||
*/
|
||||
export interface ITokenResponse {
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token endpoint error response
|
||||
*/
|
||||
export interface ITokenErrorResponse {
|
||||
error: 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | 'unsupported_grant_type' | 'invalid_scope';
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization request parameters
|
||||
*/
|
||||
export interface IAuthorizationRequest {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
response_type: 'code';
|
||||
scope: string;
|
||||
state: string;
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: 'S256';
|
||||
nonce?: string;
|
||||
prompt?: 'none' | 'login' | 'consent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Token request for authorization_code grant
|
||||
*/
|
||||
export interface ITokenRequestAuthCode {
|
||||
grant_type: 'authorization_code';
|
||||
code: string;
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
code_verifier?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token request for refresh_token grant
|
||||
*/
|
||||
export interface ITokenRequestRefresh {
|
||||
grant_type: 'refresh_token';
|
||||
refresh_token: string;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for token requests
|
||||
*/
|
||||
export type ITokenRequest = ITokenRequestAuthCode | ITokenRequestRefresh;
|
||||
@@ -1,13 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
import { type IBillingPlan } from './loint-reception.billingplan.js';
|
||||
import { type IRole } from './loint-reception.role.js';
|
||||
|
||||
export interface IOrganization {
|
||||
id: string;
|
||||
data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
billingPlanId: string;
|
||||
roleIds: string[];
|
||||
};
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
export interface IPaddleCheckoutData<TPassthrough = null> {
|
||||
checkout: {
|
||||
created_at: string;
|
||||
completed: boolean;
|
||||
id: string;
|
||||
coupon: {
|
||||
coupon_code?: string;
|
||||
};
|
||||
passthrough?: TPassthrough;
|
||||
prices: {
|
||||
customer: {
|
||||
currency: string;
|
||||
unit: string;
|
||||
unit_tax: string;
|
||||
total: string;
|
||||
total_tax: string;
|
||||
items: Array<{
|
||||
checkout_product_id: number;
|
||||
product_id: number;
|
||||
name: string;
|
||||
custom_message: string;
|
||||
quantity: number;
|
||||
allow_quantity: false;
|
||||
icon_url: string;
|
||||
min_quantity: number;
|
||||
max_quantity: number;
|
||||
currency: string;
|
||||
unit_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
line_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
discounts: [];
|
||||
/**
|
||||
* factorised, not percentage, so looks like 0.19 for Germany.
|
||||
*/
|
||||
tax_rate: number;
|
||||
recurring: {
|
||||
period: string;
|
||||
interval: number;
|
||||
trial_days: number;
|
||||
currency: string;
|
||||
unit_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
line_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
discounts: [];
|
||||
tax_rate: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
vendor: {
|
||||
currency: string;
|
||||
unit: string;
|
||||
unit_tax: string;
|
||||
total: string;
|
||||
total_tax: string;
|
||||
items: [
|
||||
{
|
||||
checkout_product_id: number;
|
||||
product_id: number;
|
||||
name: string;
|
||||
custom_message: string;
|
||||
quantity: number;
|
||||
allow_quantity: false;
|
||||
icon_url: string;
|
||||
min_quantity: number;
|
||||
max_quantity: number;
|
||||
currency: string;
|
||||
unit_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
line_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
discounts: [];
|
||||
tax_rate: number;
|
||||
recurring: {
|
||||
period: string;
|
||||
interval: number;
|
||||
trial_days: number;
|
||||
currency: string;
|
||||
unit_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
line_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
discounts: [];
|
||||
tax_rate: number;
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
redirect_url: null;
|
||||
test_variant: 'newCheckout';
|
||||
recurring_prices: {
|
||||
customer: {
|
||||
currency: string;
|
||||
unit: string;
|
||||
unit_tax: string;
|
||||
total: string;
|
||||
total_tax: string;
|
||||
items: [
|
||||
{
|
||||
checkout_product_id: number;
|
||||
product_id: number;
|
||||
name: string;
|
||||
custom_message: string;
|
||||
quantity: number;
|
||||
allow_quantity: false;
|
||||
icon_url: string;
|
||||
min_quantity: number;
|
||||
max_quantity: number;
|
||||
currency: string;
|
||||
unit_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
line_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
discounts: [];
|
||||
tax_rate: number;
|
||||
recurring: {
|
||||
period: string;
|
||||
interval: number;
|
||||
trial_days: number;
|
||||
currency: string;
|
||||
unit_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
line_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
discounts: [];
|
||||
tax_rate: number;
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
interval: {
|
||||
length: number;
|
||||
type: string;
|
||||
};
|
||||
vendor: {
|
||||
currency: string;
|
||||
unit: string;
|
||||
unit_tax: string;
|
||||
total: string;
|
||||
total_tax: string;
|
||||
items: [
|
||||
{
|
||||
checkout_product_id: number;
|
||||
product_id: number;
|
||||
name: string;
|
||||
custom_message: string;
|
||||
quantity: number;
|
||||
allow_quantity: false;
|
||||
icon_url: string;
|
||||
min_quantity: number;
|
||||
max_quantity: number;
|
||||
currency: string;
|
||||
unit_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
line_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
discounts: [];
|
||||
tax_rate: number;
|
||||
recurring: {
|
||||
period: string;
|
||||
interval: number;
|
||||
trial_days: number;
|
||||
currency: string;
|
||||
unit_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
line_price: {
|
||||
net: number;
|
||||
gross: number;
|
||||
net_discount: number;
|
||||
gross_discount: number;
|
||||
net_after_discount: number;
|
||||
gross_after_discount: number;
|
||||
tax: number;
|
||||
tax_after_discount: number;
|
||||
};
|
||||
discounts: [];
|
||||
tax_rate: number;
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
product: {
|
||||
quantity: number;
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
country: string;
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
import { type IRole } from './loint-reception.role.js';
|
||||
|
||||
export interface ISubOrgProperty {
|
||||
name: string;
|
||||
domain: string;
|
||||
roles: IRole[];
|
||||
/**
|
||||
* contains the ids of all the apps that show the property
|
||||
*/
|
||||
attributedAppIds: string[];
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export type TRegistrationSessionStatus =
|
||||
| 'announced'
|
||||
| 'emailValidated'
|
||||
| 'mobileVerified'
|
||||
| 'registered'
|
||||
| 'failed';
|
||||
|
||||
export interface IRegistrationSession {
|
||||
id: string;
|
||||
data: {
|
||||
emailAddress: string;
|
||||
hashedEmailToken: string;
|
||||
smsCodeHash?: string | null;
|
||||
smsvalidationCounter: number;
|
||||
status: TRegistrationSessionStatus;
|
||||
validUntil: number;
|
||||
createdAt: number;
|
||||
collectedData: {
|
||||
userData: {
|
||||
username?: string | null;
|
||||
connectedOrgs: string[];
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
status?: 'new' | 'active' | 'deleted' | 'suspended' | null;
|
||||
mobileNumber?: string | null;
|
||||
password?: string | null;
|
||||
passwordHash?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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 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;
|
||||
/** Array of roles - supports standard roles and custom role names */
|
||||
roles: string[];
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
import { type IRole } from './loint-reception.role.js';
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
data: {
|
||||
name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* mobile number used for verification
|
||||
*/
|
||||
mobileNumber?: string;
|
||||
/**
|
||||
* only used during initial password setting
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* used for validation of passwords
|
||||
*/
|
||||
passwordHash?: string;
|
||||
status: 'new' | 'active' | 'deleted' | 'suspended';
|
||||
/**
|
||||
* a quick ref for which organizations might have roles for this user
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
/**
|
||||
* A UserInvitation represents an invitation to join an organization.
|
||||
* Key characteristics:
|
||||
* - Unique by email (multiple orgs can share the same invitation)
|
||||
* - Converts to real User on registration or folds into existing user
|
||||
* - Auto-expires after 90 days
|
||||
*/
|
||||
export interface IUserInvitation {
|
||||
id: string;
|
||||
data: {
|
||||
/** The invited email address - unique key for sharing across orgs */
|
||||
email: string;
|
||||
|
||||
/** Secure token for invitation link validation */
|
||||
token: string;
|
||||
|
||||
/** Current status of the invitation */
|
||||
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
|
||||
|
||||
/** When the invitation was first created */
|
||||
createdAt: number;
|
||||
|
||||
/** When the invitation expires (createdAt + 90 days) */
|
||||
expiresAt: number;
|
||||
|
||||
/**
|
||||
* Organizations that have invited this email.
|
||||
* Multiple orgs can link to the same invitation.
|
||||
*/
|
||||
organizationRefs: IOrganizationInvitationRef[];
|
||||
|
||||
/** When the invitation was accepted (user registered/folded) */
|
||||
acceptedAt?: number;
|
||||
|
||||
/** The User ID after conversion (when accepted) */
|
||||
convertedToUserId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one organization's invitation to the user.
|
||||
* Stored as part of IUserInvitation.organizationRefs array.
|
||||
*/
|
||||
export interface IOrganizationInvitationRef {
|
||||
/** The organization that sent this invitation */
|
||||
organizationId: string;
|
||||
|
||||
/** The user who sent the invitation */
|
||||
invitedByUserId: string;
|
||||
|
||||
/** When this org invited the user */
|
||||
invitedAt: number;
|
||||
|
||||
/** Roles to assign when the invitation is accepted */
|
||||
roles: string[];
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// requests
|
||||
import * as request from './request/index.js';
|
||||
import * as data from './data/index.js';
|
||||
import * as tags from './tags/index.js';
|
||||
|
||||
export { request, data, tags };
|
||||
@@ -1,9 +0,0 @@
|
||||
// @apiglobal scope
|
||||
import * as typedRequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||
|
||||
export { typedRequestInterfaces };
|
||||
|
||||
// @tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
export { tsclass };
|
||||
@@ -1,128 +0,0 @@
|
||||
# @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.
|
||||
@@ -1,130 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
import { type IUser, type IRole } from '../data/index.js';
|
||||
|
||||
export interface IReq_InternalAuthorization
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_InternalAuthorization
|
||||
> {
|
||||
method: '';
|
||||
request: {
|
||||
accountData: IUser;
|
||||
jwt: string;
|
||||
};
|
||||
response: {
|
||||
accountData: IUser;
|
||||
jwt: string;
|
||||
relevantRoles: IRole[];
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
import * as data from '../data/index.js';
|
||||
|
||||
export interface IReq_UpdatePaymentMethod
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_UpdatePaymentMethod
|
||||
> {
|
||||
method: 'updatePaymentMethod';
|
||||
request: {
|
||||
jwtString: string;
|
||||
orgId: string;
|
||||
paddle?: {
|
||||
checkoutId: string;
|
||||
};
|
||||
};
|
||||
response: {
|
||||
billingPlan: plugins.tsclass.typeFest.PartialDeep<data.IBillingPlan>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* allows getting the billing plan for a user
|
||||
*/
|
||||
export interface IReq_GetBillingPlan
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetBillingPlan
|
||||
> {
|
||||
method: 'getBillingPlan';
|
||||
request: {
|
||||
jwtString: string;
|
||||
orgId: string;
|
||||
billingPlanId: string;
|
||||
};
|
||||
response: {
|
||||
billingPlan: data.IBillingPlan;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Paddle configuration from environment variables
|
||||
*/
|
||||
export interface IReq_GetPaddleConfig
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetPaddleConfig
|
||||
> {
|
||||
method: 'getPaddleConfig';
|
||||
request: {};
|
||||
response: {
|
||||
paddleToken: string;
|
||||
paddlePriceId: string;
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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';
|
||||
export * from './loint-reception.login.js';
|
||||
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';
|
||||
@@ -1,79 +0,0 @@
|
||||
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,
|
||||
IReq_GetPublicKeyForValidation
|
||||
> {
|
||||
method: 'getPublicKeyForValidation';
|
||||
request: {
|
||||
backendToken: string;
|
||||
};
|
||||
response: {
|
||||
publicKeyPem: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
IReq_PushPublicKeyForValidation
|
||||
> {
|
||||
method: 'pushPublicKeyForValidation';
|
||||
request: {
|
||||
publicKeyPem: string;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_PushOrGetJwtIdBlocklist
|
||||
> {
|
||||
method: 'pushOrGetJwtIdBlocklist';
|
||||
request: {
|
||||
blockedJwtIds?: string[];
|
||||
};
|
||||
response: {
|
||||
blockedJwtIds?: string[];
|
||||
};
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
import * as data from '../data/index.js';
|
||||
|
||||
export interface IReq_LoginWithEmailOrUsernameAndPassword
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_LoginWithEmailOrUsernameAndPassword
|
||||
> {
|
||||
method: 'loginWithEmailOrUsernameAndPassword';
|
||||
request: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
response: {
|
||||
refreshToken?: string;
|
||||
twoFaNeeded: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_LoginWithEmail
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_LoginWithEmailOrUsernameAndPassword
|
||||
> {
|
||||
method: 'loginWithEmail';
|
||||
request: {
|
||||
email: string;
|
||||
};
|
||||
response: {
|
||||
status: 'ok' | 'not ok';
|
||||
testOnlyToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_LoginWithEmailAfterEmailTokenAquired
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_LoginWithEmailOrUsernameAndPassword
|
||||
> {
|
||||
method: 'loginWithEmailAfterEmailTokenAquired';
|
||||
request: {
|
||||
email: string;
|
||||
token: string;
|
||||
};
|
||||
response: {
|
||||
refreshToken: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* in case you authenticate with a long lived api token
|
||||
*/
|
||||
export interface IReq_LoginWithApiToken
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_LoginWithApiToken
|
||||
> {
|
||||
method: 'loginWithApiToken';
|
||||
request: {
|
||||
apiToken: string;
|
||||
};
|
||||
response: {
|
||||
jwt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ILogoutRequest
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
ILogoutRequest
|
||||
> {
|
||||
method: 'logout';
|
||||
request: {
|
||||
refreshToken: string;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
export interface IReq_RefreshJwt
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_RefreshJwt
|
||||
> {
|
||||
method: 'refreshJwt';
|
||||
request: {
|
||||
refreshToken: string;
|
||||
};
|
||||
response: {
|
||||
status: data.TLoginStatus;
|
||||
jwt?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* allows the exchange between refreshToken and transferTokens
|
||||
*/
|
||||
export interface IReq_ExchangeRefreshTokenAndTransferToken
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_ExchangeRefreshTokenAndTransferToken
|
||||
> {
|
||||
method: 'exchangeRefreshTokenAndTransferToken';
|
||||
request: {
|
||||
transferToken?: string;
|
||||
refreshToken?: string;
|
||||
appData: data.IAppLegacy;
|
||||
};
|
||||
response: {
|
||||
refreshToken?: string;
|
||||
transferToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* in case you authenticate with a long lived api token
|
||||
*/
|
||||
export interface IReq_ResetPassword
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_ResetPassword
|
||||
> {
|
||||
method: 'resetPassword';
|
||||
request: {
|
||||
email: string;
|
||||
};
|
||||
response: {
|
||||
status: 'ok' | 'not ok';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* in cse you authenticate with a long lived api token
|
||||
*/
|
||||
export interface IReq_SetNewPassword
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_SetNewPassword
|
||||
> {
|
||||
method: 'setNewPassword';
|
||||
request: {
|
||||
email: string;
|
||||
oldPassword?: string;
|
||||
tokenArg?: string;
|
||||
newPassword: string;
|
||||
};
|
||||
response: {
|
||||
status: 'ok' | 'not ok';
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ObtainDeviceId
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_ObtainDeviceId
|
||||
> {
|
||||
method: 'obtainDeviceId';
|
||||
request: {};
|
||||
response: {
|
||||
deviceId: data.IDevice;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* allows attaching a device id to a login session
|
||||
* to share a login session across contexts
|
||||
*/
|
||||
export interface IReq_AttachDeviceId
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_AttachDeviceId
|
||||
> {
|
||||
method: 'attachDeviceId';
|
||||
request: {
|
||||
jwt: string;
|
||||
deviceId: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as data from '../data/index.js';
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
export interface IReq_GetOrganizationById
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetOrganizationById
|
||||
> {
|
||||
method: 'getOrganizationById';
|
||||
request: {
|
||||
jwt: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
organization: data.IOrganization;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreateOrganization
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_CreateOrganization
|
||||
> {
|
||||
method: 'createOrganization';
|
||||
request: {
|
||||
jwt: string;
|
||||
userId: string;
|
||||
organizationName: string;
|
||||
organizationSlug: string;
|
||||
action: 'checkAvailability' | 'manifest';
|
||||
};
|
||||
response: {
|
||||
nameAvailable: boolean;
|
||||
resultingOrganization?: data.IOrganization;
|
||||
role?: data.IRole;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateOrganization
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateOrganization
|
||||
> {
|
||||
method: 'updateOrganization';
|
||||
request: {
|
||||
organization: data.IOrganization;
|
||||
};
|
||||
response: {
|
||||
organization: data.IOrganization;
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import * as data from '../data/index.js';
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
export interface IReq_GetPlansForOrganizationId
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetPlansForOrganizationId
|
||||
> {
|
||||
method: 'getBillingPlansForOrganizationId';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
};
|
||||
response: {
|
||||
billingPlans: data.IBillingPlan[];
|
||||
};
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
import { type IUser } from '../data/index.js';
|
||||
|
||||
export interface IReq_FirstRegistration
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_FirstRegistration
|
||||
> {
|
||||
method: 'firstRegistrationRequest';
|
||||
request: {
|
||||
email: string;
|
||||
productSlugOfInterest: string;
|
||||
};
|
||||
response: {
|
||||
status: 'ok' | 'not ok';
|
||||
testOnlyToken?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_AfterRegistrationEmailClicked
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_AfterRegistrationEmailClicked
|
||||
> {
|
||||
method: 'afterRegistrationEmailClicked';
|
||||
request: {
|
||||
/**
|
||||
* the token that has been sent with the registation email to verify access
|
||||
*/
|
||||
token: string;
|
||||
};
|
||||
response: {
|
||||
status: 'ok' | 'not ok';
|
||||
/**
|
||||
* the email thats associated with the given request token
|
||||
*/
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_SetDataForRegistration
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_SetDataForRegistration
|
||||
> {
|
||||
method: 'setDataForRegistration';
|
||||
request: {
|
||||
token: string;
|
||||
userData: IUser['data'];
|
||||
};
|
||||
response: {
|
||||
status: 'ok' | 'not ok';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be used to verify a mobile number for an verifcation
|
||||
*/
|
||||
export interface IReq_MobileVerificationForRegistration
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_MobileVerificationForRegistration
|
||||
> {
|
||||
method: 'mobileVerificationForRegistration';
|
||||
request: {
|
||||
token: string;
|
||||
mobileNumber?: string;
|
||||
verificationCode?: string;
|
||||
};
|
||||
response: {
|
||||
messageSent?: boolean;
|
||||
verficationCodeOk?: boolean;
|
||||
testOnlySmsCode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_FinishRegistration
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_FinishRegistration
|
||||
> {
|
||||
method: 'finishRegistration';
|
||||
request: {
|
||||
token: string;
|
||||
};
|
||||
response: {
|
||||
status: 'ok' | 'not ok';
|
||||
userData?: IUser['data'];
|
||||
};
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import * as data from '../data/index.js';
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
export interface IReq_GetUserData
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetUserData
|
||||
> {
|
||||
method: 'getUserData';
|
||||
request: {
|
||||
refreshToken: string;
|
||||
};
|
||||
response: {
|
||||
jwt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_SetUserData
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_SetUserData
|
||||
> {
|
||||
method: 'setUserData';
|
||||
request: {
|
||||
refreshToken: string;
|
||||
};
|
||||
response: {
|
||||
oneTimeTransferCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_SuspendUser
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_SuspendUser
|
||||
> {
|
||||
method: 'suspendUser';
|
||||
request: {
|
||||
jwt: string;
|
||||
userId: string;
|
||||
};
|
||||
response: {
|
||||
publicKeyPem: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDeleteSuspendedUser
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IDeleteSuspendedUser
|
||||
> {
|
||||
method: 'deleteSuspendedUser';
|
||||
request: {
|
||||
backendToken: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
errorText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetRolesAndOrganizationsForUserId
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetRolesAndOrganizationsForUserId
|
||||
> {
|
||||
method: 'getRolesAndOrganizationsForUserId';
|
||||
request: {
|
||||
jwt: string;
|
||||
userId: string;
|
||||
};
|
||||
response: {
|
||||
roles: data.IRole[];
|
||||
organizations: data.IOrganization[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_WhoIs {
|
||||
method: 'whoIs';
|
||||
request: {
|
||||
jwt: string;
|
||||
};
|
||||
response: {
|
||||
user: data.IUser;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetUserSessions
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetUserSessions
|
||||
> {
|
||||
method: 'getUserSessions';
|
||||
request: {
|
||||
jwt: string;
|
||||
};
|
||||
response: {
|
||||
sessions: Array<{
|
||||
id: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
lastActive: number;
|
||||
createdAt: number;
|
||||
isCurrent: boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_RevokeSession
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_RevokeSession
|
||||
> {
|
||||
method: 'revokeSession';
|
||||
request: {
|
||||
jwt: string;
|
||||
sessionId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetUserActivity
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetUserActivity
|
||||
> {
|
||||
method: 'getUserActivity';
|
||||
request: {
|
||||
jwt: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
activities: data.IActivityLog[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as plugins from '../loint-reception.plugins.js';
|
||||
|
||||
export interface ITag_LolePubapi
|
||||
extends plugins.typedRequestInterfaces.implementsTag<
|
||||
plugins.typedRequestInterfaces.ITag,
|
||||
ITag_LolePubapi
|
||||
> {
|
||||
name: 'lole-reception';
|
||||
payload: {
|
||||
backendToken: string;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"order": 1
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { App } from '../ts/reception/classes.app.js';
|
||||
import { Organization } from '../ts/reception/classes.organization.js';
|
||||
import { Role } from '../ts/reception/classes.role.js';
|
||||
import { User } from '../ts/reception/classes.user.js';
|
||||
|
||||
export type TSeedScenario = 'admin' | 'workspace' | 'globalApps';
|
||||
|
||||
export interface ISeedOptions {
|
||||
scenario: TSeedScenario;
|
||||
adminEmail: string;
|
||||
adminPassword: string;
|
||||
adminName: string;
|
||||
organizationName: string;
|
||||
organizationSlug: string;
|
||||
}
|
||||
|
||||
export class SeedRunner {
|
||||
public qenv = new plugins.qenv.Qenv('./', './.nogit', false);
|
||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
|
||||
public CUser = plugins.smartdata.setDefaultManagerForDoc(this, User);
|
||||
public COrganization = plugins.smartdata.setDefaultManagerForDoc(this, Organization);
|
||||
public CRole = plugins.smartdata.setDefaultManagerForDoc(this, Role);
|
||||
public CApp = plugins.smartdata.setDefaultManagerForDoc(this, App);
|
||||
|
||||
public get db() {
|
||||
return this.smartdataDb;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
const mongoDbUrl = await this.qenv.getEnvVarOnDemandStrict('MONGODB_URL');
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({ mongoDbUrl });
|
||||
await this.smartdataDb.init();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.smartdataDb) {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
public async seed(optionsArg: ISeedOptions) {
|
||||
if (optionsArg.scenario === 'globalApps') {
|
||||
await this.seedGlobalApps();
|
||||
return;
|
||||
}
|
||||
|
||||
const adminUser = await this.seedAdminUser(optionsArg);
|
||||
const organization = await this.seedOrganization(optionsArg, adminUser.id);
|
||||
await this.seedOwnerRole(adminUser.id, organization.id);
|
||||
await this.seedGlobalApps();
|
||||
|
||||
if (optionsArg.scenario === 'workspace') {
|
||||
await this.seedWorkspaceUsers(organization.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedAdminUser(optionsArg: ISeedOptions) {
|
||||
let adminUser = await this.CUser.getInstance({
|
||||
data: {
|
||||
email: optionsArg.adminEmail,
|
||||
},
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
adminUser = await this.CUser.createNewUserForUserData({
|
||||
name: optionsArg.adminName,
|
||||
username: optionsArg.adminEmail,
|
||||
email: optionsArg.adminEmail,
|
||||
password: optionsArg.adminPassword,
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
});
|
||||
}
|
||||
|
||||
adminUser.data.name = optionsArg.adminName;
|
||||
adminUser.data.username = optionsArg.adminEmail;
|
||||
adminUser.data.email = optionsArg.adminEmail;
|
||||
adminUser.data.status = 'active';
|
||||
adminUser.data.isGlobalAdmin = true;
|
||||
adminUser.data.passwordHash = await this.CUser.hashPassword(optionsArg.adminPassword);
|
||||
await adminUser.save();
|
||||
|
||||
return adminUser;
|
||||
}
|
||||
|
||||
private async seedOrganization(optionsArg: ISeedOptions, adminUserIdArg: string) {
|
||||
let organization = await this.COrganization.getInstance({
|
||||
data: {
|
||||
slug: optionsArg.organizationSlug,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
organization = await this.COrganization.createNewOrganizationForUser(
|
||||
this as any,
|
||||
adminUserIdArg,
|
||||
optionsArg.organizationName,
|
||||
optionsArg.organizationSlug,
|
||||
);
|
||||
}
|
||||
|
||||
organization.data.name = optionsArg.organizationName;
|
||||
organization.data.slug = optionsArg.organizationSlug;
|
||||
organization.data.roleIds = organization.data.roleIds || [];
|
||||
this.seedDefaultOrgRoleDefinitions(organization);
|
||||
await organization.save();
|
||||
|
||||
const adminUser = await this.CUser.getInstance({ id: adminUserIdArg });
|
||||
if (adminUser && !adminUser.data.connectedOrgs.includes(organization.id)) {
|
||||
adminUser.data.connectedOrgs.push(organization.id);
|
||||
await adminUser.save();
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
private seedDefaultOrgRoleDefinitions(organizationArg: Organization) {
|
||||
const now = Date.now();
|
||||
const defaultRoleDefinitions = [
|
||||
{ key: 'finance', name: 'Finance', description: 'Billing, invoice, and procurement access.' },
|
||||
{ key: 'engineering', name: 'Engineering', description: 'Developer and infrastructure access.' },
|
||||
{ key: 'support', name: 'Support', description: 'Customer and incident support access.' },
|
||||
{ key: 'contractor', name: 'Contractor', description: 'Limited temporary external access.' },
|
||||
];
|
||||
const roleDefinitions = organizationArg.data.roleDefinitions || [];
|
||||
for (const defaultRoleDefinition of defaultRoleDefinitions) {
|
||||
const existingRoleDefinition = roleDefinitions.find((roleDefinitionArg) => roleDefinitionArg.key === defaultRoleDefinition.key);
|
||||
if (existingRoleDefinition) {
|
||||
existingRoleDefinition.name = defaultRoleDefinition.name;
|
||||
existingRoleDefinition.description = defaultRoleDefinition.description;
|
||||
existingRoleDefinition.updatedAt = now;
|
||||
} else {
|
||||
roleDefinitions.push({
|
||||
...defaultRoleDefinition,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
organizationArg.data.roleDefinitions = roleDefinitions.sort((leftArg, rightArg) => leftArg.name.localeCompare(rightArg.name));
|
||||
}
|
||||
|
||||
private async seedOwnerRole(userIdArg: string, organizationIdArg: string) {
|
||||
let role = await this.CRole.getInstance({
|
||||
data: {
|
||||
userId: userIdArg,
|
||||
organizationId: organizationIdArg,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
role = new this.CRole();
|
||||
role.id = plugins.smartunique.shortId();
|
||||
role.data = {
|
||||
userId: userIdArg,
|
||||
organizationId: organizationIdArg,
|
||||
roles: ['owner', 'admin'],
|
||||
};
|
||||
} else {
|
||||
role.data.roles = [...new Set([...role.data.roles, 'owner', 'admin'])];
|
||||
}
|
||||
await role.save();
|
||||
|
||||
const organization = await this.COrganization.getInstance({ id: organizationIdArg });
|
||||
if (organization && !organization.data.roleIds.includes(role.id)) {
|
||||
organization.data.roleIds.push(role.id);
|
||||
await organization.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async seedWorkspaceUsers(organizationIdArg: string) {
|
||||
const users = [
|
||||
{
|
||||
email: 'alex@idp.global',
|
||||
name: 'Alex Mercer',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
email: 'jane@idp.global',
|
||||
name: 'Jane Doe',
|
||||
roles: ['editor'],
|
||||
},
|
||||
{
|
||||
email: 'sam@idp.global',
|
||||
name: 'Sam Chen',
|
||||
roles: ['viewer'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const userData of users) {
|
||||
let user = await this.CUser.getInstance({
|
||||
data: {
|
||||
email: userData.email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
user = await this.CUser.createNewUserForUserData({
|
||||
name: userData.name,
|
||||
username: userData.email,
|
||||
email: userData.email,
|
||||
password: 'idp.global',
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
});
|
||||
}
|
||||
user.data.name = userData.name;
|
||||
user.data.username = userData.email;
|
||||
user.data.status = 'active';
|
||||
user.data.passwordHash = await this.CUser.hashPassword('idp.global');
|
||||
if (!user.data.connectedOrgs.includes(organizationIdArg)) {
|
||||
user.data.connectedOrgs.push(organizationIdArg);
|
||||
}
|
||||
await user.save();
|
||||
|
||||
let role = await this.CRole.getInstance({
|
||||
data: {
|
||||
userId: user.id,
|
||||
organizationId: organizationIdArg,
|
||||
},
|
||||
});
|
||||
if (!role) {
|
||||
role = new this.CRole();
|
||||
role.id = plugins.smartunique.shortId();
|
||||
}
|
||||
role.data = {
|
||||
userId: user.id,
|
||||
organizationId: organizationIdArg,
|
||||
roles: userData.roles,
|
||||
};
|
||||
await role.save();
|
||||
|
||||
const organization = await this.COrganization.getInstance({ id: organizationIdArg });
|
||||
if (organization && !organization.data.roleIds.includes(role.id)) {
|
||||
organization.data.roleIds.push(role.id);
|
||||
await organization.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async seedGlobalApps() {
|
||||
const defaultGlobalApps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl: string;
|
||||
appUrl: string;
|
||||
clientId: string;
|
||||
redirectUris: string[];
|
||||
category: string;
|
||||
}> = [
|
||||
{
|
||||
id: 'app-foss-global',
|
||||
name: 'foss.global',
|
||||
description: 'Open Source Package Registry and Collaboration Platform',
|
||||
logoUrl: 'https://foss.global/assets/logo.png',
|
||||
appUrl: 'https://foss.global',
|
||||
clientId: 'foss-global-client',
|
||||
redirectUris: ['https://foss.global/auth/callback'],
|
||||
category: 'Development',
|
||||
},
|
||||
{
|
||||
id: 'app-task-vc',
|
||||
name: 'task.vc',
|
||||
description: 'Task Management and Project Collaboration',
|
||||
logoUrl: 'https://task.vc/assets/logo.png',
|
||||
appUrl: 'https://task.vc',
|
||||
clientId: 'task-vc-client',
|
||||
redirectUris: ['https://task.vc/auth/callback'],
|
||||
category: 'Productivity',
|
||||
},
|
||||
{
|
||||
id: 'app-hetzner-cloud',
|
||||
name: 'Hetzner Cloud',
|
||||
description: 'Cloud infrastructure console access',
|
||||
logoUrl: 'https://www.hetzner.com/favicon.ico',
|
||||
appUrl: 'https://console.hetzner.cloud',
|
||||
clientId: 'hetzner-cloud-client',
|
||||
redirectUris: ['https://console.hetzner.cloud/oauth/callback'],
|
||||
category: 'Infrastructure',
|
||||
},
|
||||
];
|
||||
|
||||
for (const appData of defaultGlobalApps) {
|
||||
let app = await this.CApp.getInstance({ id: appData.id });
|
||||
if (!app) {
|
||||
app = new this.CApp();
|
||||
app.id = appData.id;
|
||||
app.type = 'global';
|
||||
}
|
||||
app.data = {
|
||||
name: appData.name,
|
||||
description: appData.description,
|
||||
logoUrl: appData.logoUrl,
|
||||
appUrl: appData.appUrl,
|
||||
oauthCredentials: {
|
||||
clientId: appData.clientId,
|
||||
clientSecretHash: '',
|
||||
redirectUris: appData.redirectUris,
|
||||
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
},
|
||||
isActive: true,
|
||||
category: appData.category,
|
||||
createdAt: Date.now(),
|
||||
createdByUserId: 'seed',
|
||||
};
|
||||
await app.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { runCli } from './index.js';
|
||||
|
||||
await runCli();
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { SeedRunner, type ISeedOptions, type TSeedScenario } from './classes.seedrunner.js';
|
||||
|
||||
export { SeedRunner } from './classes.seedrunner.js';
|
||||
|
||||
const defaults: ISeedOptions = {
|
||||
scenario: 'workspace',
|
||||
adminEmail: 'admin@idp.global',
|
||||
adminPassword: 'idp.global',
|
||||
adminName: 'IDP Global Admin',
|
||||
organizationName: 'Lossless GmbH',
|
||||
organizationSlug: 'lossless',
|
||||
};
|
||||
|
||||
const scenarios: TSeedScenario[] = ['admin', 'workspace', 'globalApps'];
|
||||
|
||||
const getArgValue = (nameArg: string) => {
|
||||
const prefix = `--${nameArg}=`;
|
||||
const prefixedArg = plugins.process.argv.find((arg) => arg.startsWith(prefix));
|
||||
if (prefixedArg) {
|
||||
return prefixedArg.slice(prefix.length);
|
||||
}
|
||||
const argIndex = plugins.process.argv.indexOf(`--${nameArg}`);
|
||||
return argIndex >= 0 ? plugins.process.argv[argIndex + 1] : undefined;
|
||||
};
|
||||
|
||||
const getScenarioFromArgs = (): TSeedScenario | null => {
|
||||
const scenarioArg = getArgValue('scenario') as TSeedScenario | undefined;
|
||||
return scenarioArg && scenarios.includes(scenarioArg) ? scenarioArg : null;
|
||||
};
|
||||
|
||||
export const runCli = async () => {
|
||||
const skipPrompts = plugins.process.argv.includes('--yes') || plugins.process.argv.includes('-y');
|
||||
if (skipPrompts) {
|
||||
const scenario = getScenarioFromArgs() || defaults.scenario;
|
||||
const runner = new SeedRunner();
|
||||
await runner.start();
|
||||
try {
|
||||
await runner.seed({
|
||||
...defaults,
|
||||
scenario,
|
||||
adminEmail: getArgValue('adminEmail') || plugins.process.env.IDP_DEMO_ADMIN_EMAIL || defaults.adminEmail,
|
||||
adminPassword: getArgValue('adminPassword') || plugins.process.env.IDP_DEMO_ADMIN_PASSWORD || defaults.adminPassword,
|
||||
adminName: getArgValue('adminName') || plugins.process.env.IDP_DEMO_ADMIN_NAME || defaults.adminName,
|
||||
organizationName: getArgValue('organizationName') || plugins.process.env.IDP_DEMO_ORG_NAME || defaults.organizationName,
|
||||
organizationSlug: getArgValue('organizationSlug') || plugins.process.env.IDP_DEMO_ORG_SLUG || defaults.organizationSlug,
|
||||
});
|
||||
console.log('Seed complete.');
|
||||
} finally {
|
||||
await runner.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
|
||||
const scenarioAnswer = await interact.askQuestion({
|
||||
name: 'scenario',
|
||||
type: 'list',
|
||||
message: 'Which seed scenario do you want to apply?',
|
||||
default: defaults.scenario,
|
||||
choices: [
|
||||
{ name: 'Demo workspace (admin, org, demo users, global apps)', value: 'workspace' },
|
||||
{ name: 'Admin only (admin, org, global apps)', value: 'admin' },
|
||||
{ name: 'Global apps only', value: 'globalApps' },
|
||||
],
|
||||
});
|
||||
|
||||
const scenario = scenarioAnswer.value as TSeedScenario;
|
||||
const options: ISeedOptions = {
|
||||
...defaults,
|
||||
scenario,
|
||||
};
|
||||
|
||||
if (scenario !== 'globalApps') {
|
||||
options.adminEmail = (await interact.askQuestion({
|
||||
name: 'adminEmail',
|
||||
type: 'input',
|
||||
message: 'Admin email:',
|
||||
default: defaults.adminEmail,
|
||||
})).value as string;
|
||||
|
||||
options.adminPassword = (await interact.askQuestion({
|
||||
name: 'adminPassword',
|
||||
type: 'password',
|
||||
message: 'Admin password:',
|
||||
default: defaults.adminPassword,
|
||||
})).value as string;
|
||||
|
||||
options.adminName = (await interact.askQuestion({
|
||||
name: 'adminName',
|
||||
type: 'input',
|
||||
message: 'Admin display name:',
|
||||
default: defaults.adminName,
|
||||
})).value as string;
|
||||
|
||||
options.organizationName = (await interact.askQuestion({
|
||||
name: 'organizationName',
|
||||
type: 'input',
|
||||
message: 'Organization name:',
|
||||
default: defaults.organizationName,
|
||||
})).value as string;
|
||||
|
||||
options.organizationSlug = (await interact.askQuestion({
|
||||
name: 'organizationSlug',
|
||||
type: 'input',
|
||||
message: 'Organization slug:',
|
||||
default: defaults.organizationSlug,
|
||||
})).value as string;
|
||||
}
|
||||
|
||||
const confirmAnswer = await interact.askQuestion({
|
||||
name: 'confirm',
|
||||
type: 'confirm',
|
||||
message: `Apply ${scenario} seed data to the configured database?`,
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (!confirmAnswer.value) {
|
||||
console.log('Seed cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const runner = new SeedRunner();
|
||||
await runner.start();
|
||||
try {
|
||||
await runner.seed(options);
|
||||
console.log('Seed complete.');
|
||||
if (scenario !== 'globalApps') {
|
||||
console.log(`Admin email: ${options.adminEmail}`);
|
||||
console.log(`Admin password: ${options.adminPassword}`);
|
||||
}
|
||||
} finally {
|
||||
await runner.stop();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
// Node scope
|
||||
import * as process from 'node:process';
|
||||
export { process };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
import * as smartinteract from '@push.rocks/smartinteract';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
|
||||
export { qenv, smartdata, smartinteract, smartunique };
|
||||
@@ -0,0 +1,47 @@
|
||||
# ts_seed
|
||||
|
||||
Interactive development seed tooling for local idp.global databases.
|
||||
|
||||
## 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.
|
||||
|
||||
Run from the app repository root:
|
||||
|
||||
```bash
|
||||
pnpm run seed
|
||||
```
|
||||
|
||||
The CLI reads the same qenv setup as the app, including `.nogit/env.json`, and asks before writing data.
|
||||
|
||||
Available scenarios:
|
||||
|
||||
- Demo workspace: global admin, organization, demo users, and global OAuth apps.
|
||||
- Admin only: global admin, organization, and global OAuth apps.
|
||||
- Global apps only: first-party/global OAuth app records.
|
||||
|
||||
Default development admin credentials when accepted unchanged:
|
||||
|
||||
- Email: `admin@idp.global`
|
||||
- Password: `idp.global`
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 6
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.19.1',
|
||||
version: '1.21.1',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
+793
-133
@@ -1,27 +1,19 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as states from '../../states/accountstate.js';
|
||||
import { IdpState } from '../../states/idp.state.js';
|
||||
import { BulkInviteModal } from './bulk-invite-modal.js';
|
||||
import { CreateOrgModal } from './create-org-modal.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
property,
|
||||
html,
|
||||
cssManager,
|
||||
unsafeCSS,
|
||||
css,
|
||||
state,
|
||||
type TemplateResult
|
||||
} 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 '../../../ts/00_commitinfo_data.js';
|
||||
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'idp-accountcontent': IdpAccountContent;
|
||||
@@ -32,6 +24,61 @@ declare global {
|
||||
export class IdpAccountContent extends DeesElement {
|
||||
|
||||
public subrouter: plugins.deesDomtools.plugins.smartrouter.SmartRouter;
|
||||
private dataLoadRun = 0;
|
||||
|
||||
@state()
|
||||
private accessor adminPage: plugins.idpCatalog.IdpAdminShell['page'] = 'overview';
|
||||
|
||||
@state()
|
||||
private accessor adminUser: plugins.idpCatalog.IIdpAdminUser = {
|
||||
name: 'Loading account',
|
||||
email: '',
|
||||
};
|
||||
|
||||
@state()
|
||||
private accessor adminOrgs: plugins.idpCatalog.IIdpAdminOrg[] = [];
|
||||
|
||||
@state()
|
||||
private accessor selectedOrgId = '';
|
||||
|
||||
@state()
|
||||
private accessor globalAdmin = false;
|
||||
|
||||
@state()
|
||||
private accessor dataLoading = false;
|
||||
|
||||
@state()
|
||||
private accessor dataError = '';
|
||||
|
||||
@state()
|
||||
private accessor sessions: plugins.idpCatalog.IIdpAdminSession[] = [];
|
||||
|
||||
@state()
|
||||
private accessor activities: plugins.idpCatalog.IIdpAdminActivity[] = [];
|
||||
|
||||
@state()
|
||||
private accessor orgMembers: plugins.idpCatalog.IIdpAdminMember[] = [];
|
||||
|
||||
@state()
|
||||
private accessor orgInvitations: plugins.idpCatalog.IIdpAdminInvitation[] = [];
|
||||
|
||||
@state()
|
||||
private accessor orgRoleDefinitions: plugins.idpCatalog.IIdpAdminOrgRoleDefinition[] = [];
|
||||
|
||||
@state()
|
||||
private accessor orgApps: plugins.idpCatalog.IIdpAdminApp[] = [];
|
||||
|
||||
@state()
|
||||
private accessor adminApps: plugins.idpCatalog.IIdpAdminApp[] = [];
|
||||
|
||||
@state()
|
||||
private accessor passportDevices: plugins.idpCatalog.IIdpAdminPassportDevice[] = [];
|
||||
|
||||
@state()
|
||||
private accessor passportEnrollment: plugins.idpCatalog.IIdpAdminPassportEnrollment | null = null;
|
||||
|
||||
@state()
|
||||
private accessor credentialMessage = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -39,169 +86,782 @@ export class IdpAccountContent extends DeesElement {
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
accountDesignTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
background: var(--idp-bg, hsl(240 10% 3.9%));
|
||||
}
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
.main {
|
||||
position: absolute;
|
||||
idp-admin-shell {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
bottom: 0px;
|
||||
}
|
||||
|
||||
lele-accountnavigation {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
height: 100vh;
|
||||
width: 200px;
|
||||
}
|
||||
.viewcontainer {
|
||||
will-change: transform;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
width: calc(100vw - 200px);
|
||||
height: 100vh;
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior: contain;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.viewcontainer.changing {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<style></style>
|
||||
<div class="main">
|
||||
<lele-accountnavigation></lele-accountnavigation>
|
||||
<div class="viewcontainer">
|
||||
<!--<lele-accountview-subscription></lele-accountview-subscription>-->
|
||||
</div>
|
||||
</div>
|
||||
<idp-admin-shell
|
||||
.page=${this.adminPage}
|
||||
.user=${this.adminUser}
|
||||
.orgs=${this.adminOrgs}
|
||||
.selectedOrgId=${this.selectedOrgId}
|
||||
.globalAdmin=${this.globalAdmin}
|
||||
.dataLoading=${this.dataLoading}
|
||||
.dataError=${this.dataError}
|
||||
.sessions=${this.sessions}
|
||||
.activities=${this.activities}
|
||||
.orgMembers=${this.orgMembers}
|
||||
.orgInvitations=${this.orgInvitations}
|
||||
.orgRoleDefinitions=${this.orgRoleDefinitions}
|
||||
.orgApps=${this.orgApps}
|
||||
.adminApps=${this.adminApps}
|
||||
.passportDevices=${this.passportDevices}
|
||||
.passportEnrollment=${this.passportEnrollment}
|
||||
.credentialMessage=${this.credentialMessage}
|
||||
@idp-admin-navigate=${this.handleAdminNavigate}
|
||||
@idp-admin-org-select=${this.handleOrgSelect}
|
||||
@idp-admin-org-create=${this.handleOrgCreate}
|
||||
@idp-admin-org-update=${this.handleOrgUpdate}
|
||||
@idp-admin-org-transfer=${this.handleOrgTransfer}
|
||||
@idp-admin-org-delete=${this.handleOrgDelete}
|
||||
@idp-admin-session-revoke=${this.handleSessionRevoke}
|
||||
@idp-admin-app-toggle=${this.handleAppToggle}
|
||||
@idp-admin-password-change=${this.handlePasswordChange}
|
||||
@idp-admin-passport-enroll=${this.handlePassportEnroll}
|
||||
@idp-admin-passport-revoke=${this.handlePassportRevoke}
|
||||
@idp-admin-member-invite=${this.handleMemberInvite}
|
||||
@idp-admin-member-remove=${this.handleMemberRemove}
|
||||
@idp-admin-member-roles-update=${this.handleMemberRolesUpdate}
|
||||
@idp-admin-invitation-resend=${this.handleInvitationResend}
|
||||
@idp-admin-invitation-cancel=${this.handleInvitationCancel}
|
||||
@idp-admin-org-role-upsert=${this.handleOrgRoleUpsert}
|
||||
@idp-admin-org-role-delete=${this.handleOrgRoleDelete}
|
||||
@idp-admin-app-role-mappings-update=${this.handleAppRoleMappingsUpdate}
|
||||
></idp-admin-shell>
|
||||
`;
|
||||
}
|
||||
|
||||
private setAdminPage(pageArg: plugins.idpCatalog.IdpAdminShell['page']) {
|
||||
this.adminPage = pageArg;
|
||||
if (this.subrouter) {
|
||||
void this.loadAdminShellData();
|
||||
}
|
||||
}
|
||||
|
||||
private getSelectedOrgSlug(): string {
|
||||
const currentState = states.accountState.getState();
|
||||
const selectedOrg = currentState.selectedOrg
|
||||
|| currentState.organizations.find((orgArg) => orgArg.id === this.selectedOrgId)
|
||||
|| currentState.organizations[0];
|
||||
return selectedOrg?.data?.slug || this.adminOrgs.find((orgArg) => orgArg.id === this.selectedOrgId)?.slug || this.adminOrgs[0]?.slug || '';
|
||||
}
|
||||
|
||||
private getPathForPage(pageArg: plugins.idpCatalog.IdpAdminShell['page']): string | null {
|
||||
const orgSlug = this.getSelectedOrgSlug();
|
||||
const orgPath = (suffixArg = '') => orgSlug ? `/org/${orgSlug}${suffixArg}` : null;
|
||||
|
||||
const pageMap: Record<plugins.idpCatalog.IdpAdminShell['page'], string | null> = {
|
||||
overview: '/overview',
|
||||
profile: '/account/profile',
|
||||
security: '/account/security',
|
||||
sessions: '/account/sessions',
|
||||
apps: '/account/apps',
|
||||
'org-general': orgPath(),
|
||||
'org-settings': orgPath('/settings'),
|
||||
'org-members': orgPath('/users'),
|
||||
'org-apps': orgPath('/apps'),
|
||||
support: '/support',
|
||||
'ga-users': '/admin/users',
|
||||
'ga-orgs': '/admin/orgs',
|
||||
'ga-apps': '/admin/apps',
|
||||
};
|
||||
|
||||
return pageMap[pageArg];
|
||||
}
|
||||
|
||||
private pushDashPath(pathArg: string) {
|
||||
const normalizedPath = pathArg || '';
|
||||
const absolutePath = `/dash${normalizedPath}`.replace(/\/$/, '') || '/dash';
|
||||
if (window.location.pathname.replace(/\/$/, '') === absolutePath) {
|
||||
return;
|
||||
}
|
||||
this.subrouter.pushUrl(normalizedPath);
|
||||
}
|
||||
|
||||
private async handleAdminNavigate(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminNavigateEventDetail>) {
|
||||
const page = eventArg.detail.page;
|
||||
this.setAdminPage(page);
|
||||
const path = this.getPathForPage(page);
|
||||
if (path !== null) {
|
||||
this.pushDashPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOrgSelect(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminOrgSelectEventDetail>) {
|
||||
const currentState = states.accountState.getState();
|
||||
const selectedOrg = currentState.organizations.find((orgArg) => orgArg.id === eventArg.detail.orgId)
|
||||
|| currentState.organizations.find((orgArg) => orgArg.data.slug === eventArg.detail.org?.slug);
|
||||
|
||||
this.selectedOrgId = eventArg.detail.orgId;
|
||||
this.setAdminPage('org-general');
|
||||
|
||||
if (selectedOrg) {
|
||||
await states.accountState.dispatchAction(states.setSelectedOrg, selectedOrg);
|
||||
this.pushDashPath(`/org/${selectedOrg.data.slug}`);
|
||||
} else if (eventArg.detail.org?.slug) {
|
||||
this.pushDashPath(`/org/${eventArg.detail.org.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOrgCreate() {
|
||||
const org = await CreateOrgModal.show();
|
||||
if (!org) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyAccountState();
|
||||
this.selectedOrgId = org.id;
|
||||
this.setAdminPage('org-general');
|
||||
this.pushDashPath(`/org/${org.data.slug}`);
|
||||
}
|
||||
|
||||
private async handleOrgUpdate(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminOrgUpdateEventDetail>) {
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateOrganization>('updateOrganization');
|
||||
const response = await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
organizationId: eventArg.detail.organizationId,
|
||||
name: eventArg.detail.name,
|
||||
slug: eventArg.detail.slug,
|
||||
confirmationText: eventArg.detail.confirmationText,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Organization update failed.');
|
||||
}
|
||||
|
||||
await states.accountState.dispatchAction(states.getOrganizationsAction, null);
|
||||
const refreshedOrg = states.accountState.getState().organizations.find((orgArg) => orgArg.id === response.organization.id) || response.organization;
|
||||
await states.accountState.dispatchAction(states.setSelectedOrg, refreshedOrg);
|
||||
this.applyAccountState();
|
||||
this.selectedOrgId = refreshedOrg.id;
|
||||
this.setAdminPage('org-settings');
|
||||
this.pushDashPath(`/org/${refreshedOrg.data.slug}/settings`);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleOrgTransfer(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminOrgTransferEventDetail>) {
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_TransferOwnership>('transferOwnership');
|
||||
const response = await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
organizationId: eventArg.detail.organizationId,
|
||||
newOwnerId: eventArg.detail.newOwnerId,
|
||||
confirmationText: eventArg.detail.confirmationText,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Ownership transfer failed.');
|
||||
}
|
||||
|
||||
await states.accountState.dispatchAction(states.getOrganizationsAction, null);
|
||||
const refreshedOrg = states.accountState.getState().organizations.find((orgArg) => orgArg.id === eventArg.detail.organizationId);
|
||||
if (refreshedOrg) {
|
||||
await states.accountState.dispatchAction(states.setSelectedOrg, refreshedOrg);
|
||||
this.selectedOrgId = refreshedOrg.id;
|
||||
}
|
||||
this.applyAccountState();
|
||||
this.setAdminPage('org-settings');
|
||||
});
|
||||
}
|
||||
|
||||
private async handleOrgDelete(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminOrgDeleteEventDetail>) {
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteOrganization>('deleteOrganization');
|
||||
const response = await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
organizationId: eventArg.detail.organizationId,
|
||||
confirmationText: eventArg.detail.confirmationText,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Organization deletion failed.');
|
||||
}
|
||||
|
||||
await states.accountState.dispatchAction(states.getOrganizationsAction, null);
|
||||
const nextOrg = states.accountState.getState().organizations[0] || null;
|
||||
if (nextOrg) {
|
||||
await states.accountState.dispatchAction(states.setSelectedOrg, nextOrg);
|
||||
} else {
|
||||
await states.accountState.dispatchAction(states.setSelectedOrg, null as any);
|
||||
}
|
||||
this.selectedOrgId = nextOrg?.id || '';
|
||||
this.applyAccountState();
|
||||
this.setAdminPage('overview');
|
||||
this.pushDashPath('/overview');
|
||||
});
|
||||
}
|
||||
|
||||
private async syncSelectedOrgFromPath() {
|
||||
const orgSlug = window.location.pathname.match(/^\/dash\/org\/([^/]+)/)?.[1];
|
||||
if (!orgSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = states.accountState.getState();
|
||||
const selectedOrg = currentState.organizations.find((orgArg) => orgArg.data.slug === orgSlug);
|
||||
if (!selectedOrg) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedOrgId = selectedOrg.id;
|
||||
if (currentState.selectedOrg?.id !== selectedOrg.id) {
|
||||
await states.accountState.dispatchAction(states.setSelectedOrg, selectedOrg);
|
||||
}
|
||||
}
|
||||
|
||||
private applyAccountState() {
|
||||
const currentState = states.accountState.getState();
|
||||
const user = currentState.user;
|
||||
|
||||
if (user) {
|
||||
this.adminUser = {
|
||||
name: user.data.name || user.data.username || user.data.email,
|
||||
email: user.data.email,
|
||||
username: user.data.username,
|
||||
mobileNumber: user.data.mobileNumber,
|
||||
status: user.data.status,
|
||||
};
|
||||
this.globalAdmin = Boolean(user.data.isGlobalAdmin);
|
||||
}
|
||||
|
||||
this.adminOrgs = currentState.organizations.map((orgArg) => {
|
||||
const role = currentState.roles.find((roleArg) => roleArg.data.organizationId === orgArg.id);
|
||||
return {
|
||||
id: orgArg.id,
|
||||
name: orgArg.data.name,
|
||||
slug: orgArg.data.slug,
|
||||
myRole: role?.data.roles?.[0] || 'member',
|
||||
};
|
||||
});
|
||||
|
||||
this.selectedOrgId = currentState.selectedOrg?.id || this.selectedOrgId || currentState.organizations[0]?.id || '';
|
||||
const selectedOrg = currentState.organizations.find((orgArg) => orgArg.id === this.selectedOrgId) || currentState.selectedOrg || currentState.organizations[0];
|
||||
this.orgRoleDefinitions = selectedOrg?.data.roleDefinitions || [];
|
||||
}
|
||||
|
||||
private async setOrgPage(pageArg: plugins.idpCatalog.IdpAdminShell['page']) {
|
||||
await this.syncSelectedOrgFromPath();
|
||||
this.setAdminPage(pageArg);
|
||||
}
|
||||
|
||||
private getSelectedOrganization(): plugins.idpInterfaces.data.IOrganization | null {
|
||||
const currentState = states.accountState.getState();
|
||||
return currentState.selectedOrg
|
||||
|| currentState.organizations.find((orgArg) => orgArg.id === this.selectedOrgId)
|
||||
|| currentState.organizations[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
private async loadSessions(idpStateArg: IdpState, jwtArg: string): Promise<plugins.idpCatalog.IIdpAdminSession[]> {
|
||||
const request = idpStateArg.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>('getUserSessions');
|
||||
const response = await request.fire({ jwt: jwtArg });
|
||||
return (response.sessions || []).map((sessionArg) => ({
|
||||
id: sessionArg.id,
|
||||
deviceName: sessionArg.deviceName,
|
||||
browser: sessionArg.browser,
|
||||
os: sessionArg.os,
|
||||
ip: sessionArg.ip,
|
||||
lastActive: sessionArg.lastActive,
|
||||
createdAt: sessionArg.createdAt,
|
||||
isCurrent: sessionArg.isCurrent,
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadActivities(idpStateArg: IdpState, jwtArg: string): Promise<plugins.idpCatalog.IIdpAdminActivity[]> {
|
||||
const request = idpStateArg.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>('getUserActivity');
|
||||
const response = await request.fire({ jwt: jwtArg, limit: 20 });
|
||||
return (response.activities || []).map((activityArg) => ({
|
||||
id: activityArg.id,
|
||||
action: activityArg.data.action,
|
||||
description: activityArg.data.metadata.description,
|
||||
timestamp: activityArg.data.timestamp,
|
||||
ip: activityArg.data.metadata.ip,
|
||||
targetType: activityArg.data.metadata.targetType,
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadOrgMembers(idpStateArg: IdpState, jwtArg: string, organizationIdArg: string): Promise<plugins.idpCatalog.IIdpAdminMember[]> {
|
||||
const currentState = states.accountState.getState();
|
||||
const request = idpStateArg.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>('getOrgMembers');
|
||||
const response = await request.fire({ jwt: jwtArg, organizationId: organizationIdArg });
|
||||
return (response.members || []).map((memberArg) => ({
|
||||
userId: memberArg.user.id,
|
||||
name: memberArg.user.data.name || memberArg.user.data.username || memberArg.user.data.email,
|
||||
email: memberArg.user.data.email,
|
||||
roles: memberArg.role.data.roles || [],
|
||||
isCurrentUser: currentState.user?.id === memberArg.user.id,
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadOrgInvitations(idpStateArg: IdpState, jwtArg: string, organizationIdArg: string): Promise<plugins.idpCatalog.IIdpAdminInvitation[]> {
|
||||
const request = idpStateArg.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgInvitations>('getOrgInvitations');
|
||||
const response = await request.fire({ jwt: jwtArg, organizationId: organizationIdArg });
|
||||
return (response.invitations || []).map((invitationArg) => {
|
||||
const orgRef = invitationArg.data.organizationRefs.find((refArg) => refArg.organizationId === organizationIdArg)
|
||||
|| invitationArg.data.organizationRefs[0];
|
||||
return {
|
||||
id: invitationArg.id,
|
||||
email: invitationArg.data.email,
|
||||
roles: orgRef?.roles || [],
|
||||
invitedAt: orgRef?.invitedAt || invitationArg.data.createdAt,
|
||||
expiresAt: invitationArg.data.expiresAt,
|
||||
status: invitationArg.data.status,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async loadOrgApps(idpStateArg: IdpState, jwtArg: string, organizationIdArg: string): Promise<plugins.idpCatalog.IIdpAdminApp[]> {
|
||||
const appsRequest = idpStateArg.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalApps>('getGlobalApps');
|
||||
const connectionsRequest = idpStateArg.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>('getAppConnections');
|
||||
const [appsResponse, connectionsResponse] = await Promise.all([
|
||||
appsRequest.fire({ jwt: jwtArg }),
|
||||
connectionsRequest.fire({ jwt: jwtArg, organizationId: organizationIdArg }),
|
||||
]);
|
||||
const activeConnectionMap = new Map((connectionsResponse.connections || [])
|
||||
.filter((connectionArg) => connectionArg.data.status === 'active')
|
||||
.map((connectionArg) => [connectionArg.data.appId, connectionArg]));
|
||||
return (appsResponse.apps || []).map((appArg) => ({
|
||||
id: appArg.id,
|
||||
name: appArg.data.name,
|
||||
description: appArg.data.description,
|
||||
logoUrl: appArg.data.logoUrl,
|
||||
appUrl: appArg.data.appUrl,
|
||||
category: appArg.data.category,
|
||||
type: appArg.type,
|
||||
status: appArg.data.isActive ? 'active' : 'inactive',
|
||||
isConnected: activeConnectionMap.has(appArg.id),
|
||||
roleMappings: activeConnectionMap.get(appArg.id)?.data.roleMappings || [],
|
||||
clientId: appArg.data.oauthCredentials.clientId,
|
||||
scopes: activeConnectionMap.get(appArg.id)?.data.grantedScopes || appArg.data.oauthCredentials.allowedScopes || [],
|
||||
grants: appArg.data.oauthCredentials.grantTypes || [],
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadAdminApps(idpStateArg: IdpState, jwtArg: string): Promise<plugins.idpCatalog.IIdpAdminApp[]> {
|
||||
if (!this.globalAdmin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const request = idpStateArg.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>('getGlobalAppStats');
|
||||
const response = await request.fire({ jwt: jwtArg });
|
||||
return (response.apps || []).map((entryArg) => ({
|
||||
id: entryArg.app.id,
|
||||
name: entryArg.app.data.name,
|
||||
description: entryArg.app.data.description,
|
||||
logoUrl: entryArg.app.data.logoUrl,
|
||||
appUrl: entryArg.app.data.appUrl,
|
||||
category: entryArg.app.data.category,
|
||||
type: entryArg.app.type,
|
||||
status: entryArg.app.data.isActive ? 'active' : 'inactive',
|
||||
connectionCount: entryArg.connectionCount,
|
||||
clientId: entryArg.app.data.oauthCredentials.clientId,
|
||||
scopes: entryArg.app.data.oauthCredentials.allowedScopes || [],
|
||||
grants: entryArg.app.data.oauthCredentials.grantTypes || [],
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadPassportDevices(idpStateArg: IdpState, jwtArg: string): Promise<plugins.idpCatalog.IIdpAdminPassportDevice[]> {
|
||||
const request = idpStateArg.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPassportDevices>('getPassportDevices');
|
||||
const response = await request.fire({ jwt: jwtArg });
|
||||
return (response.devices || []).map((deviceArg) => ({
|
||||
id: deviceArg.id,
|
||||
label: deviceArg.data.label,
|
||||
platform: deviceArg.data.platform,
|
||||
status: deviceArg.data.status,
|
||||
capabilities: deviceArg.data.capabilities,
|
||||
appVersion: deviceArg.data.appVersion,
|
||||
createdAt: deviceArg.data.createdAt,
|
||||
lastSeenAt: deviceArg.data.lastSeenAt,
|
||||
lastChallengeAt: deviceArg.data.lastChallengeAt,
|
||||
pushRegistered: Boolean(deviceArg.data.pushRegistration),
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadAdminShellData() {
|
||||
const currentRun = ++this.dataLoadRun;
|
||||
this.dataLoading = true;
|
||||
this.dataError = '';
|
||||
|
||||
try {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
const selectedOrg = this.getSelectedOrganization();
|
||||
const orgId = selectedOrg?.id || '';
|
||||
|
||||
const [sessions, activities, members, invitations, orgApps, adminApps, passportDevices] = await Promise.all([
|
||||
this.loadSessions(idpState, jwt).catch((error) => {
|
||||
console.error('Error loading sessions:', error);
|
||||
return this.sessions;
|
||||
}),
|
||||
this.loadActivities(idpState, jwt).catch((error) => {
|
||||
console.error('Error loading activity:', error);
|
||||
return this.activities;
|
||||
}),
|
||||
orgId ? this.loadOrgMembers(idpState, jwt, orgId).catch((error) => {
|
||||
console.error('Error loading org members:', error);
|
||||
return this.orgMembers;
|
||||
}) : Promise.resolve([]),
|
||||
orgId ? this.loadOrgInvitations(idpState, jwt, orgId).catch((error) => {
|
||||
console.error('Error loading org invitations:', error);
|
||||
return this.orgInvitations;
|
||||
}) : Promise.resolve([]),
|
||||
orgId ? this.loadOrgApps(idpState, jwt, orgId).catch((error) => {
|
||||
console.error('Error loading org apps:', error);
|
||||
return this.orgApps;
|
||||
}) : Promise.resolve([]),
|
||||
this.loadAdminApps(idpState, jwt).catch((error) => {
|
||||
console.error('Error loading admin apps:', error);
|
||||
return this.adminApps;
|
||||
}),
|
||||
this.loadPassportDevices(idpState, jwt).catch((error) => {
|
||||
console.error('Error loading passport devices:', error);
|
||||
return this.passportDevices;
|
||||
}),
|
||||
]);
|
||||
|
||||
if (currentRun !== this.dataLoadRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessions = sessions;
|
||||
this.activities = activities;
|
||||
this.orgMembers = members;
|
||||
this.orgInvitations = invitations;
|
||||
this.orgApps = orgApps;
|
||||
this.adminApps = adminApps;
|
||||
this.passportDevices = passportDevices;
|
||||
} catch (error) {
|
||||
console.error('Error loading admin shell data:', error);
|
||||
if (currentRun === this.dataLoadRun) {
|
||||
this.dataError = error instanceof Error ? error.message : 'Failed to load admin console data.';
|
||||
}
|
||||
} finally {
|
||||
if (currentRun === this.dataLoadRun) {
|
||||
this.dataLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runAdminAction(actionArg: () => Promise<void>) {
|
||||
this.dataError = '';
|
||||
try {
|
||||
await actionArg();
|
||||
await this.loadAdminShellData();
|
||||
} catch (error) {
|
||||
console.error('Admin console action failed:', error);
|
||||
this.dataError = error instanceof Error ? error.message : 'Action failed. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSessionRevoke(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminSessionEventDetail>) {
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>('revokeSession');
|
||||
await request.fire({ jwt: await idpState.idpClient.getJwt(), sessionId: eventArg.detail.sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAppToggle(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminAppToggleEventDetail>) {
|
||||
const selectedOrg = this.getSelectedOrganization();
|
||||
if (!selectedOrg) {
|
||||
this.dataError = 'Select an organisation before changing app connections.';
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ToggleAppConnection>('toggleAppConnection');
|
||||
await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
organizationId: selectedOrg.id,
|
||||
appId: eventArg.detail.appId,
|
||||
action: eventArg.detail.connected ? 'connect' : 'disconnect',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handlePasswordChange(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminPasswordChangeEventDetail>) {
|
||||
const email = states.accountState.getState().user?.data.email;
|
||||
if (!email) {
|
||||
this.credentialMessage = '';
|
||||
this.dataError = 'Cannot change password before account data is loaded.';
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetNewPassword>('setNewPassword');
|
||||
const response = await request.fire({
|
||||
email,
|
||||
oldPassword: eventArg.detail.currentPassword,
|
||||
newPassword: eventArg.detail.newPassword,
|
||||
});
|
||||
if (response.status !== 'ok') {
|
||||
throw new Error('Password change failed.');
|
||||
}
|
||||
this.credentialMessage = 'Password changed successfully.';
|
||||
});
|
||||
}
|
||||
|
||||
private async handlePassportEnroll(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminPassportEnrollmentEventDetail>) {
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreatePassportEnrollmentChallenge>('createPassportEnrollmentChallenge');
|
||||
const response = await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
deviceLabel: eventArg.detail.deviceLabel,
|
||||
platform: 'web',
|
||||
capabilities: {
|
||||
gps: false,
|
||||
nfc: false,
|
||||
push: false,
|
||||
},
|
||||
});
|
||||
this.passportEnrollment = response;
|
||||
this.credentialMessage = 'Passport enrollment challenge created.';
|
||||
});
|
||||
}
|
||||
|
||||
private async handlePassportRevoke(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminPassportDeviceEventDetail>) {
|
||||
const device = this.passportDevices.find((deviceArg) => deviceArg.id === eventArg.detail.deviceId);
|
||||
if (!device || !confirm(`Revoke passport device ${device.label}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokePassportDevice>('revokePassportDevice');
|
||||
await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
deviceId: eventArg.detail.deviceId,
|
||||
});
|
||||
this.credentialMessage = 'Passport device revoked.';
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMemberInvite() {
|
||||
const selectedOrg = this.getSelectedOrganization();
|
||||
if (!selectedOrg) {
|
||||
this.dataError = 'Select an organisation before inviting members.';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await BulkInviteModal.show({
|
||||
organizationId: selectedOrg.id,
|
||||
organizationName: selectedOrg.data.name,
|
||||
});
|
||||
if (result?.invitedCount) {
|
||||
await this.loadAdminShellData();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMemberRemove(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminMemberEventDetail>) {
|
||||
const selectedOrg = this.getSelectedOrganization();
|
||||
const member = this.orgMembers.find((memberArg) => memberArg.userId === eventArg.detail.userId);
|
||||
if (!selectedOrg || !member || !confirm(`Remove ${member.name} from ${selectedOrg.data.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RemoveMember>('removeMember');
|
||||
await request.fire({ jwt: await idpState.idpClient.getJwt(), organizationId: selectedOrg.id, userId: member.userId });
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMemberRolesUpdate(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminMemberRolesEventDetail>) {
|
||||
const selectedOrg = this.getSelectedOrganization();
|
||||
if (!selectedOrg) {
|
||||
this.dataError = 'Select an organisation before editing member roles.';
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateMemberRoles>('updateMemberRoles');
|
||||
const response = await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
organizationId: selectedOrg.id,
|
||||
userId: eventArg.detail.userId,
|
||||
roles: eventArg.detail.roles,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Member role update failed.');
|
||||
}
|
||||
await states.accountState.dispatchAction(states.getOrganizationsAction, null);
|
||||
this.applyAccountState();
|
||||
});
|
||||
}
|
||||
|
||||
private async handleOrgRoleUpsert(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminOrgRoleUpsertEventDetail>) {
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpsertOrgRoleDefinition>('upsertOrgRoleDefinition');
|
||||
const response = await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
organizationId: eventArg.detail.organizationId,
|
||||
roleDefinition: eventArg.detail.roleDefinition,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Organization role update failed.');
|
||||
}
|
||||
await states.accountState.dispatchAction(states.getOrganizationsAction, null);
|
||||
this.applyAccountState();
|
||||
});
|
||||
}
|
||||
|
||||
private async handleOrgRoleDelete(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminOrgRoleDeleteEventDetail>) {
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteOrgRoleDefinition>('deleteOrgRoleDefinition');
|
||||
const response = await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
organizationId: eventArg.detail.organizationId,
|
||||
roleKey: eventArg.detail.roleKey,
|
||||
confirmationText: eventArg.detail.confirmationText,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Organization role delete failed.');
|
||||
}
|
||||
await states.accountState.dispatchAction(states.getOrganizationsAction, null);
|
||||
this.applyAccountState();
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAppRoleMappingsUpdate(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminAppRoleMappingsEventDetail>) {
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateAppRoleMappings>('updateAppRoleMappings');
|
||||
const response = await request.fire({
|
||||
jwt: await idpState.idpClient.getJwt(),
|
||||
organizationId: eventArg.detail.organizationId,
|
||||
appId: eventArg.detail.appId,
|
||||
roleMappings: eventArg.detail.roleMappings,
|
||||
});
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'App role mapping update failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleInvitationResend(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminInvitationEventDetail>) {
|
||||
const selectedOrg = this.getSelectedOrganization();
|
||||
if (!selectedOrg) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResendInvitation>('resendInvitation');
|
||||
await request.fire({ jwt: await idpState.idpClient.getJwt(), organizationId: selectedOrg.id, invitationId: eventArg.detail.invitationId });
|
||||
});
|
||||
}
|
||||
|
||||
private async handleInvitationCancel(eventArg: CustomEvent<plugins.idpCatalog.IIdpAdminInvitationEventDetail>) {
|
||||
const selectedOrg = this.getSelectedOrganization();
|
||||
if (!selectedOrg || !confirm('Cancel this invitation?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runAdminAction(async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CancelInvitation>('cancelInvitation');
|
||||
await request.fire({ jwt: await idpState.idpClient.getJwt(), organizationId: selectedOrg.id, invitationId: eventArg.detail.invitationId });
|
||||
});
|
||||
}
|
||||
|
||||
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
||||
super.firstUpdated(_changedProperties);
|
||||
await this.domtoolsPromise;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
viewcontainer.append(new views.BaseView());
|
||||
console.log(`loaded base view`);
|
||||
this.subrouter = this.domtools.router.createSubRouter('/dash');
|
||||
await states.accountState.dispatchAction(states.getOrganizationsAction, null);
|
||||
this.applyAccountState();
|
||||
|
||||
this.subrouter.on('', async () => {
|
||||
viewcontainer.classList.add('changing');
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
console.log('We are viewing the account overview');
|
||||
await cleanupViews();
|
||||
viewcontainer.append(new views.BaseView());
|
||||
viewcontainer.classList.remove('changing');
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
this.pushDashPath('/overview');
|
||||
});
|
||||
|
||||
this.subrouter.on('/org/:orgName/billing', async () => {
|
||||
viewcontainer.classList.add('changing');
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
console.log('We are viewing the billing page');
|
||||
await cleanupViews();
|
||||
viewcontainer.append(new views.SubscriptionView());
|
||||
viewcontainer.classList.remove('changing');
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
this.subrouter.on('/overview', async () => {
|
||||
this.setAdminPage('overview');
|
||||
});
|
||||
|
||||
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('/account/profile', async () => {
|
||||
this.setAdminPage('profile');
|
||||
});
|
||||
|
||||
this.subrouter.on('/account/security', async () => {
|
||||
this.setAdminPage('security');
|
||||
});
|
||||
|
||||
this.subrouter.on('/account/sessions', async () => {
|
||||
this.setAdminPage('sessions');
|
||||
});
|
||||
|
||||
this.subrouter.on('/account/apps', async () => {
|
||||
this.setAdminPage('apps');
|
||||
});
|
||||
|
||||
this.subrouter.on('/support', async () => {
|
||||
this.setAdminPage('support');
|
||||
});
|
||||
|
||||
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);
|
||||
await this.setOrgPage('org-general');
|
||||
});
|
||||
|
||||
this.subrouter.on('/org/:orgName/settings', async () => {
|
||||
await this.setOrgPage('org-settings');
|
||||
});
|
||||
|
||||
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);
|
||||
await this.setOrgPage('org-apps');
|
||||
});
|
||||
|
||||
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);
|
||||
await this.setOrgPage('org-members');
|
||||
});
|
||||
|
||||
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.pushDashPath('/admin/apps');
|
||||
});
|
||||
|
||||
this.subrouter.on('/admin/users', async () => {
|
||||
this.setAdminPage('ga-users');
|
||||
});
|
||||
|
||||
this.subrouter.on('/admin/orgs', async () => {
|
||||
this.setAdminPage('ga-orgs');
|
||||
});
|
||||
|
||||
this.subrouter.on('/admin/apps', async () => {
|
||||
this.setAdminPage('ga-apps');
|
||||
});
|
||||
|
||||
this.subrouter._handleRouteState();
|
||||
|
||||
states.accountState.select((stateArg) => stateArg.user).subscribe(() => this.applyAccountState());
|
||||
states.accountState.select((stateArg) => stateArg.organizations).subscribe(() => this.applyAccountState());
|
||||
states.accountState.select((stateArg) => stateArg.roles).subscribe(() => this.applyAccountState());
|
||||
states.accountState.select((stateArg) => stateArg.selectedOrg).subscribe(() => this.applyAccountState());
|
||||
|
||||
this.registerGarbageFunction(async () => {
|
||||
this.subrouter.destroy();
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
description,
|
||||
});
|
||||
if (result) {
|
||||
await this.navigateTo(result.path.replace('/account', ''));
|
||||
await this.navigateTo(result.path.replace('/dash', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,7 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.logo dees-icon {
|
||||
font-size: 24px;
|
||||
.logo idp-icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -157,13 +156,12 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.navigationOption dees-icon {
|
||||
font-size: 16px;
|
||||
.navigationOption idp-icon {
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navigationOption:hover dees-icon {
|
||||
.navigationOption:hover idp-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -172,7 +170,7 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.navigationOption.active dees-icon {
|
||||
.navigationOption.active idp-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -182,7 +180,7 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
margin: 8px 16px;
|
||||
}
|
||||
|
||||
dees-input-dropdown {
|
||||
idp-select {
|
||||
margin: 8px;
|
||||
}
|
||||
`,
|
||||
@@ -197,7 +195,7 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
return html`
|
||||
<div class="logoArea">
|
||||
<div class="logo">
|
||||
<dees-icon .icon=${'lucide:fingerprint'}></dees-icon>
|
||||
<idp-icon name="fingerprint" size="22"></idp-icon>
|
||||
idp.global
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +206,7 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
class="navigationOption ${this.isActive('') ? 'active' : ''}"
|
||||
@click=${() => this.navigateTo('')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
||||
<idp-icon name="home" size="16"></idp-icon>
|
||||
Overview
|
||||
</div>
|
||||
<div
|
||||
@@ -217,7 +215,7 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
|
||||
}}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
||||
<idp-icon name="shield" size="16"></idp-icon>
|
||||
Manage Roles
|
||||
</div>
|
||||
<div
|
||||
@@ -227,21 +225,21 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
idpState.domtools.router.pushUrl('/logout');
|
||||
}}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:power'}></dees-icon>
|
||||
<idp-icon name="power" size="16"></idp-icon>
|
||||
Log Out
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="navigationGroupLabel">Organization</div>
|
||||
<dees-input-dropdown
|
||||
.label=${'Select organization'}
|
||||
@selectedOption=${async (eventArg: CustomEvent) => {
|
||||
<idp-select
|
||||
label="Select organization"
|
||||
@idp-select=${async (eventArg: CustomEvent<plugins.idpCatalog.IIdpSelectEventDetail>) => {
|
||||
// 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`);
|
||||
await this.navigateTo(`/org/${org.data.slug}/settings`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -252,9 +250,9 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
// 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
|
||||
// Extract the page type (apps, settings, etc.) and navigate to new org
|
||||
const pathParts = currentPath.split('/');
|
||||
const pageType = pathParts[5]; // /account/org/:orgName/:pageType
|
||||
const pageType = pathParts[4]; // /dash/org/:orgName/:pageType
|
||||
if (pageType) {
|
||||
await this.navigateTo(`/org/${newOrg.data.slug}/${pageType}`);
|
||||
} else {
|
||||
@@ -262,42 +260,42 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
}
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
></idp-select>
|
||||
|
||||
<div
|
||||
class="navigationOption ${this.isActive('org-overview') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
||||
<idp-icon name="home" size="16"></idp-icon>
|
||||
Overview
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption ${this.isActive('apps') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('apps')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:box'}></dees-icon>
|
||||
<idp-icon name="box" size="16"></idp-icon>
|
||||
Apps
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption ${this.isActive('users') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('users')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:users'}></dees-icon>
|
||||
<idp-icon name="users" size="16"></idp-icon>
|
||||
Users
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption"
|
||||
@click=${async () => {}}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:activity'}></dees-icon>
|
||||
<idp-icon name="activity" size="16"></idp-icon>
|
||||
Activity
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption ${this.isActive('billing') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('billing')}
|
||||
class="navigationOption ${this.isActive('settings') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('settings')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
|
||||
Billing
|
||||
<idp-icon name="settings" size="16"></idp-icon>
|
||||
Settings
|
||||
</div>
|
||||
|
||||
${this.renderAdminLink()}
|
||||
@@ -318,7 +316,7 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
class="navigationOption ${this.isActive('admin') ? 'active' : ''}"
|
||||
@click=${() => this.navigateTo('/admin')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
||||
<idp-icon name="shield" size="16"></idp-icon>
|
||||
Global Admin
|
||||
</div>
|
||||
`;
|
||||
@@ -328,11 +326,11 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
const path = this.currentPath;
|
||||
if (page === '') {
|
||||
// Account overview - exact match
|
||||
return path === '/account' || path === '/account/';
|
||||
return path === '/dash' || path === '/dash/';
|
||||
}
|
||||
if (page === 'org-overview') {
|
||||
// Org overview - /account/org/:slug without trailing page type
|
||||
return /^\/account\/org\/[^\/]+\/?$/.test(path);
|
||||
// Org overview - /dash/org/:slug without trailing page type
|
||||
return /^\/dash\/org\/[^\/]+\/?$/.test(path);
|
||||
}
|
||||
// For other pages, check if the path contains the page segment
|
||||
return path.includes(`/${page}`);
|
||||
@@ -355,8 +353,8 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
};
|
||||
requestAnimationFrame(checkPath);
|
||||
|
||||
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
|
||||
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
|
||||
const orgSelect = this.shadowRoot.querySelector('idp-select') as plugins.idpCatalog.IdpSelect | null;
|
||||
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization): plugins.idpCatalog.IIdpSelectOption | null => {
|
||||
if (!orgArg) {
|
||||
return null;
|
||||
}
|
||||
@@ -378,19 +376,25 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
.select((stateArg) => stateArg.organizations)
|
||||
.pipe(
|
||||
plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => {
|
||||
const orgEntries = orgArrayArg.map(orgToMenuEntry);
|
||||
const orgEntries = orgArrayArg
|
||||
.map(orgToMenuEntry)
|
||||
.filter((entryArg): entryArg is plugins.idpCatalog.IIdpSelectOption => Boolean(entryArg));
|
||||
// Add "Create new..." at the end
|
||||
return [...orgEntries, createNewOption];
|
||||
})
|
||||
)
|
||||
.subscribe((menuEntries) => {
|
||||
deesInputDropdown.options = menuEntries;
|
||||
if (orgSelect) {
|
||||
orgSelect.options = menuEntries;
|
||||
}
|
||||
});
|
||||
states.accountState
|
||||
.select((stateArg) => stateArg.selectedOrg)
|
||||
.pipe(plugins.deesDomtools.plugins.smartrx.rxjs.ops.map(orgToMenuEntry))
|
||||
.subscribe((selectedOrgArg) => {
|
||||
deesInputDropdown.selectedOption = selectedOrgArg;
|
||||
if (orgSelect) {
|
||||
orgSelect.selectedOption = selectedOrgArg;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if user is global admin
|
||||
|
||||
@@ -97,14 +97,12 @@ export class BaseView extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
idp-card.card::part(card) {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card.full-width {
|
||||
idp-card.card.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -124,7 +122,7 @@ export class BaseView extends DeesElement {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title dees-icon {
|
||||
.card-title idp-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -209,7 +207,7 @@ export class BaseView extends DeesElement {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-icon dees-icon {
|
||||
.org-icon idp-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -290,7 +288,7 @@ export class BaseView extends DeesElement {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-icon dees-icon {
|
||||
.session-icon idp-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -298,7 +296,7 @@ export class BaseView extends DeesElement {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.session-icon.current dees-icon {
|
||||
.session-icon.current idp-icon {
|
||||
color: #22c55e;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -382,8 +380,7 @@ export class BaseView extends DeesElement {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-icon dees-icon {
|
||||
font-size: 14px;
|
||||
.activity-icon idp-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -391,7 +388,7 @@ export class BaseView extends DeesElement {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.activity-icon.login dees-icon {
|
||||
.activity-icon.login idp-icon {
|
||||
color: #22c55e;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -400,7 +397,7 @@ export class BaseView extends DeesElement {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.activity-icon.logout dees-icon {
|
||||
.activity-icon.logout idp-icon {
|
||||
color: #ef4444;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -427,8 +424,7 @@ export class BaseView extends DeesElement {
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 32px;
|
||||
.empty-state idp-icon {
|
||||
opacity: 0.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -467,7 +463,7 @@ export class BaseView extends DeesElement {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.create-org-btn dees-icon {
|
||||
.create-org-btn idp-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
`,
|
||||
@@ -494,10 +490,10 @@ export class BaseView extends DeesElement {
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- Profile Card -->
|
||||
<div class="card">
|
||||
<idp-card class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<dees-icon .icon=${'lucide:user'}></dees-icon>
|
||||
<idp-icon name="user" size="16"></idp-icon>
|
||||
Profile
|
||||
</span>
|
||||
</div>
|
||||
@@ -510,50 +506,49 @@ export class BaseView extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</idp-card>
|
||||
|
||||
<!-- Organizations Card -->
|
||||
<div class="card">
|
||||
<idp-card class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||
<idp-icon name="building2" size="16"></idp-icon>
|
||||
Organizations
|
||||
</span>
|
||||
<button class="create-org-btn" @click=${this.handleCreateOrg}>
|
||||
<dees-icon .icon=${'lucide:plus'}></dees-icon>
|
||||
<idp-button variant="outline" size="sm" icon="plus" @click=${this.handleCreateOrg}>
|
||||
New
|
||||
</button>
|
||||
</idp-button>
|
||||
</div>
|
||||
<div class="card-body no-padding">
|
||||
${this.renderOrganizations()}
|
||||
</div>
|
||||
</div>
|
||||
</idp-card>
|
||||
|
||||
<!-- Sessions Card -->
|
||||
<div class="card">
|
||||
<idp-card class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<dees-icon .icon=${'lucide:monitor-smartphone'}></dees-icon>
|
||||
<idp-icon name="monitor-smartphone" size="16"></idp-icon>
|
||||
Active Sessions
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body no-padding">
|
||||
${this.renderSessions()}
|
||||
</div>
|
||||
</div>
|
||||
</idp-card>
|
||||
|
||||
<!-- Activity Card -->
|
||||
<div class="card">
|
||||
<idp-card class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">
|
||||
<dees-icon .icon=${'lucide:activity'}></dees-icon>
|
||||
<idp-icon name="activity" size="16"></idp-icon>
|
||||
Recent Activity
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body no-padding">
|
||||
${this.renderActivity()}
|
||||
</div>
|
||||
</div>
|
||||
</idp-card>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -563,7 +558,7 @@ export class BaseView extends DeesElement {
|
||||
if (this.organizations.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||
<idp-icon name="building2" size="32"></idp-icon>
|
||||
<p>You're not a member of any organizations yet.</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -580,13 +575,13 @@ export class BaseView extends DeesElement {
|
||||
return html`
|
||||
<div class="org-item" @click=${() => this.handleSelectOrg(org)}>
|
||||
<div class="org-icon">
|
||||
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||
<idp-icon name="building2" size="16"></idp-icon>
|
||||
</div>
|
||||
<div class="org-info">
|
||||
<div class="org-name">${org.data.name}</div>
|
||||
<div class="org-role">${org.data.slug}</div>
|
||||
</div>
|
||||
<span class="role-badge ${roleClass}">${roleDisplay}</span>
|
||||
<idp-badge variant=${roleClass === 'owner' ? 'accent' : roleClass === 'admin' ? 'warn' : 'outline'}>${roleDisplay}</idp-badge>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
@@ -598,7 +593,7 @@ export class BaseView extends DeesElement {
|
||||
if (this.sessions.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:monitor'}></dees-icon>
|
||||
<idp-icon name="monitor" size="32"></idp-icon>
|
||||
<p>No active sessions found.</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -609,12 +604,12 @@ export class BaseView extends DeesElement {
|
||||
${this.sessions.map((session) => html`
|
||||
<div class="session-item" data-session-id=${session.id}>
|
||||
<div class="session-icon ${session.isCurrent ? 'current' : ''}">
|
||||
<dees-icon .icon=${this.getDeviceIcon(session.os)}></dees-icon>
|
||||
<idp-icon name=${this.getDeviceIcon(session.os)} size="16"></idp-icon>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<div class="session-device">
|
||||
${session.deviceName || 'Unknown Device'}
|
||||
${session.isCurrent ? html`<span class="current-badge">Current</span>` : ''}
|
||||
${session.isCurrent ? html`<idp-badge variant="ok">Current</idp-badge>` : ''}
|
||||
</div>
|
||||
<div class="session-details">
|
||||
${session.browser} · ${session.os} · Last active ${this.formatTimeAgo(session.lastActive)}
|
||||
@@ -622,9 +617,9 @@ export class BaseView extends DeesElement {
|
||||
</div>
|
||||
${!session.isCurrent ? html`
|
||||
<div class="session-actions">
|
||||
<button class="revoke-btn" @click=${() => this.handleRevokeSession(session.id)}>
|
||||
<idp-button variant="destructive" size="sm" @click=${() => this.handleRevokeSession(session.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
</idp-button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -637,7 +632,7 @@ export class BaseView extends DeesElement {
|
||||
if (this.activities.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:activity'}></dees-icon>
|
||||
<idp-icon name="activity" size="32"></idp-icon>
|
||||
<p>No recent activity.</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -648,7 +643,7 @@ export class BaseView extends DeesElement {
|
||||
${this.activities.slice(0, 5).map((activity) => html`
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon ${this.getActivityIconClass(activity.data.action)}">
|
||||
<dees-icon .icon=${this.getActivityIcon(activity.data.action)}></dees-icon>
|
||||
<idp-icon name=${this.getActivityIcon(activity.data.action)} size="14"></idp-icon>
|
||||
</div>
|
||||
<div class="activity-info">
|
||||
<div class="activity-description">${activity.data.metadata.description}</div>
|
||||
|
||||
@@ -100,7 +100,7 @@ export class SubscriptionView extends DeesElement {
|
||||
|
||||
<h3>Paddle</h3>
|
||||
<dees-button @click=${async () => {
|
||||
// Extract org slug from current URL: /account/org/{orgSlug}/billing
|
||||
// Extract org slug from current URL: /dash/org/{orgSlug}/settings
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const orgSlug = pathParts[3];
|
||||
// Use parent's subrouter for proper navigation within account section
|
||||
@@ -152,4 +152,4 @@ export class SubscriptionView extends DeesElement {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ export class UsersView extends DeesElement {
|
||||
@state()
|
||||
accessor organizationName: string = '';
|
||||
|
||||
@state()
|
||||
accessor organizationSlug: string = '';
|
||||
|
||||
@state()
|
||||
accessor inviteEmail: string = '';
|
||||
|
||||
@@ -631,6 +634,7 @@ export class UsersView extends DeesElement {
|
||||
|
||||
this.organizationId = selectedOrg.id;
|
||||
this.organizationName = selectedOrg.data.name;
|
||||
this.organizationSlug = selectedOrg.data.slug;
|
||||
this.currentUserId = currentState.user?.id || '';
|
||||
|
||||
// Check if current user is admin/owner
|
||||
@@ -855,8 +859,8 @@ export class UsersView extends DeesElement {
|
||||
}
|
||||
|
||||
private async handleTransferOwnership(newOwnerId: string, name: string) {
|
||||
const confirmed = await this.showTransferConfirmation(name);
|
||||
if (!confirmed) return;
|
||||
const confirmationText = await this.showTransferConfirmation(name);
|
||||
if (!confirmationText) return;
|
||||
|
||||
this.submitting = true;
|
||||
this.actionMessage = null;
|
||||
@@ -873,6 +877,7 @@ export class UsersView extends DeesElement {
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
newOwnerId,
|
||||
confirmationText,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
@@ -889,8 +894,10 @@ export class UsersView extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async showTransferConfirmation(name: string): Promise<boolean> {
|
||||
private async showTransferConfirmation(name: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const expectedText = `transfer ${this.organizationSlug}`;
|
||||
let confirmationText = '';
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Transfer Ownership',
|
||||
content: html`
|
||||
@@ -899,11 +906,15 @@ export class UsersView extends DeesElement {
|
||||
<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>
|
||||
<p style="margin: 12px 0 8px 0; color: var(--muted-foreground);">
|
||||
Type <code>${expectedText}</code> to confirm.
|
||||
</p>
|
||||
<input style="box-sizing:border-box;width:100%;padding:8px;border:1px solid var(--border);border-radius:8px;" @input=${(eventArg: Event) => { confirmationText = (eventArg.target as HTMLInputElement).value; }} />
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal) => { modal.destroy(); resolve(false); } },
|
||||
{ name: 'Transfer Ownership', action: async (modal) => { modal.destroy(); resolve(true); } },
|
||||
{ name: 'Cancel', action: async (modal) => { modal.destroy(); resolve(null); } },
|
||||
{ name: 'Transfer Ownership', action: async (modal) => { modal.destroy(); resolve(confirmationText.trim() === expectedText ? confirmationText.trim() : null); } },
|
||||
],
|
||||
width: 420,
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export class IdpCenterContainer extends DeesElement {
|
||||
|
||||
/* Left Panel - Branding */
|
||||
.brand-panel {
|
||||
background: linear-gradient(135deg, hsl(240 10% 8%) 0%, hsl(240 10% 4%) 50%, hsl(240 12% 6%) 100%);
|
||||
background: #09090B;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -74,8 +74,9 @@ export class IdpCenterContainer extends DeesElement {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(ellipse at 30% 20%, hsla(240 20% 20% / 0.3) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 80%, hsla(240 20% 15% / 0.2) 0%, transparent 50%);
|
||||
background:
|
||||
radial-gradient(ellipse at 50% -10%, rgb(110 91 230 / 0.18) 0%, transparent 58%),
|
||||
radial-gradient(circle at 2px 2px, rgb(255 255 255 / 0.04) 1px, transparent 0) 0 0 / 32px 32px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -87,18 +88,41 @@ export class IdpCenterContainer extends DeesElement {
|
||||
|
||||
.logo {
|
||||
font-family: 'Cal Sans', 'Geist Sans', sans-serif;
|
||||
font-size: 42px;
|
||||
font-weight: 600;
|
||||
font-size: clamp(44px, 6vw, 72px);
|
||||
font-weight: 900;
|
||||
color: var(--foreground);
|
||||
margin: 0 0 12px 0;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 18px;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 48px 0;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 44px 0;
|
||||
line-height: 1.65;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 28px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #16A34A;
|
||||
}
|
||||
|
||||
.features {
|
||||
@@ -117,17 +141,16 @@ export class IdpCenterContainer extends DeesElement {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: hsla(240 10% 20% / 0.5);
|
||||
border: 1px solid hsla(240 10% 30% / 0.3);
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-icon dees-icon {
|
||||
.feature-icon idp-icon {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.feature-text h3 {
|
||||
@@ -146,6 +169,9 @@ export class IdpCenterContainer extends DeesElement {
|
||||
|
||||
.learn-more {
|
||||
margin-top: 48px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Right Panel - Form */
|
||||
@@ -258,12 +284,13 @@ export class IdpCenterContainer extends DeesElement {
|
||||
<div class="brand-panel">
|
||||
<div class="brand-content">
|
||||
<h1 class="logo">idp.global</h1>
|
||||
<p class="tagline">Your permanent identity on the web</p>
|
||||
<div class="badge"><span class="badge-dot"></span>Open identity infrastructure</div>
|
||||
<p class="tagline">One Identity. Any Scale. Yours Forever.</p>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<div class="feature-icon">
|
||||
<dees-icon .icon=${'lucide:code'}></dees-icon>
|
||||
<idp-icon name="globe" size="18"></idp-icon>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<h3>Open Source</h3>
|
||||
@@ -273,7 +300,7 @@ export class IdpCenterContainer extends DeesElement {
|
||||
|
||||
<div class="feature">
|
||||
<div class="feature-icon">
|
||||
<dees-icon .icon=${'lucide:heart'}></dees-icon>
|
||||
<idp-icon name="shield" size="18"></idp-icon>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<h3>Always Free</h3>
|
||||
@@ -283,7 +310,7 @@ export class IdpCenterContainer extends DeesElement {
|
||||
|
||||
<div class="feature">
|
||||
<div class="feature-icon">
|
||||
<dees-icon .icon=${'lucide:fingerprint'}></dees-icon>
|
||||
<idp-icon name="key" size="18"></idp-icon>
|
||||
</div>
|
||||
<div class="feature-text">
|
||||
<h3>Permanent Identity</h3>
|
||||
@@ -293,10 +320,14 @@ export class IdpCenterContainer extends DeesElement {
|
||||
</div>
|
||||
|
||||
<div class="learn-more">
|
||||
<dees-button
|
||||
type="secondary"
|
||||
<idp-button
|
||||
variant="outline"
|
||||
@click=${() => window.open('https://about.idp.global', '_blank')}
|
||||
>Learn more</dees-button>
|
||||
>Learn more</idp-button>
|
||||
<idp-button
|
||||
variant="ghost"
|
||||
@click=${() => window.open('https://code.foss.global/idp.global/app', '_blank')}
|
||||
>Source code</idp-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,8 @@ import {
|
||||
domtools,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
// third party catalogs
|
||||
import '@uptime.link/webwidget';
|
||||
|
||||
import '@design.estate/dees-catalog';
|
||||
import { DeesForm, DeesFormSubmit, DeesInputText } from '@design.estate/dees-catalog';
|
||||
import { IdpState } from '../states/idp.state.js';
|
||||
|
||||
declare global {
|
||||
@@ -29,6 +26,12 @@ declare global {
|
||||
export class IdpLoginPrompt extends DeesElement {
|
||||
public static demo = () => html`<idp-loginprompt></idp-loginprompt>`;
|
||||
|
||||
@state()
|
||||
accessor oidcConsentState: plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response'] | null = null;
|
||||
|
||||
@state()
|
||||
accessor oidcConsentError = '';
|
||||
|
||||
@property()
|
||||
accessor productOfInterest: string;
|
||||
|
||||
@@ -48,6 +51,155 @@ export class IdpLoginPrompt extends DeesElement {
|
||||
domtools.elementBasic.setup();
|
||||
}
|
||||
|
||||
private getOidcAuthorizationContext(): Omit<
|
||||
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
|
||||
'jwt'
|
||||
> | null {
|
||||
const currentUrl = plugins.smarturl.Smarturl.createFromUrl(window.location.href);
|
||||
|
||||
if (currentUrl.searchParams.oauth !== 'true') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clientId = currentUrl.searchParams.client_id;
|
||||
const redirectUri = currentUrl.searchParams.redirect_uri;
|
||||
const scope = currentUrl.searchParams.scope;
|
||||
const state = currentUrl.searchParams.state;
|
||||
|
||||
if (!clientId || !redirectUri || !scope || !state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prompt = ['none', 'login', 'consent'].includes(currentUrl.searchParams.prompt)
|
||||
? (currentUrl.searchParams.prompt as 'none' | 'login' | 'consent')
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
clientId,
|
||||
redirectUri,
|
||||
scope,
|
||||
state,
|
||||
prompt,
|
||||
codeChallenge: currentUrl.searchParams.code_challenge || undefined,
|
||||
codeChallengeMethod:
|
||||
currentUrl.searchParams.code_challenge_method === 'S256' ? 'S256' : undefined,
|
||||
nonce: currentUrl.searchParams.nonce || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private redirectOidcError(errorArg: string, descriptionArg?: string) {
|
||||
const oidcContext = this.getOidcAuthorizationContext();
|
||||
if (!oidcContext) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(oidcContext.redirectUri);
|
||||
redirectUrl.searchParams.set('error', errorArg);
|
||||
redirectUrl.searchParams.set('state', oidcContext.state);
|
||||
if (descriptionArg) {
|
||||
redirectUrl.searchParams.set('error_description', descriptionArg);
|
||||
}
|
||||
window.location.href = redirectUrl.toString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private getOidcScopeDescription(scopeArg: plugins.idpInterfaces.data.TOidcScope) {
|
||||
const scopeMap: Record<plugins.idpInterfaces.data.TOidcScope, string> = {
|
||||
openid: 'Confirm your identity with this app.',
|
||||
profile: 'Share your display name and username.',
|
||||
email: 'Share your email address.',
|
||||
organizations: 'Share your organizations and their roles.',
|
||||
roles: 'Share your platform roles.',
|
||||
};
|
||||
|
||||
return scopeMap[scopeArg];
|
||||
}
|
||||
|
||||
private getOidcAppHost(appUrlArg: string) {
|
||||
try {
|
||||
return new URL(appUrlArg).hostname;
|
||||
} catch {
|
||||
return appUrlArg;
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareOidcAuthorization(jwtArg: string) {
|
||||
const oidcContext = this.getOidcAuthorizationContext();
|
||||
if (!oidcContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
return idpState.idpClient.requests.prepareOidcAuthorization
|
||||
.fire({
|
||||
jwt: jwtArg,
|
||||
...oidcContext,
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
private async handleOidcAfterLogin(jwtArg: string) {
|
||||
const oidcContext = this.getOidcAuthorizationContext();
|
||||
if (!oidcContext) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
|
||||
loginForm?.setStatus('pending', 'preparing application authorization...');
|
||||
this.oidcConsentError = '';
|
||||
|
||||
const preparation = await this.prepareOidcAuthorization(jwtArg);
|
||||
if (!preparation) {
|
||||
loginForm?.setStatus('error', 'could not prepare the application authorization');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preparation.status === 'consent_required') {
|
||||
if (oidcContext.prompt === 'none') {
|
||||
this.redirectOidcError('consent_required');
|
||||
return true;
|
||||
}
|
||||
|
||||
this.oidcConsentState = preparation;
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.completeOidcAuthorization(jwtArg);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async completeOidcAuthorization(jwtArg: string, consentApproved = false) {
|
||||
const oidcContext = this.getOidcAuthorizationContext();
|
||||
if (!oidcContext) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
|
||||
loginForm?.setStatus('pending', 'authorizing application...');
|
||||
this.oidcConsentError = '';
|
||||
|
||||
const response = await idpState.idpClient.requests.completeOidcAuthorization
|
||||
.fire({
|
||||
jwt: jwtArg,
|
||||
...oidcContext,
|
||||
consentApproved,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!response?.redirectUrl) {
|
||||
if (this.oidcConsentState) {
|
||||
this.oidcConsentError = 'Could not authorize the application.';
|
||||
} else {
|
||||
loginForm?.setStatus('error', 'could not authorize the application');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
window.location.href = response.redirectUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
@@ -79,7 +231,7 @@ export class IdpLoginPrompt extends DeesElement {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dees-form {
|
||||
idp-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -103,41 +255,162 @@ export class IdpLoginPrompt extends DeesElement {
|
||||
.form-footer a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.consent-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.consent-appname {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.consent-appurl {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.consent-scopes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.consent-scope {
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.consent-scope-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.consent-scope-tag {
|
||||
color: #9cd67c;
|
||||
}
|
||||
|
||||
.consent-scope-description {
|
||||
margin-top: 6px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.consent-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.consent-error {
|
||||
color: #ff9a9a;
|
||||
font-size: 14px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.oidcConsentState) {
|
||||
return html`
|
||||
<idp-centercontainer>
|
||||
<div class="form-header">
|
||||
<h2>Continue to ${this.oidcConsentState.appName}</h2>
|
||||
<p>Review and approve the access this app is requesting.</p>
|
||||
</div>
|
||||
<div class="consent-card">
|
||||
<div class="consent-appname">${this.oidcConsentState.appName}</div>
|
||||
<div class="consent-appurl">${this.getOidcAppHost(this.oidcConsentState.appUrl)}</div>
|
||||
<div class="consent-scopes">
|
||||
${this.oidcConsentState.requestedScopes.map((scopeArg) => html`
|
||||
<div class="consent-scope">
|
||||
<div class="consent-scope-header">
|
||||
<span>${scopeArg}</span>
|
||||
${this.oidcConsentState.grantedScopes.includes(scopeArg)
|
||||
? html`<span class="consent-scope-tag">Previously allowed</span>`
|
||||
: null}
|
||||
</div>
|
||||
<div class="consent-scope-description">${this.getOidcScopeDescription(scopeArg)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
${this.oidcConsentError ? html`<div class="consent-error">${this.oidcConsentError}</div>` : null}
|
||||
<div class="consent-actions">
|
||||
<idp-button
|
||||
variant="outline"
|
||||
@click=${() => {
|
||||
this.redirectOidcError('access_denied');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</idp-button>
|
||||
<idp-button
|
||||
variant="accent"
|
||||
@click=${async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
if (!jwt) {
|
||||
this.redirectOidcError('login_required');
|
||||
return;
|
||||
}
|
||||
await this.completeOidcAuthorization(jwt, true);
|
||||
}}
|
||||
>
|
||||
Allow and continue
|
||||
</idp-button>
|
||||
</div>
|
||||
</div>
|
||||
</idp-centercontainer>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<idp-centercontainer>
|
||||
<div class="form-header">
|
||||
<h2>Sign in to your account</h2>
|
||||
<p>Enter your credentials to continue</p>
|
||||
</div>
|
||||
<dees-form
|
||||
<idp-form
|
||||
id="loginForm"
|
||||
@formData="${(eventArg) => {
|
||||
@idp-submit=${(eventArg: CustomEvent<plugins.idpCatalog.IIdpFormSubmitEventDetail>) => {
|
||||
this.login({
|
||||
emailAddress: eventArg.detail.data.emailAddress,
|
||||
passwordArg: eventArg.detail.data.password,
|
||||
emailAddress: String(eventArg.detail.data.emailAddress || ''),
|
||||
passwordArg: String(eventArg.detail.data.password || ''),
|
||||
});
|
||||
}}"
|
||||
}}
|
||||
>
|
||||
<dees-input-text
|
||||
<idp-input
|
||||
id="loginEmailInput"
|
||||
.required=${true}
|
||||
key="emailAddress"
|
||||
required
|
||||
name="emailAddress"
|
||||
label="Email or Username"
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.id=${'loginPasswordInput'}
|
||||
.key=${'password'}
|
||||
.label=${'Password'}
|
||||
.isPasswordBool=${true}
|
||||
></dees-input-text>
|
||||
<dees-form-submit id="loginSubmitButton"></dees-form-submit>
|
||||
</dees-form>
|
||||
autocomplete="username"
|
||||
></idp-input>
|
||||
<idp-input
|
||||
id="loginPasswordInput"
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
></idp-input>
|
||||
<idp-form-submit id="loginSubmitButton"></idp-form-submit>
|
||||
</idp-form>
|
||||
<div class="form-footer">
|
||||
Don't have an account? <a @click=${async () => {
|
||||
Don't have an account?
|
||||
<a @click=${async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
idpState.domtools.router.pushUrl('/register');
|
||||
}}>Create one</a>
|
||||
@@ -147,32 +420,50 @@ export class IdpLoginPrompt extends DeesElement {
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
const domtoolsInstance = await this.domtoolsPromise;
|
||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
||||
const loginPasswordInput: DeesInputText = loginForm.querySelector('#loginPasswordInput');
|
||||
const loginSubmitButton: DeesFormSubmit = loginForm.querySelector('#loginSubmitButton');
|
||||
await this.domtoolsPromise;
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
|
||||
const loginPasswordInput = loginForm.querySelector('#loginPasswordInput') as plugins.idpCatalog.IdpInput;
|
||||
const loginSubmitButton = loginForm.querySelector('#loginSubmitButton') as plugins.idpCatalog.IdpFormSubmit;
|
||||
const oidcContext = this.getOidcAuthorizationContext();
|
||||
const setButtonText = async () => {
|
||||
if (loginPasswordInput.value) {
|
||||
console.log('updating text of loginprompt.');
|
||||
loginSubmitButton.text = 'Login';
|
||||
loginSubmitButton.text = oidcContext ? 'Sign in and continue' : 'Login';
|
||||
} else {
|
||||
loginSubmitButton.text = 'Send magic link (or enter password)';
|
||||
}
|
||||
};
|
||||
loginForm.changeSubject.subscribe(() => {
|
||||
console.log(`checking button text ${loginPasswordInput.value}`);
|
||||
setButtonText();
|
||||
loginForm.addEventListener('idp-input-change', () => {
|
||||
void setButtonText();
|
||||
});
|
||||
setButtonText();
|
||||
await setButtonText();
|
||||
|
||||
if (oidcContext) {
|
||||
const loggedIn = await idpState.idpClient.determineLoginStatus(false);
|
||||
if (!loggedIn && oidcContext.prompt === 'none') {
|
||||
this.redirectOidcError('login_required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loggedIn && oidcContext.prompt !== 'login') {
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
if (jwt) {
|
||||
await this.handleOidcAfterLogin(jwt);
|
||||
}
|
||||
}
|
||||
} else if (await idpState.idpClient.determineLoginStatus(false)) {
|
||||
idpState.domtools.router.pushUrl('/dash/overview');
|
||||
}
|
||||
}
|
||||
|
||||
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
|
||||
// lets disable the submit button
|
||||
const loginSubmitButton: plugins.deesCatalog.DeesFormSubmit = this.shadowRoot.querySelector('#loginSubmitButton');
|
||||
const loginSubmitButton = this.shadowRoot.querySelector(
|
||||
'#loginSubmitButton'
|
||||
) as plugins.idpCatalog.IdpFormSubmit;
|
||||
loginSubmitButton.disabled = true;
|
||||
// lets define the needed requests
|
||||
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
||||
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
|
||||
const loginRequestWithUsernameAndPassword =
|
||||
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword'
|
||||
@@ -182,19 +473,19 @@ export class IdpLoginPrompt extends DeesElement {
|
||||
'loginWithEmail'
|
||||
);
|
||||
|
||||
// lets do the actual logging in
|
||||
if (valueArg.emailAddress && valueArg.passwordArg) {
|
||||
loginForm.setStatus('pending', 'logging in...');
|
||||
const response = await loginRequestWithUsernameAndPassword
|
||||
.fire({
|
||||
username: valueArg.emailAddress, // TODO: rename to emailAddress
|
||||
username: valueArg.emailAddress,
|
||||
password: valueArg.passwordArg,
|
||||
})
|
||||
.catch(() => {
|
||||
loginForm.setStatus('error', 'could not log you in. Try Again!');
|
||||
return;
|
||||
return null;
|
||||
});
|
||||
if (!response) {
|
||||
loginSubmitButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (response.refreshToken) {
|
||||
@@ -202,27 +493,33 @@ export class IdpLoginPrompt extends DeesElement {
|
||||
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
|
||||
if (jwt) {
|
||||
loginForm.setStatus('success', 'obtained jwt.');
|
||||
idpState.domtools.router.pushUrl('/account');
|
||||
const oidcHandled = await this.handleOidcAfterLogin(jwt);
|
||||
if (!oidcHandled) {
|
||||
idpState.domtools.router.pushUrl('/dash/overview');
|
||||
}
|
||||
} else {
|
||||
loginForm.setStatus('error', 'something went wrong');
|
||||
}
|
||||
} else {
|
||||
}
|
||||
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
||||
loginForm.setStatus('pending', 'sending magic link...');
|
||||
const response = await loginRequestWithEmail.fire({
|
||||
email: valueArg.emailAddress,
|
||||
}).catch((err) => {
|
||||
const message = err?.errorText || err?.message || 'Could not send the magic link. Please try again.';
|
||||
loginForm.setStatus('error', message);
|
||||
return null;
|
||||
});
|
||||
if (response.status === 'ok') {
|
||||
if (response?.status === 'ok') {
|
||||
loginForm.setStatus('success', 'Please check your email!');
|
||||
}
|
||||
console.log(response);
|
||||
}
|
||||
|
||||
loginSubmitButton.disabled = false;
|
||||
};
|
||||
|
||||
public async dispatchJwt(jwtArg?: string) {
|
||||
if (jwtArg !== undefined) {
|
||||
console.log(`dispatching jwt from loginprompt.`);
|
||||
this.jwt = jwtArg;
|
||||
await domtools.plugins.smartdelay.delayFor(200);
|
||||
this.dispatchEvent(
|
||||
@@ -237,9 +534,7 @@ export class IdpLoginPrompt extends DeesElement {
|
||||
}
|
||||
|
||||
public async focus() {
|
||||
(
|
||||
this.shadowRoot.querySelector('#loginEmailInput') as plugins.deesCatalog.DeesInputText
|
||||
).focus();
|
||||
(this.shadowRoot.querySelector('#loginEmailInput') as plugins.idpCatalog.IdpInput).focus();
|
||||
}
|
||||
|
||||
public async show() {
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
// third party catalogs
|
||||
import '@uptime.link/webwidget';
|
||||
|
||||
import '@design.estate/dees-catalog';
|
||||
import { DeesForm, DeesFormSubmit, DeesInputText } from '@design.estate/dees-catalog';
|
||||
import { IdpState } from '../states/idp.state.js';
|
||||
|
||||
declare global {
|
||||
@@ -27,7 +25,7 @@ declare global {
|
||||
|
||||
@customElement('idp-registrationprompt')
|
||||
export class IdpRegistrationPrompt extends DeesElement {
|
||||
public static demo = () => html`<idp-login></idp-login>`;
|
||||
public static demo = () => html`<idp-registrationprompt></idp-registrationprompt>`;
|
||||
|
||||
@property()
|
||||
accessor productOfInterest: string;
|
||||
@@ -79,7 +77,7 @@ export class IdpRegistrationPrompt extends DeesElement {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dees-form {
|
||||
idp-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -113,25 +111,28 @@ export class IdpRegistrationPrompt extends DeesElement {
|
||||
<h2>Create your account</h2>
|
||||
<p>Get started with your permanent identity</p>
|
||||
</div>
|
||||
<dees-form
|
||||
<idp-form
|
||||
id="registrationForm"
|
||||
@formData="${(eventArg) => {
|
||||
@idp-submit=${(eventArg: CustomEvent<plugins.idpCatalog.IIdpFormSubmitEventDetail>) => {
|
||||
this.register({
|
||||
emailAddress: eventArg.detail.data.emailAddress,
|
||||
emailAddress: String(eventArg.detail.data.emailAddress || ''),
|
||||
});
|
||||
}}"
|
||||
}}
|
||||
>
|
||||
<dees-input-text
|
||||
.required=${true}
|
||||
key="emailAddress"
|
||||
<idp-input
|
||||
required
|
||||
name="emailAddress"
|
||||
label="Email Address"
|
||||
></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.label="${'I agree to the Terms and Conditions'}"
|
||||
.required=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-form-submit>Send Verification Email</dees-form-submit>
|
||||
</dees-form>
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
></idp-input>
|
||||
<idp-checkbox
|
||||
name="termsAccepted"
|
||||
label="I agree to the Terms and Conditions"
|
||||
required
|
||||
></idp-checkbox>
|
||||
<idp-form-submit>Send Verification Email</idp-form-submit>
|
||||
</idp-form>
|
||||
<div class="form-footer">
|
||||
Already have an account? <a @click=${async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
@@ -147,28 +148,12 @@ export class IdpRegistrationPrompt extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const loggedIn = await idpState.idpClient.determineLoginStatus();
|
||||
if (loggedIn) {
|
||||
idpState.domtools.router.pushUrl('/');
|
||||
idpState.domtools.router.pushUrl('/dash/overview');
|
||||
}
|
||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
||||
const loginPasswordInput: DeesInputText = loginForm.querySelector('#loginPasswordInput');
|
||||
const loginSubmitButton: DeesFormSubmit = loginForm.querySelector('#loginSubmitButton');
|
||||
const setButtonText = async () => {
|
||||
if (loginPasswordInput.value) {
|
||||
console.log('updating text of registrationprompt.');
|
||||
loginSubmitButton.text = 'Login';
|
||||
} else {
|
||||
loginSubmitButton.text = 'Send magic link (or enter password)';
|
||||
}
|
||||
};
|
||||
loginForm.changeSubject.subscribe(() => {
|
||||
console.log(`checking button text ${loginPasswordInput.value}`);
|
||||
setButtonText();
|
||||
});
|
||||
setButtonText();
|
||||
}
|
||||
|
||||
private register = async (valueArg: { emailAddress: string }) => {
|
||||
const registrationForm: DeesForm = this.shadowRoot.querySelector('#registrationForm');
|
||||
const registrationForm = this.shadowRoot.querySelector('#registrationForm') as plugins.idpCatalog.IdpForm;
|
||||
registrationForm.setStatus('pending', 'registering...');
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const firstSignupRequest =
|
||||
@@ -181,11 +166,14 @@ export class IdpRegistrationPrompt extends DeesElement {
|
||||
productSlugOfInterest: this.productOfInterest,
|
||||
})
|
||||
.catch((err) => {
|
||||
registrationForm.setStatus('error', err.message);
|
||||
const message = err?.errorText || err?.message || 'Registration request failed. Please try again.';
|
||||
registrationForm.setStatus('error', message);
|
||||
return null;
|
||||
});
|
||||
if (response.status === 'ok') {
|
||||
if (response?.status === 'ok') {
|
||||
registrationForm.setStatus('success', 'Please check your email!');
|
||||
} else if (response) {
|
||||
registrationForm.setStatus('error', 'Registration request failed. Please try again.');
|
||||
}
|
||||
console.log(response);
|
||||
};
|
||||
|
||||
@@ -497,7 +497,7 @@ export class IdpRegistrationStepper extends DeesElement {
|
||||
}
|
||||
|
||||
deesForm.setStatus('success', 'Ok! Lets Go!');
|
||||
idpState.domtools.router.pushUrl('/account');
|
||||
idpState.domtools.router.pushUrl('/dash/overview');
|
||||
}, { signal });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -102,19 +102,20 @@ export class IdpWelcome extends DeesElement {
|
||||
<p class="greeting">Signed in as <strong>${data.user.data.name}</strong></p>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<dees-button
|
||||
<idp-button
|
||||
variant="accent"
|
||||
@click=${async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
idpState.domtools.router.pushUrl('/account');
|
||||
idpState.domtools.router.pushUrl('/dash/overview');
|
||||
}}
|
||||
>Manage your account</dees-button>
|
||||
<dees-button
|
||||
type="secondary"
|
||||
>Open dashboard</idp-button>
|
||||
<idp-button
|
||||
variant="outline"
|
||||
@click=${async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
idpState.domtools.router.pushUrl('/logout');
|
||||
}}
|
||||
>Sign out</dees-button>
|
||||
>Sign out</idp-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -124,29 +125,30 @@ export class IdpWelcome extends DeesElement {
|
||||
<p>Sign in to your account or create a new one</p>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<dees-button
|
||||
<idp-button
|
||||
variant="accent"
|
||||
@click=${async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
idpState.domtools.router.pushUrl('/login');
|
||||
}}
|
||||
>Sign In</dees-button>
|
||||
<dees-button
|
||||
type="secondary"
|
||||
>Sign In</idp-button>
|
||||
<idp-button
|
||||
variant="outline"
|
||||
@click=${async () => {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
idpState.domtools.router.pushUrl('/register');
|
||||
}}
|
||||
>Create Account</dees-button>
|
||||
>Create Account</idp-button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="secondary-actions">
|
||||
<dees-button
|
||||
type="discreet"
|
||||
<idp-button
|
||||
variant="ghost"
|
||||
@click=${() => {
|
||||
window.open('https://code.foss.global/idp.global/idp.global', '_blank');
|
||||
window.open('https://code.foss.global/idp.global/app', '_blank');
|
||||
}}
|
||||
>View Source Code</dees-button>
|
||||
>View Source Code</idp-button>
|
||||
</div>
|
||||
</idp-centercontainer>
|
||||
`;
|
||||
|
||||
+17
-15
@@ -15,24 +15,26 @@ const run = async () => {
|
||||
'Your permanent identity on the web',
|
||||
canonicalDomain: 'https://idp.global',
|
||||
ldCompany: {
|
||||
type: 'company',
|
||||
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',
|
||||
},
|
||||
description: 'work',
|
||||
description: 'work',
|
||||
address: {
|
||||
name: 'Task Venture Capital GmbH',
|
||||
type: 'company',
|
||||
website: 'https://task.vc',
|
||||
phone: '+49 421 16767 548',
|
||||
city: 'Grasberg',
|
||||
country: 'Germany',
|
||||
countryCode: 'DE',
|
||||
houseNumber: '24',
|
||||
postalCode: '28879',
|
||||
streetName: 'Eickedorfer Vorweide',
|
||||
},
|
||||
closedDate: null,
|
||||
website: 'https://task.vc',
|
||||
phone: '+49 421 16767 548',
|
||||
registrationDetails: {
|
||||
vatId: '',
|
||||
registrationId: 'HRB 35230 HB',
|
||||
registrationName: 'District court Bremen',
|
||||
},
|
||||
status: 'active',
|
||||
foundedDate: {
|
||||
day: 1,
|
||||
month: 1,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user