feat(app): wire dashboard administration flows

This commit is contained in:
2026-05-07 15:35:37 +00:00
parent e9eb9b4172
commit 91f06ccae1
91 changed files with 4087 additions and 5863 deletions
+15 -6
View File
@@ -2,7 +2,7 @@
"npmci": {
"npmGlobalTools": [],
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
"registry.gitlab.com": "code.foss.global/idp.global/app"
},
"dockerBuildargEnvMap": {
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
@@ -17,7 +17,7 @@
"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",
@@ -58,12 +58,13 @@
"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 +72,7 @@
{
"name": "backend",
"watch": "./ts/**/*",
"command": "npm run startTs",
"command": "pnpm run startTs",
"restart": true,
"debounce": 300,
"runOnStart": true
@@ -82,7 +83,15 @@
"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
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"fileMatch": ["/.smartconfig.json"],
"schema": {
"type": "object",
"properties": {
+1 -6
View File
@@ -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 {
+30 -26
View File
@@ -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"
@@ -22,60 +23,63 @@
"@api.global/typedsocket": "^4.1.2",
"@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.6",
"@idp.global/catalog": "file:../catalog",
"@idp.global/interfaces": "file:../interfaces",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartcli": "^4.0.21",
"@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/tsbundle": "^2.10.1",
"@git.zone/tsrun": "^2.0.3",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@git.zone/tswatch": "^3.3.3",
"@push.rocks/projectinfo": "^5.1.0",
"@types/node": "^25.6.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 +87,7 @@
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"readme.md"
],
"keywords": [
+1076 -2513
View File
File diff suppressed because it is too large Load Diff
+38 -8
View File
@@ -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, browser SDK, CLI, web UI, and tspublish submodules used by the hosted service. Shared public contracts live in the sibling `@idp.global/interfaces` package.
## 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` |
## 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,6 +128,18 @@ 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`.
@@ -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
+29
View File
@@ -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_idpclient/`, `ts_idpcli/`, and the sibling `@idp.global/interfaces` package.
## Directory Structure
```
@@ -90,3 +98,24 @@ Stories derived from code TODOs reference these files:
- `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.
+168
View File
@@ -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();
+94 -1
View File
@@ -1,16 +1,21 @@
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 = () => {
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;
@@ -205,4 +210,92 @@ tap.test('prepares OAuth authorization as ready when consent already exists', as
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();
+302
View File
@@ -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();
+46 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+5
View File
@@ -313,6 +313,11 @@ export class AlertManager {
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' },
@@ -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;
@@ -150,6 +151,7 @@ export class AppConnectionManager {
connectedAt: Date.now(),
connectedByUserId: user.id,
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
roleMappings: [],
};
await connection.save();
}
@@ -198,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))];
}
/**
+2
View File
@@ -123,7 +123,9 @@ export class ReceptionHousekeeping {
}),
'12 * * * * *'
);
}
public async start() {
this.taskmanager.start();
logger.log('info', 'housekeeping started');
}
+45 -3
View File
@@ -588,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);
}
@@ -638,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,
@@ -651,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) {
@@ -697,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
*/
+1
View File
@@ -21,6 +21,7 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
slug: slugNameArg,
billingPlanId: null,
roleIds: [],
roleDefinitions: [],
}
await newOrg.save();
return newOrg;
+472
View File
@@ -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(
+3 -1
View File
@@ -72,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();
}
/**
+35 -6
View File
@@ -59,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();
@@ -86,7 +90,7 @@ export class UserInvitationManager {
action: 'create',
userId: existingUser.id,
organizationId: requestArg.organizationId,
roles: requestArg.roles,
roles,
});
return {
success: true,
@@ -103,14 +107,14 @@ 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;
}
@@ -323,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: {
@@ -336,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 },
});
@@ -349,7 +357,7 @@ export class UserInvitationManager {
}
}
role.data.roles = requestArg.roles;
role.data.roles = roles;
await role.save();
const updatedUser = await this.receptionRef.userManager.CUser.getInstance({
@@ -360,7 +368,7 @@ export class UserInvitationManager {
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 ${requestArg.roles.join(', ')}.`,
body: `${user.data.email} changed roles for ${updatedUser?.data?.email || requestArg.userId} to ${roles.join(', ')}.`,
actorUserId: user.id,
relatedEntityId: requestArg.userId,
relatedEntityType: 'user',
@@ -391,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: {
@@ -418,6 +438,15 @@ export class UserInvitationManager {
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',
+2 -2
View File
@@ -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 -1
View File
@@ -334,7 +334,7 @@ export class IdpClient {
}
}
public typedsocketDeferred = plugins.smartpromise.defer();
public typedsocketDeferred = plugins.smartpromise.defer<plugins.typedsocket.TypedSocket>();
public async enableTypedSocket() {
if (this.typedsocketDeferred.claimed) {
return this.typedsocketDeferred.promise;
+30
View File
@@ -172,6 +172,30 @@ export class IdpRequests {
);
}
public get deleteOrganization() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteOrganization>(
'deleteOrganization'
);
}
public get getOrgRoleDefinitions() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgRoleDefinitions>(
'getOrgRoleDefinitions'
);
}
public get upsertOrgRoleDefinition() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpsertOrgRoleDefinition>(
'upsertOrgRoleDefinition'
);
}
public get deleteOrgRoleDefinition() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteOrgRoleDefinition>(
'deleteOrgRoleDefinition'
);
}
// ============================================
// Member & Invitation Management
// ============================================
@@ -242,6 +266,12 @@ export class IdpRequests {
);
}
public get updateAppRoleMappings() {
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateAppRoleMappings>(
'updateAppRoleMappings'
);
}
// ============================================
// Billing
// ============================================
+2 -2
View File
@@ -1,5 +1,5 @@
// losslessone_private scope
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
// idp.global scope
import * as idpInterfaces from '@idp.global/interfaces';
export { idpInterfaces };
+3
View File
@@ -125,6 +125,8 @@ await idpClient.getTransferTokenAndSwitchToLocation('https://app.example.com/');
- billing requests
- JWT validation key requests
- admin requests
- OIDC authorization preparation and completion
- passport device enrollment, challenge approval, alert, and push-token requests
Use these when you want full control instead of the higher-level helper methods on `IdpClient`.
@@ -133,6 +135,7 @@ Use these when you want full control instead of the higher-level helper methods
- 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.
- Passport, alert, and OIDC helper flows are available through `idpClient.requests` even when there is no higher-level convenience method on `IdpClient` yet.
## License and Legal Information
-8
View File
@@ -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'
}
-13
View File
@@ -1,13 +0,0 @@
export interface IAbuseWindow {
id: string;
data: {
action: string;
identifierHash: string;
attemptCount: number;
windowStartedAt: number;
blockedUntil: number;
validUntil: number;
createdAt: number;
updatedAt: number;
};
}
-32
View File
@@ -1,32 +0,0 @@
export type TActivityAction =
| 'login'
| 'logout'
| 'session_created'
| 'session_revoked'
| 'passport_device_enrolled'
| 'passport_device_revoked'
| 'passport_challenge_approved'
| 'passport_challenge_rejected'
| '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;
};
};
}
-35
View File
@@ -1,35 +0,0 @@
export type TAlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type TAlertStatus = 'pending' | 'seen' | 'dismissed';
export type TAlertCategory = 'security' | 'admin' | 'system';
export type TAlertNotificationStatus = 'pending' | 'sent' | 'failed' | 'seen';
export interface IAlert {
id: string;
data: {
recipientUserId: string;
organizationId?: string;
category: TAlertCategory;
eventType: string;
severity: TAlertSeverity;
title: string;
body: string;
actorUserId?: string;
relatedEntityId?: string;
relatedEntityType?: string;
notification: {
hintId: string;
status: TAlertNotificationStatus;
attemptCount: number;
createdAt: number;
deliveredAt?: number | null;
seenAt?: number | null;
lastError?: string | null;
};
createdAt: number;
seenAt?: number | null;
dismissedAt?: number | null;
};
}
-22
View File
@@ -1,22 +0,0 @@
import type { TAlertSeverity } from './alert.js';
export type TAlertRuleScope = 'global' | 'organization';
export type TAlertRuleRecipientMode = 'global_admins' | 'org_admins' | 'specific_users';
export interface IAlertRule {
id: string;
data: {
scope: TAlertRuleScope;
organizationId?: string;
eventType: string;
minimumSeverity: TAlertSeverity;
recipientMode: TAlertRuleRecipientMode;
recipientUserIds?: string[];
push: boolean;
enabled: boolean;
createdByUserId: string;
createdAt: number;
updatedAt: number;
};
}
-99
View File
@@ -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'];
}
-16
View File
@@ -1,16 +0,0 @@
import type { TAppType } from './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[];
};
}
-47
View File
@@ -1,47 +0,0 @@
import * as plugins from '../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>;
};
}
-3
View File
@@ -1,3 +0,0 @@
import * as plugins from '../plugins.js';
export interface IDevice extends plugins.tsclass.network.IDevice {}
-12
View File
@@ -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;
};
}
-21
View File
@@ -1,21 +0,0 @@
export * from './abusewindow.js';
export * from './activity.js';
export * from './alert.js';
export * from './alertrule.js';
export * from './app.js';
export * from './emailactiontoken.js';
export * from './oidc.js';
export * from './appconnection.js';
export * from './billingplan.js';
export * from './device.js';
export * from './jwt.js';
export * from './loginsession.js';
export * from './organization.js';
export * from './paddlecheckoutdata.js';
export * from './passportchallenge.js';
export * from './passportdevice.js';
export * from './passportnonce.js';
export * from './registrationsession.js';
export * from './role.js';
export * from './user.js';
export * from './userinvitation.js';
-43
View File
@@ -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;
};
};
}
-38
View File
@@ -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;
};
}
-275
View File
@@ -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;
-13
View File
@@ -1,13 +0,0 @@
import * as plugins from '../plugins.js';
import { type IBillingPlan } from './billingplan.js';
import { type IRole } from './role.js';
export interface IOrganization {
id: string;
data: {
name: string;
slug: string;
billingPlanId: string;
roleIds: string[];
};
}
-316
View File
@@ -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;
};
}
-80
View File
@@ -1,80 +0,0 @@
import type { IPassportCapabilities } from './passportdevice.js';
export type TPassportChallengeType =
| 'device_enrollment'
| 'authentication'
| 'step_up'
| 'physical_access';
export type TPassportChallengeStatus = 'pending' | 'approved' | 'expired' | 'rejected';
export type TPassportChallengeDeliveryStatus = 'pending' | 'sent' | 'failed' | 'seen';
export type TPassportSignatureFormat = 'raw' | 'der';
export interface IPassportLocationEvidence {
latitude: number;
longitude: number;
accuracyMeters: number;
capturedAt: number;
}
export interface IPassportNfcEvidence {
tagId?: string;
readerId?: string;
}
export interface IPassportLocationPolicy {
mode: 'geofence';
label?: string;
latitude: number;
longitude: number;
radiusMeters: number;
maxAccuracyMeters?: number;
}
export interface IPassportChallenge {
id: string;
data: {
userId: string;
deviceId?: string | null;
type: TPassportChallengeType;
status: TPassportChallengeStatus;
tokenHash?: string | null;
challenge: string;
metadata: {
originHost?: string;
audience?: string;
notificationTitle?: string;
deviceLabel?: string;
requireLocation: boolean;
requireNfc: boolean;
locationPolicy?: IPassportLocationPolicy;
requestedCapabilities?: Partial<IPassportCapabilities>;
};
evidence?: {
signatureFormat?: TPassportSignatureFormat;
location?: IPassportLocationEvidence;
locationEvaluation?: {
matched: boolean;
distanceMeters?: number;
accuracyAccepted?: boolean;
evaluatedAt: number;
reason?: string;
};
nfc?: IPassportNfcEvidence;
};
notification?: {
hintId: string;
status: TPassportChallengeDeliveryStatus;
attemptCount: number;
createdAt: number;
deliveredAt?: number | null;
seenAt?: number | null;
lastError?: string | null;
};
createdAt: number;
expiresAt: number;
completedAt?: number | null;
};
}
-46
View File
@@ -1,46 +0,0 @@
export type TPassportDevicePlatform =
| 'ios'
| 'ipados'
| 'macos'
| 'watchos'
| 'android'
| 'web'
| 'unknown';
export type TPassportDeviceStatus = 'active' | 'revoked';
export type TPassportPushProvider = 'apns';
export type TPassportPushEnvironment = 'development' | 'production';
export interface IPassportCapabilities {
gps: boolean;
nfc: boolean;
push: boolean;
}
export interface IPassportDevice {
id: string;
data: {
userId: string;
label: string;
platform: TPassportDevicePlatform;
status: TPassportDeviceStatus;
publicKeyAlgorithm: 'p256';
publicKeyX963Base64: string;
capabilities: IPassportCapabilities;
pushRegistration?: {
provider: TPassportPushProvider;
token: string;
topic: string;
environment: TPassportPushEnvironment;
registeredAt: number;
lastDeliveredAt?: number;
lastError?: string;
};
appVersion?: string;
createdAt: number;
lastSeenAt?: number;
lastChallengeAt?: number;
};
}
-9
View File
@@ -1,9 +0,0 @@
export interface IPassportNonce {
id: string;
data: {
deviceId: string;
nonceHash: string;
createdAt: number;
expiresAt: number;
};
}
-12
View File
@@ -1,12 +0,0 @@
import * as plugins from '../plugins.js';
import { type IRole } from './role.js';
export interface ISubOrgProperty {
name: string;
domain: string;
roles: IRole[];
/**
* contains the ids of all the apps that show the property
*/
attributedAppIds: string[];
}
-31
View File
@@ -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;
};
};
};
}
-18
View File
@@ -1,18 +0,0 @@
import * as plugins from '../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[];
};
}
-36
View File
@@ -1,36 +0,0 @@
import * as plugins from '../plugins.js';
import { type IRole } from './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;
};
}
-58
View File
@@ -1,58 +0,0 @@
import * as plugins from '../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[];
}
-6
View File
@@ -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 };
-9
View File
@@ -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 };
-128
View File
@@ -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.
-130
View File
@@ -1,130 +0,0 @@
import * as plugins from '../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
};
}
-113
View File
@@ -1,113 +0,0 @@
import * as plugins from '../plugins.js';
import * as data from '../data/index.js';
import type { IPassportDeviceSignedRequest } from './passport.js';
export interface IReq_ListPassportAlerts
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_ListPassportAlerts
> {
method: 'listPassportAlerts';
request: IPassportDeviceSignedRequest & {
includeDismissed?: boolean;
};
response: {
alerts: data.IAlert[];
};
}
export interface IReq_GetPassportAlertByHint
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetPassportAlertByHint
> {
method: 'getPassportAlertByHint';
request: IPassportDeviceSignedRequest & {
hintId: string;
};
response: {
alert?: data.IAlert;
};
}
export interface IReq_MarkPassportAlertSeen
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_MarkPassportAlertSeen
> {
method: 'markPassportAlertSeen';
request: IPassportDeviceSignedRequest & {
hintId: string;
};
response: {
success: boolean;
};
}
export interface IReq_DismissPassportAlert
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_DismissPassportAlert
> {
method: 'dismissPassportAlert';
request: IPassportDeviceSignedRequest & {
hintId: string;
};
response: {
success: boolean;
};
}
export interface IReq_UpsertAlertRule
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_UpsertAlertRule
> {
method: 'upsertAlertRule';
request: {
jwt: string;
ruleId?: string;
scope: data.TAlertRuleScope;
organizationId?: string;
eventType: string;
minimumSeverity: data.TAlertSeverity;
recipientMode: data.TAlertRuleRecipientMode;
recipientUserIds?: string[];
push: boolean;
enabled: boolean;
};
response: {
rule: data.IAlertRule;
};
}
export interface IReq_GetAlertRules
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetAlertRules
> {
method: 'getAlertRules';
request: {
jwt: string;
scope?: data.TAlertRuleScope;
organizationId?: string;
};
response: {
rules: data.IAlertRule[];
};
}
export interface IReq_DeleteAlertRule
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_DeleteAlertRule
> {
method: 'deleteAlertRule';
request: {
jwt: string;
ruleId: string;
};
response: {
success: boolean;
};
}
-1
View File
@@ -1 +0,0 @@
export {};
-52
View File
@@ -1,52 +0,0 @@
import * as data from '../data/index.js';
import * as plugins from '../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;
};
}
-72
View File
@@ -1,72 +0,0 @@
import * as plugins from '../plugins.js';
import { type IUser, type IRole } from '../data/index.js';
import { type TOidcScope } 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[];
};
}
export interface IReq_CompleteOidcAuthorization
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CompleteOidcAuthorization
> {
method: 'completeOidcAuthorization';
request: {
jwt: string;
clientId: string;
redirectUri: string;
scope: string;
state: string;
prompt?: 'none' | 'login' | 'consent';
codeChallenge?: string;
codeChallengeMethod?: 'S256';
nonce?: string;
consentApproved?: boolean;
};
response: {
code: string;
redirectUrl: string;
};
}
export interface IReq_PrepareOidcAuthorization
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_PrepareOidcAuthorization
> {
method: 'prepareOidcAuthorization';
request: {
jwt: string;
clientId: string;
redirectUri: string;
scope: string;
state: string;
prompt?: 'none' | 'login' | 'consent';
codeChallenge?: string;
codeChallengeMethod?: 'S256';
nonce?: string;
};
response: {
status: 'ready' | 'consent_required';
clientId: string;
appName: string;
appUrl: string;
logoUrl?: string;
requestedScopes: TOidcScope[];
grantedScopes: TOidcScope[];
};
}
-55
View File
@@ -1,55 +0,0 @@
import * as plugins from '../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;
};
}
-14
View File
@@ -1,14 +0,0 @@
export * from './admin.js';
export * from './apitoken.js';
export * from './alert.js';
export * from './app.js';
export * from './authorization.js';
export * from './billingplan.js';
export * from './jwt.js';
export * from './login.js';
export * from './organization.js';
export * from './passport.js';
export * from './plan.js';
export * from './registration.js';
export * from './user.js';
export * from './userinvitation.js';
-79
View File
@@ -1,79 +0,0 @@
import * as data from '../data/index.js';
import * as plugins from '../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[];
};
}
-181
View File
@@ -1,181 +0,0 @@
import * as plugins from '../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;
};
}
-51
View File
@@ -1,51 +0,0 @@
import * as data from '../data/index.js';
import * as plugins from '../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;
};
}
-227
View File
@@ -1,227 +0,0 @@
import * as plugins from '../plugins.js';
import * as data from '../data/index.js';
export interface IPassportDeviceSignedRequest {
deviceId: string;
timestamp: number;
nonce: string;
signatureBase64: string;
signatureFormat?: data.TPassportSignatureFormat;
}
export interface IReq_CreatePassportEnrollmentChallenge
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CreatePassportEnrollmentChallenge
> {
method: 'createPassportEnrollmentChallenge';
request: {
jwt: string;
deviceLabel: string;
platform: data.TPassportDevicePlatform;
appVersion?: string;
capabilities?: Partial<data.IPassportCapabilities>;
};
response: {
challengeId: string;
pairingToken: string;
pairingPayload: string;
signingPayload: string;
expiresAt: number;
};
}
export interface IReq_CompletePassportEnrollment
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CompletePassportEnrollment
> {
method: 'completePassportEnrollment';
request: {
pairingToken: string;
deviceLabel: string;
platform: data.TPassportDevicePlatform;
publicKeyX963Base64: string;
signatureBase64: string;
signatureFormat?: data.TPassportSignatureFormat;
appVersion?: string;
capabilities?: Partial<data.IPassportCapabilities>;
};
response: {
device: data.IPassportDevice;
};
}
export interface IReq_GetPassportDevices
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetPassportDevices
> {
method: 'getPassportDevices';
request: {
jwt: string;
};
response: {
devices: data.IPassportDevice[];
};
}
export interface IReq_RevokePassportDevice
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RevokePassportDevice
> {
method: 'revokePassportDevice';
request: {
jwt: string;
deviceId: string;
};
response: {
success: boolean;
};
}
export interface IReq_CreatePassportChallenge
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CreatePassportChallenge
> {
method: 'createPassportChallenge';
request: {
jwt: string;
type?: Exclude<data.TPassportChallengeType, 'device_enrollment'>;
preferredDeviceId?: string;
audience?: string;
notificationTitle?: string;
requireLocation?: boolean;
requireNfc?: boolean;
locationPolicy?: data.IPassportLocationPolicy;
};
response: {
challengeId: string;
challenge: string;
signingPayload: string;
deviceId: string;
expiresAt: number;
};
}
export interface IReq_ApprovePassportChallenge
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_ApprovePassportChallenge
> {
method: 'approvePassportChallenge';
request: {
challengeId: string;
deviceId: string;
signatureBase64: string;
signatureFormat?: data.TPassportSignatureFormat;
location?: data.IPassportLocationEvidence;
nfc?: data.IPassportNfcEvidence;
};
response: {
success: boolean;
challenge: data.IPassportChallenge;
};
}
export interface IReq_RejectPassportChallenge
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RejectPassportChallenge
> {
method: 'rejectPassportChallenge';
request: IPassportDeviceSignedRequest & {
challengeId: string;
};
response: {
success: boolean;
challenge: data.IPassportChallenge;
};
}
export interface IReq_RegisterPassportPushToken
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RegisterPassportPushToken
> {
method: 'registerPassportPushToken';
request: IPassportDeviceSignedRequest & {
provider: data.TPassportPushProvider;
token: string;
topic: string;
environment: data.TPassportPushEnvironment;
};
response: {
success: boolean;
};
}
export interface IReq_ListPendingPassportChallenges
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_ListPendingPassportChallenges
> {
method: 'listPendingPassportChallenges';
request: IPassportDeviceSignedRequest;
response: {
challenges: data.IPassportChallenge[];
};
}
export interface IReq_GetPassportChallengeByHint
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetPassportChallengeByHint
> {
method: 'getPassportChallengeByHint';
request: IPassportDeviceSignedRequest & {
hintId: string;
};
response: {
challenge?: {
challenge: data.IPassportChallenge;
signingPayload: string;
};
};
}
export interface IReq_MarkPassportChallengeSeen
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_MarkPassportChallengeSeen
> {
method: 'markPassportChallengeSeen';
request: IPassportDeviceSignedRequest & {
hintId: string;
};
response: {
success: boolean;
};
}
export interface IReq_GetPassportDashboard
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetPassportDashboard
> {
method: 'getPassportDashboard';
request: IPassportDeviceSignedRequest;
response: {
profile: {
userId: string;
name: string;
handle: string;
organizations: Array<{ id: string; name: string }>;
deviceCount: number;
recoverySummary: string;
};
devices: data.IPassportDevice[];
challenges: Array<{
challenge: data.IPassportChallenge;
signingPayload: string;
}>;
alerts: data.IAlert[];
};
}
-17
View File
@@ -1,17 +0,0 @@
import * as data from '../data/index.js';
import * as plugins from '../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[];
};
}
-90
View File
@@ -1,90 +0,0 @@
import * as plugins from '../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'];
};
}
-142
View File
@@ -1,142 +0,0 @@
import * as data from '../data/index.js';
import * as plugins from '../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;
};
}
-247
View File
@@ -1,247 +0,0 @@
import * as data from '../data/index.js';
import * as plugins from '../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;
};
};
}
-12
View File
@@ -1,12 +0,0 @@
import * as plugins from '../plugins.js';
export interface ITag_LolePubapi
extends plugins.typedRequestInterfaces.implementsTag<
plugins.typedRequestInterfaces.ITag,
ITag_LolePubapi
> {
name: 'lole-reception';
payload: {
backendToken: string;
};
}
-3
View File
@@ -1,3 +0,0 @@
{
"order": 1
}
+312
View File
@@ -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();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import { runCli } from './index.js';
await runCli();
+136
View File
@@ -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();
}
};
+11
View File
@@ -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 };
+22
View File
@@ -0,0 +1,22 @@
# ts_seed
Interactive development seed tooling for local idp.global databases.
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`
+3
View File
@@ -0,0 +1,3 @@
{
"order": 6
}
+793 -133
View File
@@ -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();
})
+40 -36
View File
@@ -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
+36 -41
View File
@@ -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>
`;
}
}
}
+16 -5
View File
@@ -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,
});
+51 -20
View File
@@ -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>
+42 -55
View File
@@ -14,8 +14,6 @@ import {
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 {
@@ -146,7 +144,7 @@ export class IdpLoginPrompt extends DeesElement {
return false;
}
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null;
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
loginForm?.setStatus('pending', 'preparing application authorization...');
this.oidcConsentError = '';
@@ -177,7 +175,7 @@ export class IdpLoginPrompt extends DeesElement {
}
const idpState = await IdpState.getSingletonInstance();
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null;
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null;
loginForm?.setStatus('pending', 'authorizing application...');
this.oidcConsentError = '';
@@ -233,7 +231,7 @@ export class IdpLoginPrompt extends DeesElement {
margin: 0;
}
dees-form {
idp-form {
display: flex;
flex-direction: column;
gap: 16px;
@@ -318,25 +316,6 @@ export class IdpLoginPrompt extends DeesElement {
gap: 12px;
}
.consent-button {
border: none;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
cursor: pointer;
}
.consent-button-secondary {
background: rgba(255, 255, 255, 0.08);
color: var(--foreground);
}
.consent-button-primary {
background: linear-gradient(135deg, #9b7bff, #5fd1ff);
color: #0a0a0a;
font-weight: 600;
}
.consent-error {
color: #ff9a9a;
font-size: 14px;
@@ -370,16 +349,16 @@ export class IdpLoginPrompt extends DeesElement {
</div>
${this.oidcConsentError ? html`<div class="consent-error">${this.oidcConsentError}</div>` : null}
<div class="consent-actions">
<button
class="consent-button consent-button-secondary"
<idp-button
variant="outline"
@click=${() => {
this.redirectOidcError('access_denied');
}}
>
Cancel
</button>
<button
class="consent-button consent-button-primary"
</idp-button>
<idp-button
variant="accent"
@click=${async () => {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
@@ -391,7 +370,7 @@ export class IdpLoginPrompt extends DeesElement {
}}
>
Allow and continue
</button>
</idp-button>
</div>
</div>
</idp-centercontainer>
@@ -404,29 +383,31 @@ export class IdpLoginPrompt extends DeesElement {
<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 () => {
@@ -441,9 +422,9 @@ export class IdpLoginPrompt extends DeesElement {
public async firstUpdated() {
await this.domtoolsPromise;
const idpState = await IdpState.getSingletonInstance();
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
const loginPasswordInput = loginForm.querySelector('#loginPasswordInput') as DeesInputText;
const loginSubmitButton = loginForm.querySelector('#loginSubmitButton') as DeesFormSubmit;
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) {
@@ -452,7 +433,7 @@ export class IdpLoginPrompt extends DeesElement {
loginSubmitButton.text = 'Send magic link (or enter password)';
}
};
loginForm.changeSubject.subscribe(() => {
loginForm.addEventListener('idp-input-change', () => {
void setButtonText();
});
await setButtonText();
@@ -470,17 +451,19 @@ export class IdpLoginPrompt extends DeesElement {
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 }) => {
const loginSubmitButton = this.shadowRoot.querySelector(
'#loginSubmitButton'
) as plugins.deesCatalog.DeesFormSubmit;
) as plugins.idpCatalog.IdpFormSubmit;
loginSubmitButton.disabled = true;
const idpState = await IdpState.getSingletonInstance();
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm;
const loginRequestWithUsernameAndPassword =
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'loginWithEmailOrUsernameAndPassword'
@@ -512,7 +495,7 @@ export class IdpLoginPrompt extends DeesElement {
loginForm.setStatus('success', 'obtained jwt.');
const oidcHandled = await this.handleOidcAfterLogin(jwt);
if (!oidcHandled) {
idpState.domtools.router.pushUrl('/account');
idpState.domtools.router.pushUrl('/dash/overview');
}
} else {
loginForm.setStatus('error', 'something went wrong');
@@ -522,8 +505,12 @@ export class IdpLoginPrompt extends DeesElement {
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!');
}
}
@@ -547,7 +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() {
+26 -38
View File
@@ -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);
};
+1 -1
View File
@@ -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 });
},
},
+17 -15
View File
@@ -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
View File
@@ -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,
+3 -2
View File
@@ -1,10 +1,11 @@
// node native
// project native
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
import * as idpCatalog from '@idp.global/catalog';
import * as idpInterfaces from '@idp.global/interfaces';
import * as leleReceptionclient from '../dist_ts_idpclient/index.js';
export { idpInterfaces, leleReceptionclient as idpClient };
export { idpCatalog, idpInterfaces, leleReceptionclient as idpClient };
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
+3 -1
View File
@@ -1,6 +1,6 @@
# `ts_web/` Web App Module
The `ts_web/` folder contains the frontend for `idp.global`: login, registration, account management, org management, billing, and admin UI.
The `ts_web/` folder contains the frontend for `idp.global`: login, logout, registration, account management, org management, billing, and admin UI.
It is built with `@design.estate/dees-element`, `@design.estate/dees-domtools`, and the shared `idp.global` client and interface packages.
@@ -42,6 +42,7 @@ The module currently includes:
| `register` | `/register` |
| `finishregistration` | `/finishregistration` |
| `account` | `/account` |
| `logout` | `/logout` |
## Build And Run
@@ -60,6 +61,7 @@ pnpm watch
- The app metadata in `ts_web/index.ts` identifies the site as `idp.global`.
- The frontend uses the shared client package for auth state and backend communication.
- Account-related UI is split into reusable elements plus state containers in `states/`.
- The router treats `/account{/*path}` as the signed-in account area, so account subroutes can stay in the SPA shell.
## License and Legal Information
+20 -4
View File
@@ -19,7 +19,7 @@ export class IdpState {
public idpClient = new plugins.idpClient.IdpClient(this.receptionUrl);
public domtools: domtools.DomTools;
public mainStatePart: plugins.deesDomtools.plugins.smartstate.StatePart<'main', {
view: 'welcome' | 'login' | 'register' | 'finishregistration' | 'account' | 'logout';
view: 'welcome' | 'login' | 'register' | 'finishregistration' | 'dash' | 'logout';
}>
public async init() {
@@ -38,6 +38,12 @@ export class IdpState {
});
this.domtools.router.on('/login', async () => {
const isOauthLogin = new URL(window.location.href).searchParams.get('oauth') === 'true';
if (!isOauthLogin && await this.idpClient.determineLoginStatus(false)) {
this.domtools.router.pushUrl('/dash/overview');
return;
}
await this.mainStatePart.setState({
...this.mainStatePart.getState(),
view: 'login',
@@ -53,6 +59,11 @@ export class IdpState {
});
this.domtools.router.on('/register', async () => {
if (await this.idpClient.determineLoginStatus(false)) {
this.domtools.router.pushUrl('/dash/overview');
return;
}
await this.mainStatePart.setState({
...this.mainStatePart.getState(),
view: 'register',
@@ -66,13 +77,18 @@ export class IdpState {
})
});
this.domtools.router.on('/account{/*path}', async () => {
this.domtools.router.on('/dash{/*path}', async () => {
if (!await this.idpClient.determineLoginStatus(false)) {
this.domtools.router.pushUrl('/login');
return;
}
await this.mainStatePart.setState({
...this.mainStatePart.getState(),
view: 'account',
view: 'dash',
})
});
this.domtools.router._handleRouteState();
}
}
}
+2 -2
View File
@@ -114,8 +114,8 @@ export class IdpViewcontainer extends DeesElement {
case 'finishregistration':
await this.loadElement(elements.IdpRegistrationStepper);
break;
case 'account':
console.log('now on /account');
case 'dash':
console.log('now on /dash');
await this.loadElement(elements.IdpAccountContent);
break;
}