Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9d56ff4b | |||
| 47ca5934a6 | |||
| dddd968796 | |||
| 2cdf86744e | |||
| 9d9f90c1d5 | |||
| 833cf3b4b8 | |||
| 8df44b99b9 | |||
| d32103618f |
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-14 - 1.11.0 - feat(idpcli)
|
||||
Add idp CLI (IdpCli) with commands, file-based credential storage, typed request APIs; bump deps and update config
|
||||
|
||||
- Introduce a new CLI implementation under ts_idpcli: IdpCli class, runCli entrypoint and multiple commands (login, login-token, logout, whoami, orgs, orgs-create, members, invite, sessions, revoke, admin-check, admin-apps, admin-suspend, etc.).
|
||||
- Add plugins module that exports node built-ins and common libraries (smartcli, smartinteract, smartpromise, smartrx, typedrequest, typedsocket) for the CLI.
|
||||
- Expose many typed request accessors in classes.idprequests (authentication, registration, user/org/member management, billing, JWT/key management, admin operations).
|
||||
- Implement file-based credential storage (~/.idp-global/credentials.json) with load/store/delete helpers to persist refresh tokens and JWTs for the CLI.
|
||||
- Update ts/index.ts to start the website server on port 2999 (was previously started without explicit port).
|
||||
- Bump and add dependencies/devDependencies: @api.global/typedserver -> ^7.11.1, @design.estate/dees-catalog -> ^3.3.1, @push.rocks/smartjson -> ^6.0.0; add @push.rocks/smartcli, smartfile, smartinteract; upgrade @git.zone/tsbuild to ^4.0.2 and update tsrun/tswatch versions.
|
||||
- Rework npmextra.json: reorganized npmci and tsdoc sections, added release configuration (registries and accessLevel) and other npmci/docker mapping entries.
|
||||
|
||||
## 2025-12-07 - 1.10.0 - feat(billingplan)
|
||||
Add Paddle v2 checkout support and backend config endpoint; add CSP headers and bump typedserver
|
||||
|
||||
- Add getPaddleConfig typedrequest handler in BillingPlanManager to expose PADDLE_TOKEN and PADDLE_PRICE_ID from environment.
|
||||
- Introduce IReq_GetPaddleConfig typedrequest interface.
|
||||
- Update frontend paddlesetup to use Paddle v2: load v2 script, call Paddle.Initialize with token, open Checkout using items.priceId and customer.email, and handle checkout.completed events (store transaction_id).
|
||||
- Attempt to obtain user email from account state or via idpClient.whoIs before starting checkout; show error if email unavailable.
|
||||
- Add Content Security Policy securityHeaders to website server configuration to allow Paddle, ProfitWell, Sentry and related assets/connections.
|
||||
- Bump dependency @api.global/typedserver from ^7.8.17 to ^7.10.2.
|
||||
|
||||
## 2025-12-01 - 1.9.0 - feat(account)
|
||||
Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking
|
||||
|
||||
|
||||
+20
-19
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "registry.npmjs.org"
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "website",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -32,22 +45,10 @@
|
||||
"user sessions"
|
||||
]
|
||||
},
|
||||
"services": [
|
||||
"mongodb",
|
||||
"minio"
|
||||
]
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/idp.global/idp.global"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "registry.npmjs.org"
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
"services": ["mongodb", "minio"],
|
||||
"release": {
|
||||
"registries": ["https://verdaccio.lossless.digital"],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@idp.global/idp.global",
|
||||
"version": "1.9.0",
|
||||
"version": "1.11.0",
|
||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
@@ -16,12 +16,12 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.1.10",
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^3.0.80",
|
||||
"@api.global/typedsocket": "^3.0.1",
|
||||
"@api.global/typedserver": "^7.11.1",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@consent.software/catalog": "^2.0.1",
|
||||
"@design.estate/dees-catalog": "^2.0.2",
|
||||
"@design.estate/dees-catalog": "^3.3.1",
|
||||
"@design.estate/dees-domtools": "^2.3.6",
|
||||
"@design.estate/dees-element": "^2.1.3",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
@@ -29,7 +29,7 @@
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smarthash": "^3.2.6",
|
||||
"@push.rocks/smartjson": "^5.2.0",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartmail": "^2.2.0",
|
||||
@@ -40,19 +40,22 @@
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smarturl": "^3.1.0",
|
||||
"@push.rocks/taskbuffer": "^3.4.0",
|
||||
"@push.rocks/taskbuffer": "^3.5.0",
|
||||
"@push.rocks/smartcli": "^4.0.19",
|
||||
"@push.rocks/smartfile": "^13.1.0",
|
||||
"@push.rocks/smartinteract": "^2.0.6",
|
||||
"@push.rocks/webjwt": "^1.0.9",
|
||||
"@push.rocks/websetup": "^3.0.15",
|
||||
"@push.rocks/webstore": "^2.0.20",
|
||||
"@serve.zone/platformclient": "^1.1.2",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"@uptime.link/webwidget": "^1.2.4"
|
||||
"@uptime.link/webwidget": "^1.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsbundle": "^2.6.2",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tswatch": "^2.2.2",
|
||||
"@git.zone/tsbuild": "^4.0.2",
|
||||
"@git.zone/tsbundle": "^2.6.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tswatch": "^2.3.13",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@types/node": "^24.10.1"
|
||||
},
|
||||
|
||||
Generated
+1029
-805
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -30,7 +30,7 @@ stories/
|
||||
| ID | Title | Priority | Source |
|
||||
|----|-------|----------|--------|
|
||||
| ORG-001 | [Sync Billing Plans with Users](organization-owner/ORG-001-billing-sync.md) | High | TODO |
|
||||
| ORG-002 | [Invite and Manage Team Members](organization-owner/ORG-002-member-management.md) | Critical | New |
|
||||
| ORG-002 | [Invite and Manage Team Members](organization-owner/ORG-002-member-management.md) | Critical | Complete |
|
||||
| ORG-003 | [Assign Roles to Members](organization-owner/ORG-003-role-assignment.md) | High | Partial |
|
||||
| ORG-004 | [Customize Organization Branding](organization-owner/ORG-004-org-branding.md) | Medium | New |
|
||||
| ORG-005 | [View Organization Usage Analytics](organization-owner/ORG-005-usage-analytics.md) | Medium | New |
|
||||
@@ -69,7 +69,7 @@ stories/
|
||||
|
||||
| Priority | Count | Stories |
|
||||
|----------|-------|---------|
|
||||
| Critical | 3 | EU-002, ORG-002, ADM-001 |
|
||||
| Critical | 2 | EU-002, ADM-001 |
|
||||
| High | 12 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003, ADM-008 |
|
||||
| Medium | 14 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, ORG-010, ORG-011, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 |
|
||||
| Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
|
||||
|
||||
@@ -2,27 +2,127 @@
|
||||
|
||||
**ID:** ORG-002
|
||||
**Priority:** Critical
|
||||
**Status:** Planned
|
||||
**Status:** Complete
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to invite team members to my organization and manage their access so that my team can collaborate securely.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Owner can invite users via email address
|
||||
- [ ] Invited user receives email with invitation link
|
||||
- [ ] Invitation can be accepted by existing users or during registration
|
||||
- [ ] Owner can view pending invitations and resend/cancel them
|
||||
- [ ] Owner can see all current members with their roles
|
||||
- [ ] Owner can remove members from organization
|
||||
- [ ] Owner can transfer ownership to another member
|
||||
- [ ] Bulk invite via CSV upload
|
||||
- [x] Owner can invite users via email address
|
||||
- [x] Invited user receives email with invitation link
|
||||
- [x] Invitation can be accepted by existing users or during registration
|
||||
- [x] Owner can view pending invitations and resend/cancel them
|
||||
- [x] Owner can see all current members with their roles
|
||||
- [x] Owner can remove members from organization
|
||||
- [x] Owner can transfer ownership to another member
|
||||
- [x] Bulk invite via CSV upload
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### UserInvitation System
|
||||
|
||||
The invitation system uses a shared `UserInvitation` model that supports multiple organizations inviting the same email address.
|
||||
|
||||
#### Invitation Lifecycle
|
||||
|
||||
1. **Create**: Org admin invites email → `UserInvitation` created (or existing one is updated)
|
||||
2. **Share**: Multiple orgs can link to the same invitation (by email)
|
||||
3. **Convert**: When user registers with that email → invitation converts to real User
|
||||
4. **Fold**: If existing user adds that email as secondary → invitation folds into existing user
|
||||
5. **Expire**: Auto-delete after 90 days with cleanup of all org refs
|
||||
|
||||
#### Data Model
|
||||
|
||||
```typescript
|
||||
// IUserInvitation
|
||||
{
|
||||
id: string;
|
||||
data: {
|
||||
email: string; // Unique key for sharing
|
||||
token: string; // Secure invitation link token
|
||||
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
|
||||
createdAt: number;
|
||||
expiresAt: number; // 90 days from creation
|
||||
organizationRefs: Array<{ // Multiple orgs can share
|
||||
organizationId: string;
|
||||
invitedByUserId: string;
|
||||
invitedAt: number;
|
||||
roles: string[]; // Roles to assign on acceptance
|
||||
}>;
|
||||
acceptedAt?: number;
|
||||
convertedToUserId?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Role System Enhancement
|
||||
|
||||
Users can have multiple roles within an organization:
|
||||
|
||||
```typescript
|
||||
// IRole
|
||||
{
|
||||
id: string;
|
||||
data: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
roles: string[]; // e.g., ['owner', 'billing-admin', 'developer']
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Standard roles: `owner`, `admin`, `editor`, `viewer`, `guest`
|
||||
Custom roles are also supported.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `createInvitation` | Invite email to org with roles |
|
||||
| `getOrgInvitations` | List pending invitations |
|
||||
| `getOrgMembers` | List members with roles |
|
||||
| `cancelInvitation` | Cancel pending invitation |
|
||||
| `resendInvitation` | Resend invitation email |
|
||||
| `removeMember` | Remove user from org |
|
||||
| `updateMemberRoles` | Change member's roles |
|
||||
| `transferOwnership` | Transfer org ownership |
|
||||
| `acceptInvitation` | Accept invitation |
|
||||
| `getInvitationByToken` | Get invitation details for landing page |
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
The Users page (`/account/org/:orgName/users`) provides:
|
||||
|
||||
- **Members tab**: List all members with roles, remove/edit actions
|
||||
- **Pending tab**: List pending invitations with resend/cancel
|
||||
- **Invite tab**: Form to invite by email with role selection
|
||||
|
||||
### Files
|
||||
|
||||
**Backend:**
|
||||
- `ts_interfaces/data/loint-reception.userinvitation.ts` - Data interface
|
||||
- `ts_interfaces/request/loint-reception.userinvitation.ts` - API contracts
|
||||
- `ts/reception/classes.userinvitation.ts` - Model
|
||||
- `ts/reception/classes.userinvitationmanager.ts` - Manager with handlers
|
||||
- `ts/reception/classes.receptionmailer.ts` - Invitation email
|
||||
|
||||
**Frontend:**
|
||||
- `ts_web/elements/account/views/usersview.ts` - Users page component
|
||||
- `ts_web/elements/account/content.ts` - Route registration
|
||||
- `ts_web/elements/account/navigation.ts` - Nav link
|
||||
|
||||
## Technical Notes
|
||||
- Organization and User models exist with association
|
||||
- Need new Invitation model with token and expiry
|
||||
- Use `ReceptionMailer` for invitation emails
|
||||
- RoleManager can be leveraged for role assignment
|
||||
- Consider invitation expiry (7 days default)
|
||||
- UserInvitation model stores invitation data with 90-day expiry
|
||||
- `ReceptionMailer.sendInvitationEmail()` handles email delivery
|
||||
- RoleManager updated to support `roles: string[]` array
|
||||
- Backward compatible with existing single-role data
|
||||
|
||||
## Related Stories
|
||||
- ORG-003: Assign Roles to Members (enhanced with multi-role support)
|
||||
|
||||
## Related TODOs
|
||||
- New feature - core organizational functionality
|
||||
- [ ] Integrate invitation acceptance into registration flow
|
||||
- [ ] Add email verification flow for secondary emails (folding)
|
||||
- [ ] Implement scheduled cleanup job for expired invitations
|
||||
- [ ] Add CSV bulk invite feature
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.9.0',
|
||||
version: '1.11.0',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
+16
-1
@@ -8,6 +8,21 @@ export const runCli = async () => {
|
||||
feedMetadata: null,
|
||||
domain: 'idp.global',
|
||||
serveDir: paths.distWebDir,
|
||||
securityHeaders: {
|
||||
csp: {
|
||||
defaultSrc: "'self'",
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.paddle.com", "https://public.profitwell.com"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.paddle.com", "https://assetbroker.lossless.one"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
fontSrc: ["'self'", "data:"],
|
||||
connectSrc: ["'self'", "https://*.paddle.com", "https://buy.paddle.com", "https://checkout.paddle.com", "https://checkout-service.paddle.com", "https://cdn.paddle.com", "https://*.sentry.io", "https://public.profitwell.com", "wss:"],
|
||||
frameSrc: ["https://buy.paddle.com", "https://checkout.paddle.com", "https://*.paddle.com"],
|
||||
},
|
||||
},
|
||||
addCustomRoutes: async (typedserver) => {
|
||||
// Enable SPA fallback - serves index.html for non-file routes (e.g., /login, /dashboard)
|
||||
typedserver.options.spaFallback = true;
|
||||
},
|
||||
});
|
||||
|
||||
// lets add the reception routes
|
||||
@@ -21,5 +36,5 @@ export const runCli = async () => {
|
||||
});
|
||||
await reception.start();
|
||||
|
||||
await websiteServer.start();
|
||||
await websiteServer.start(2999);
|
||||
};
|
||||
|
||||
@@ -59,6 +59,17 @@ export class BillingPlanManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
// Paddle configuration endpoint
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPaddleConfig>(
|
||||
'getPaddleConfig',
|
||||
async () => ({
|
||||
paddleToken: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PADDLE_TOKEN'),
|
||||
paddlePriceId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PADDLE_PRICE_ID'),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,6 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
|
||||
|
||||
public async checkIfUserIsAdmin(userArg: User) {
|
||||
const role = await this.manager.receptionRef.roleManager.getRoleForUserAndOrg(userArg, this);
|
||||
return role.data.role === 'admin';
|
||||
return role.data.roles?.includes('admin') || role.data.roles?.includes('owner');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,13 +50,14 @@ export class OrganizationManager {
|
||||
action: 'create',
|
||||
organizationId: newOrg.id,
|
||||
userId: userData.id,
|
||||
role: 'admin',
|
||||
roles: ['owner'],
|
||||
});
|
||||
newOrg.data.roleIds.push(role.id);
|
||||
await newOrg.save();
|
||||
return {
|
||||
nameAvailable: true,
|
||||
resultingOrganization: await newOrg.createSavableObject()
|
||||
resultingOrganization: await newOrg.createSavableObject(),
|
||||
role: await role.createSavableObject(),
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BillingPlanManager } from './classes.billingplanmanager.js';
|
||||
import { AppManager } from './classes.appmanager.js';
|
||||
import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
||||
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
||||
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
||||
|
||||
export interface IReceptionOptions {
|
||||
/**
|
||||
@@ -47,6 +48,7 @@ export class Reception {
|
||||
public appManager = new AppManager(this);
|
||||
public appConnectionManager = new AppConnectionManager(this);
|
||||
public activityLogManager = new ActivityLogManager(this);
|
||||
public userInvitationManager = new UserInvitationManager(this);
|
||||
housekeeping = new ReceptionHousekeeping(this);
|
||||
|
||||
constructor(public options: IReceptionOptions) {
|
||||
|
||||
@@ -268,4 +268,33 @@ export class ReceptionMailer {
|
||||
`),
|
||||
});
|
||||
}
|
||||
|
||||
public sendInvitationEmail(
|
||||
email: string,
|
||||
organizationName: string,
|
||||
invitationToken: string,
|
||||
baseUrl: string
|
||||
) {
|
||||
const invitationUrl = `${baseUrl}/invite?token=${encodeURI(invitationToken)}`;
|
||||
|
||||
this.receptionRef.szPlatformClient.emailConnector.sendEmail({
|
||||
from: `idp.global@${this.receptionRef.options.baseUrl} <noreply@mail.workspace.global>`,
|
||||
title: `You've been invited to join ${organizationName}`,
|
||||
to: email,
|
||||
body: this.createBodyString(`
|
||||
<h1>You're Invited!</h1>
|
||||
<p>You've been invited to join <b>${organizationName}</b> on idp.global.</p>
|
||||
<p>Click the button below to accept the invitation and join the organization.</p>
|
||||
<a href="${invitationUrl}"><div class="button">
|
||||
Accept Invitation
|
||||
</div></a>
|
||||
<p style="color: #888888; font-size: 12px; margin-top: 20px;">
|
||||
If you don't have an account yet, you'll be able to create one when you accept the invitation.
|
||||
</p>
|
||||
<p style="color: #888888; font-size: 12px;">
|
||||
This invitation will expire in 90 days.
|
||||
</p>
|
||||
`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,24 @@ export class RoleManager {
|
||||
this.receptionRef = receptionRefArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create, change, or delete a role for a user in an organization.
|
||||
* Supports both old single-role and new multi-role patterns.
|
||||
*/
|
||||
public async modifyRoleForUserAtOrg(optionsArg: {
|
||||
action: 'create' | 'change' | 'delete';
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
role: plugins.idpInterfaces.data.IRole['data']['role'];
|
||||
/** @deprecated Use `roles` instead */
|
||||
role?: string;
|
||||
/** Array of roles to assign */
|
||||
roles?: string[];
|
||||
}) {
|
||||
let returnRole: Role;
|
||||
|
||||
// Support both old single role and new roles array
|
||||
const roles = optionsArg.roles || (optionsArg.role ? [optionsArg.role] : ['viewer']);
|
||||
|
||||
switch (optionsArg.action) {
|
||||
case 'create':
|
||||
returnRole = new this.CRole();
|
||||
@@ -29,9 +40,35 @@ export class RoleManager {
|
||||
returnRole.data = {
|
||||
userId: optionsArg.userId,
|
||||
organizationId: optionsArg.organizationId,
|
||||
role: optionsArg.role,
|
||||
roles: roles,
|
||||
};
|
||||
await returnRole.save();
|
||||
break;
|
||||
|
||||
case 'change':
|
||||
returnRole = await this.CRole.getInstance({
|
||||
data: {
|
||||
userId: optionsArg.userId,
|
||||
organizationId: optionsArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (returnRole) {
|
||||
returnRole.data.roles = roles;
|
||||
await returnRole.save();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
returnRole = await this.CRole.getInstance({
|
||||
data: {
|
||||
userId: optionsArg.userId,
|
||||
organizationId: optionsArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (returnRole) {
|
||||
await returnRole.delete();
|
||||
}
|
||||
break;
|
||||
}
|
||||
return returnRole;
|
||||
}
|
||||
@@ -54,4 +91,13 @@ export class RoleManager {
|
||||
});
|
||||
return roles;
|
||||
}
|
||||
|
||||
public async getAllRolesForOrg(organizationId: string) {
|
||||
const roles = await this.CRole.getInstances({
|
||||
data: {
|
||||
organizationId: organizationId
|
||||
}
|
||||
});
|
||||
return roles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* UserInvitation represents an invitation to join one or more organizations.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Unique by email (multiple orgs can share the same invitation)
|
||||
* - Converts to real User on registration
|
||||
* - Can fold into existing user if they add the email as secondary
|
||||
* - Auto-expires after 90 days
|
||||
*/
|
||||
@plugins.smartdata.Manager()
|
||||
export class UserInvitation extends plugins.smartdata.SmartDataDbDoc<
|
||||
UserInvitation,
|
||||
plugins.idpInterfaces.data.IUserInvitation
|
||||
> {
|
||||
// STATIC
|
||||
public static readonly EXPIRY_DAYS = 90;
|
||||
|
||||
public static generateToken(): string {
|
||||
return plugins.smartunique.shortId() + '-' + plugins.smartunique.shortId();
|
||||
}
|
||||
|
||||
public static async createNewInvitation(
|
||||
email: string,
|
||||
organizationId: string,
|
||||
invitedByUserId: string,
|
||||
roles: string[]
|
||||
): Promise<UserInvitation> {
|
||||
const invitation = new UserInvitation();
|
||||
invitation.id = plugins.smartunique.shortId();
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
invitation.data = {
|
||||
email: email.toLowerCase().trim(),
|
||||
token: UserInvitation.generateToken(),
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
expiresAt: expiresAt,
|
||||
organizationRefs: [{
|
||||
organizationId,
|
||||
invitedByUserId,
|
||||
invitedAt: now,
|
||||
roles,
|
||||
}],
|
||||
};
|
||||
|
||||
await invitation.save();
|
||||
return invitation;
|
||||
}
|
||||
|
||||
// INSTANCE
|
||||
@plugins.smartdata.unI()
|
||||
id: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data: plugins.idpInterfaces.data.IUserInvitation['data'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add another organization to this invitation
|
||||
*/
|
||||
public async addOrganization(
|
||||
organizationId: string,
|
||||
invitedByUserId: string,
|
||||
roles: string[]
|
||||
): Promise<void> {
|
||||
// Check if org already exists
|
||||
const existingRef = this.data.organizationRefs.find(
|
||||
ref => ref.organizationId === organizationId
|
||||
);
|
||||
|
||||
if (existingRef) {
|
||||
// Update roles for existing org ref
|
||||
existingRef.roles = roles;
|
||||
existingRef.invitedAt = Date.now();
|
||||
existingRef.invitedByUserId = invitedByUserId;
|
||||
} else {
|
||||
// Add new org ref
|
||||
this.data.organizationRefs.push({
|
||||
organizationId,
|
||||
invitedByUserId,
|
||||
invitedAt: Date.now(),
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an organization from this invitation
|
||||
*/
|
||||
public async removeOrganization(organizationId: string): Promise<void> {
|
||||
this.data.organizationRefs = this.data.organizationRefs.filter(
|
||||
ref => ref.organizationId !== organizationId
|
||||
);
|
||||
|
||||
// If no more org refs, cancel the invitation
|
||||
if (this.data.organizationRefs.length === 0) {
|
||||
this.data.status = 'cancelled';
|
||||
}
|
||||
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if invitation is expired
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
return Date.now() > this.data.expiresAt || this.data.status === 'expired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark invitation as accepted and record the user ID
|
||||
*/
|
||||
public async accept(userId: string): Promise<void> {
|
||||
this.data.status = 'accepted';
|
||||
this.data.acceptedAt = Date.now();
|
||||
this.data.convertedToUserId = userId;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate token and extend expiry (for resend)
|
||||
*/
|
||||
public async regenerateToken(): Promise<void> {
|
||||
this.data.token = UserInvitation.generateToken();
|
||||
this.data.expiresAt = Date.now() + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Reception } from './classes.reception.js';
|
||||
import { UserInvitation } from './classes.userinvitation.js';
|
||||
import { Organization } from './classes.organization.js';
|
||||
import { User } from './classes.user.js';
|
||||
import { Role } from './classes.role.js';
|
||||
|
||||
export class UserInvitationManager {
|
||||
public receptionRef: Reception;
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public CUserInvitation = plugins.smartdata.setDefaultManagerForDoc(this, UserInvitation);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
// Create invitation
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||
'createInvitation',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const email = requestArg.email.toLowerCase().trim();
|
||||
|
||||
// Check if user with this email already exists
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
if (existingUser) {
|
||||
// User already exists - just add them to the org directly
|
||||
const existingRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (existingRole) {
|
||||
return {
|
||||
success: false,
|
||||
isNew: false,
|
||||
message: 'User is already a member of this organization.',
|
||||
};
|
||||
}
|
||||
// Add user to org with the specified roles
|
||||
await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
|
||||
action: 'create',
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
roles: requestArg.roles,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
isNew: false,
|
||||
message: 'Existing user has been added to the organization.',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if invitation already exists for this email
|
||||
let invitation = await this.CUserInvitation.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
|
||||
let isNew = false;
|
||||
if (invitation) {
|
||||
// Add org to existing invitation
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, requestArg.roles);
|
||||
} else {
|
||||
// Create new invitation
|
||||
invitation = await UserInvitation.createNewInvitation(
|
||||
email,
|
||||
requestArg.organizationId,
|
||||
user.id,
|
||||
requestArg.roles
|
||||
);
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invitation: await invitation.createSavableObject(),
|
||||
isNew,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get org invitations
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
|
||||
'getOrgInvitations',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const allInvitations = await this.CUserInvitation.getInstances({});
|
||||
const orgInvitations = allInvitations.filter(inv =>
|
||||
inv.data.status === 'pending' &&
|
||||
!inv.isExpired() &&
|
||||
inv.data.organizationRefs.some(ref => ref.organizationId === requestArg.organizationId)
|
||||
);
|
||||
|
||||
return {
|
||||
invitations: await Promise.all(orgInvitations.map(inv => inv.createSavableObject())),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get org members
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||
'getOrgMembers',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsMemberOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const roles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||
data: { organizationId: requestArg.organizationId },
|
||||
});
|
||||
|
||||
const members: Array<{
|
||||
user: plugins.idpInterfaces.data.IUser;
|
||||
role: plugins.idpInterfaces.data.IRole;
|
||||
}> = [];
|
||||
|
||||
for (const role of roles) {
|
||||
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: role.data.userId,
|
||||
});
|
||||
if (memberUser) {
|
||||
members.push({
|
||||
user: await memberUser.createSavableObject(),
|
||||
role: await role.createSavableObject(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { members };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Cancel invitation
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CancelInvitation>(
|
||||
'cancelInvitation',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const invitation = await this.CUserInvitation.getInstance({ id: requestArg.invitationId });
|
||||
if (!invitation) {
|
||||
return { success: false, message: 'Invitation not found.' };
|
||||
}
|
||||
|
||||
await invitation.removeOrganization(requestArg.organizationId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Resend invitation
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResendInvitation>(
|
||||
'resendInvitation',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const invitation = await this.CUserInvitation.getInstance({ id: requestArg.invitationId });
|
||||
if (!invitation) {
|
||||
return { success: false, message: 'Invitation not found.' };
|
||||
}
|
||||
|
||||
await invitation.regenerateToken();
|
||||
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
||||
|
||||
return { success: true, message: 'Invitation resent.' };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove member
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RemoveMember>(
|
||||
'removeMember',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
// Cannot remove yourself if you're the only owner
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: requestArg.userId,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
return { success: false, message: 'Member not found.' };
|
||||
}
|
||||
|
||||
// Check if trying to remove an owner
|
||||
if (role.data.roles.includes('owner')) {
|
||||
// Count owners
|
||||
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||
data: { organizationId: requestArg.organizationId },
|
||||
});
|
||||
const ownerCount = allRoles.filter(r => r.data.roles.includes('owner')).length;
|
||||
if (ownerCount <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cannot remove the last owner. Transfer ownership first.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await role.delete();
|
||||
|
||||
// Remove org from user's connectedOrgs
|
||||
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: requestArg.userId,
|
||||
});
|
||||
if (memberUser && memberUser.data.connectedOrgs) {
|
||||
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
||||
orgId => orgId !== requestArg.organizationId
|
||||
);
|
||||
await memberUser.save();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Update member roles
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateMemberRoles>(
|
||||
'updateMemberRoles',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: requestArg.userId,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
return { success: false, message: 'Member not found.' };
|
||||
}
|
||||
|
||||
// If removing owner role, check we're not removing the last owner
|
||||
if (role.data.roles.includes('owner') && !requestArg.roles.includes('owner')) {
|
||||
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
|
||||
data: { organizationId: requestArg.organizationId },
|
||||
});
|
||||
const ownerCount = allRoles.filter(r => r.data.roles.includes('owner')).length;
|
||||
if (ownerCount <= 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cannot remove owner role from the last owner.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
role.data.roles = requestArg.roles;
|
||||
await role.save();
|
||||
|
||||
return { success: true, role: await role.createSavableObject() };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Transfer ownership
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_TransferOwnership>(
|
||||
'transferOwnership',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
|
||||
// Verify current user is an owner
|
||||
const currentUserRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: user.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (!currentUserRole || !currentUserRole.data.roles.includes('owner')) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Only owners can transfer ownership.'
|
||||
);
|
||||
}
|
||||
|
||||
// Get new owner's role
|
||||
const newOwnerRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: requestArg.newOwnerId,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
if (!newOwnerRole) {
|
||||
return { success: false, message: 'New owner must be a member of the organization.' };
|
||||
}
|
||||
|
||||
// Add owner role to new owner
|
||||
if (!newOwnerRole.data.roles.includes('owner')) {
|
||||
newOwnerRole.data.roles.push('owner');
|
||||
await newOwnerRole.save();
|
||||
}
|
||||
|
||||
// Remove owner role from current user (but keep other roles)
|
||||
currentUserRole.data.roles = currentUserRole.data.roles.filter(r => r !== 'owner');
|
||||
if (currentUserRole.data.roles.length === 0) {
|
||||
currentUserRole.data.roles = ['admin']; // Demote to admin
|
||||
}
|
||||
await currentUserRole.save();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get invitation by token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetInvitationByToken>(
|
||||
'getInvitationByToken',
|
||||
async (requestArg) => {
|
||||
const invitation = await this.CUserInvitation.getInstance({
|
||||
data: { token: requestArg.token },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return { isExpired: true, requiresRegistration: false };
|
||||
}
|
||||
|
||||
if (invitation.isExpired()) {
|
||||
return { isExpired: true, requiresRegistration: false };
|
||||
}
|
||||
|
||||
// Get organization names
|
||||
const organizations: Array<{ id: string; name: string }> = [];
|
||||
for (const ref of invitation.data.organizationRefs) {
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: ref.organizationId,
|
||||
});
|
||||
if (org) {
|
||||
organizations.push({ id: org.id, name: org.data.name });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user with this email exists
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: { email: invitation.data.email },
|
||||
});
|
||||
|
||||
return {
|
||||
invitation: await invitation.createSavableObject(),
|
||||
organizations,
|
||||
isExpired: false,
|
||||
requiresRegistration: !existingUser,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Accept invitation
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AcceptInvitation>(
|
||||
'acceptInvitation',
|
||||
async (requestArg) => {
|
||||
const invitation = await this.CUserInvitation.getInstance({
|
||||
data: { token: requestArg.token },
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return { success: false, message: 'Invalid invitation token.' };
|
||||
}
|
||||
|
||||
if (invitation.isExpired()) {
|
||||
return { success: false, message: 'This invitation has expired.' };
|
||||
}
|
||||
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: requestArg.userId,
|
||||
});
|
||||
if (!user) {
|
||||
return { success: false, message: 'User not found.' };
|
||||
}
|
||||
|
||||
// Create roles for each organization
|
||||
const organizations: plugins.idpInterfaces.data.IOrganization[] = [];
|
||||
const roles: plugins.idpInterfaces.data.IRole[] = [];
|
||||
|
||||
for (const ref of invitation.data.organizationRefs) {
|
||||
// Check if role already exists
|
||||
let role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: user.id,
|
||||
organizationId: ref.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
role = await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
|
||||
action: 'create',
|
||||
userId: user.id,
|
||||
organizationId: ref.organizationId,
|
||||
roles: ref.roles,
|
||||
});
|
||||
}
|
||||
|
||||
roles.push(await role.createSavableObject());
|
||||
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: ref.organizationId,
|
||||
});
|
||||
if (org) {
|
||||
// Add role to org's roleIds if not already there
|
||||
if (!org.data.roleIds.includes(role.id)) {
|
||||
org.data.roleIds.push(role.id);
|
||||
await org.save();
|
||||
}
|
||||
organizations.push(await org.createSavableObject());
|
||||
}
|
||||
|
||||
// Update user's connectedOrgs
|
||||
if (!user.data.connectedOrgs) {
|
||||
user.data.connectedOrgs = [];
|
||||
}
|
||||
if (!user.data.connectedOrgs.includes(ref.organizationId)) {
|
||||
user.data.connectedOrgs.push(ref.organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
await user.save();
|
||||
await invitation.accept(user.id);
|
||||
|
||||
return { success: true, organizations, roles };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Bulk create invitations
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
|
||||
'bulkCreateInvitations',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: requestArg.organizationId,
|
||||
});
|
||||
const orgName = org?.data.name || 'an organization';
|
||||
|
||||
const results: Array<{
|
||||
email: string;
|
||||
success: boolean;
|
||||
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
|
||||
message?: string;
|
||||
}> = [];
|
||||
const summary = {
|
||||
total: 0,
|
||||
invited: 0,
|
||||
alreadyMembers: 0,
|
||||
invalid: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// Deduplicate emails in the batch
|
||||
const processedEmails = new Set<string>();
|
||||
|
||||
for (const inv of requestArg.invitations) {
|
||||
summary.total++;
|
||||
const email = inv.email?.toLowerCase().trim();
|
||||
|
||||
// Validate email format
|
||||
if (!email || !this.isValidEmail(email)) {
|
||||
results.push({
|
||||
email: inv.email || '',
|
||||
success: false,
|
||||
status: 'invalid_email',
|
||||
message: 'Invalid email format',
|
||||
});
|
||||
summary.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip duplicates within batch
|
||||
if (processedEmails.has(email)) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'invalid_email',
|
||||
message: 'Duplicate email in batch',
|
||||
});
|
||||
summary.invalid++;
|
||||
continue;
|
||||
}
|
||||
processedEmails.add(email);
|
||||
|
||||
try {
|
||||
// Check if user with this email already exists
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Check if already a member
|
||||
const existingRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRole) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'already_member',
|
||||
message: 'Already a member of this organization',
|
||||
});
|
||||
summary.alreadyMembers++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add existing user to org
|
||||
const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles;
|
||||
await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
|
||||
action: 'create',
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
roles,
|
||||
});
|
||||
results.push({
|
||||
email,
|
||||
success: true,
|
||||
status: 'invited',
|
||||
message: 'Existing user added to organization',
|
||||
});
|
||||
summary.invited++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if invitation already exists
|
||||
let invitation = await this.CUserInvitation.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
|
||||
const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles;
|
||||
|
||||
if (invitation) {
|
||||
// Add org to existing invitation
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, roles);
|
||||
} else {
|
||||
// Create new invitation
|
||||
invitation = await UserInvitation.createNewInvitation(
|
||||
email,
|
||||
requestArg.organizationId,
|
||||
user.id,
|
||||
roles
|
||||
);
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
await this.receptionRef.receptionMailer.sendInvitationEmail(
|
||||
email,
|
||||
orgName,
|
||||
invitation.data.token,
|
||||
this.receptionRef.options.baseUrl
|
||||
);
|
||||
|
||||
results.push({
|
||||
email,
|
||||
success: true,
|
||||
status: 'invited',
|
||||
});
|
||||
summary.invited++;
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'error',
|
||||
message: error.message || 'Unknown error',
|
||||
});
|
||||
summary.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, results, summary };
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invitation by email
|
||||
*/
|
||||
public async getInvitationByEmail(email: string): Promise<UserInvitation | null> {
|
||||
return this.CUserInvitation.getInstance({
|
||||
data: { email: email.toLowerCase().trim() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending invitations for an email (for registration flow)
|
||||
*/
|
||||
public async getPendingInvitationsForEmail(email: string): Promise<UserInvitation | null> {
|
||||
const invitation = await this.getInvitationByEmail(email);
|
||||
if (invitation && invitation.data.status === 'pending' && !invitation.isExpired()) {
|
||||
return invitation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired invitations
|
||||
*/
|
||||
public async cleanupExpiredInvitations(): Promise<number> {
|
||||
const allInvitations = await this.CUserInvitation.getInstances({
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
|
||||
let cleanedCount = 0;
|
||||
for (const invitation of allInvitations) {
|
||||
if (invitation.isExpired()) {
|
||||
invitation.data.status = 'expired';
|
||||
await invitation.save();
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invitation email
|
||||
*/
|
||||
private async sendInvitationEmail(
|
||||
invitation: UserInvitation,
|
||||
organizationId: string
|
||||
): Promise<void> {
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: organizationId,
|
||||
});
|
||||
const orgName = org?.data.name || 'an organization';
|
||||
|
||||
await this.receptionRef.receptionMailer.sendInvitationEmail(
|
||||
invitation.data.email,
|
||||
orgName,
|
||||
invitation.data.token,
|
||||
this.receptionRef.options.baseUrl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is admin/owner of organization
|
||||
*/
|
||||
private async verifyUserIsAdminOfOrg(userId: string, organizationId: string): Promise<void> {
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: { userId, organizationId },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.');
|
||||
}
|
||||
|
||||
const hasAdminRole = role.data.roles.some(r =>
|
||||
['owner', 'admin'].includes(r)
|
||||
);
|
||||
|
||||
if (!hasAdminRole) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'You do not have permission to perform this action.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is member of organization
|
||||
*/
|
||||
private async verifyUserIsMemberOfOrg(userId: string, organizationId: string): Promise<void> {
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: { userId, organizationId },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
private isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IIdpCliConfig {
|
||||
idpBaseUrl: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
export interface IStoredCredentials {
|
||||
refreshToken?: string;
|
||||
jwt?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IdpCli - A Node.js CLI client for idp.global
|
||||
* Uses file-based storage instead of browser webstore
|
||||
*/
|
||||
export class IdpCli {
|
||||
public config: IIdpCliConfig;
|
||||
public configDir: string;
|
||||
public credentialsPath: string;
|
||||
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private typedsocketDeferred = plugins.smartpromise.defer<plugins.typedsocket.TypedSocket>();
|
||||
|
||||
constructor(configArg: IIdpCliConfig) {
|
||||
this.config = configArg;
|
||||
this.configDir = configArg.configDir || plugins.path.join(plugins.os.homedir(), '.idp-global');
|
||||
this.credentialsPath = plugins.path.join(this.configDir, 'credentials.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure config directory exists
|
||||
*/
|
||||
private ensureConfigDir(): void {
|
||||
if (!plugins.fs.existsSync(this.configDir)) {
|
||||
plugins.fs.mkdirSync(this.configDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store credentials to file
|
||||
*/
|
||||
public storeCredentials(credentials: IStoredCredentials): void {
|
||||
this.ensureConfigDir();
|
||||
plugins.fs.writeFileSync(this.credentialsPath, JSON.stringify(credentials, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stored credentials
|
||||
*/
|
||||
public loadCredentials(): IStoredCredentials | null {
|
||||
try {
|
||||
if (!plugins.fs.existsSync(this.credentialsPath)) {
|
||||
return null;
|
||||
}
|
||||
const content = plugins.fs.readFileSync(this.credentialsPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored credentials (logout)
|
||||
*/
|
||||
public deleteCredentials(): void {
|
||||
try {
|
||||
if (plugins.fs.existsSync(this.credentialsPath)) {
|
||||
plugins.fs.unlinkSync(this.credentialsPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to IDP server via WebSocket
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.typedsocketDeferred.status === 'fulfilled') {
|
||||
return;
|
||||
}
|
||||
|
||||
let baseUrl = this.config.idpBaseUrl;
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
if (!baseUrl.endsWith('/typedrequest')) {
|
||||
baseUrl = `${baseUrl}/typedrequest`;
|
||||
}
|
||||
|
||||
console.log(`Connecting to ${baseUrl}...`);
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
this.typedrouter,
|
||||
baseUrl
|
||||
);
|
||||
this.typedsocketDeferred.resolve(this.typedsocket);
|
||||
console.log('Connected!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from IDP server
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.typedsocket) {
|
||||
await this.typedsocket.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Authentication Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
public async loginWithPassword(email: string, password: string): Promise<boolean> {
|
||||
await this.connect();
|
||||
|
||||
const loginRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (response.refreshToken) {
|
||||
this.storeCredentials({
|
||||
refreshToken: response.refreshToken,
|
||||
});
|
||||
console.log('Login successful!');
|
||||
return true;
|
||||
} else if (response.twoFaNeeded) {
|
||||
console.log('Two-factor authentication required.');
|
||||
return false;
|
||||
} else {
|
||||
console.log('Login failed.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with API token
|
||||
*/
|
||||
public async loginWithApiToken(apiToken: string): Promise<boolean> {
|
||||
await this.connect();
|
||||
|
||||
const loginRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithApiToken>(
|
||||
'loginWithApiToken'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
apiToken,
|
||||
});
|
||||
|
||||
if (response.jwt) {
|
||||
this.storeCredentials({
|
||||
jwt: response.jwt,
|
||||
});
|
||||
console.log('Login successful!');
|
||||
return true;
|
||||
} else {
|
||||
console.log('Login failed.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh JWT from stored refresh token
|
||||
*/
|
||||
public async refreshJwt(): Promise<string | null> {
|
||||
const credentials = this.loadCredentials();
|
||||
if (!credentials?.refreshToken) {
|
||||
console.error('No refresh token stored. Please login first.');
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
|
||||
const refreshRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
|
||||
const response = await refreshRequest.fire({
|
||||
refreshToken: credentials.refreshToken,
|
||||
});
|
||||
|
||||
if (response.jwt) {
|
||||
this.storeCredentials({
|
||||
...credentials,
|
||||
jwt: response.jwt,
|
||||
});
|
||||
return response.jwt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - clear stored credentials
|
||||
*/
|
||||
public async logout(): Promise<void> {
|
||||
const credentials = this.loadCredentials();
|
||||
|
||||
if (credentials?.refreshToken) {
|
||||
try {
|
||||
await this.connect();
|
||||
const logoutRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.ILogoutRequest>(
|
||||
'logout'
|
||||
);
|
||||
await logoutRequest.fire({
|
||||
refreshToken: credentials.refreshToken,
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors during server-side logout
|
||||
}
|
||||
}
|
||||
|
||||
this.deleteCredentials();
|
||||
console.log('Logged out successfully.');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
*/
|
||||
public async whoami(): Promise<plugins.idpInterfaces.data.IUser | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const whoIsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_WhoIs>(
|
||||
'whoIs'
|
||||
);
|
||||
|
||||
const response = await whoIsRequest.fire({ jwt });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user sessions
|
||||
*/
|
||||
public async getSessions(): Promise<plugins.idpInterfaces.request.IReq_GetUserSessions['response']['sessions'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const sessionsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||
'getUserSessions'
|
||||
);
|
||||
|
||||
const response = await sessionsRequest.fire({ jwt });
|
||||
return response.sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a session
|
||||
*/
|
||||
public async revokeSession(sessionId: string): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const revokeRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||
'revokeSession'
|
||||
);
|
||||
|
||||
const response = await revokeRequest.fire({ jwt, sessionId });
|
||||
return response.success;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Organization Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get organizations for current user
|
||||
*/
|
||||
public async getOrganizations(): Promise<{
|
||||
roles: plugins.idpInterfaces.data.IRole[];
|
||||
organizations: plugins.idpInterfaces.data.IOrganization[];
|
||||
} | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
const user = await this.whoami();
|
||||
if (!user) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const orgsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetRolesAndOrganizationsForUserId>(
|
||||
'getRolesAndOrganizationsForUserId'
|
||||
);
|
||||
|
||||
const response = await orgsRequest.fire({
|
||||
jwt,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new organization
|
||||
*/
|
||||
public async createOrganization(
|
||||
name: string,
|
||||
slug: string,
|
||||
mode: 'checkAvailability' | 'manifest' = 'manifest'
|
||||
): Promise<plugins.idpInterfaces.request.IReq_CreateOrganization['response'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
const user = await this.whoami();
|
||||
if (!user) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const createRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateOrganization>(
|
||||
'createOrganization'
|
||||
);
|
||||
|
||||
const response = await createRequest.fire({
|
||||
jwt,
|
||||
userId: user.id,
|
||||
organizationName: name,
|
||||
organizationSlug: slug,
|
||||
action: mode,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization members
|
||||
*/
|
||||
public async getOrgMembers(
|
||||
organizationId: string
|
||||
): Promise<plugins.idpInterfaces.request.IReq_GetOrgMembers['response']['members'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const membersRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||
'getOrgMembers'
|
||||
);
|
||||
|
||||
const response = await membersRequest.fire({
|
||||
jwt,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return response.members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to organization
|
||||
*/
|
||||
public async inviteMember(
|
||||
organizationId: string,
|
||||
email: string,
|
||||
roles: string[] = ['member']
|
||||
): Promise<plugins.idpInterfaces.request.IReq_CreateInvitation['response'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const inviteRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||
'createInvitation'
|
||||
);
|
||||
|
||||
const response = await inviteRequest.fire({
|
||||
jwt,
|
||||
organizationId,
|
||||
email,
|
||||
roles,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Admin Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if current user is global admin
|
||||
*/
|
||||
public async checkGlobalAdmin(): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const adminRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
|
||||
'checkGlobalAdmin'
|
||||
);
|
||||
|
||||
const response = await adminRequest.fire({ jwt });
|
||||
return response.isGlobalAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global app statistics (admin only)
|
||||
*/
|
||||
public async getGlobalAppStats(): Promise<plugins.idpInterfaces.request.IReq_GetGlobalAppStats['response']['apps'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const statsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'getGlobalAppStats'
|
||||
);
|
||||
|
||||
const response = await statsRequest.fire({ jwt });
|
||||
return response.apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a user (admin only)
|
||||
*/
|
||||
public async suspendUser(userId: string): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const suspendRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SuspendUser>(
|
||||
'suspendUser'
|
||||
);
|
||||
|
||||
await suspendRequest.fire({ jwt, userId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Ensure user is authenticated, refresh JWT if needed
|
||||
*/
|
||||
private async ensureAuthenticated(): Promise<string | null> {
|
||||
let credentials = this.loadCredentials();
|
||||
|
||||
if (!credentials) {
|
||||
console.error('Not logged in. Please run: idp login');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we have a JWT, return it
|
||||
if (credentials.jwt) {
|
||||
return credentials.jwt;
|
||||
}
|
||||
|
||||
// Otherwise, try to get a new JWT from refresh token
|
||||
if (credentials.refreshToken) {
|
||||
const jwt = await this.refreshJwt();
|
||||
return jwt;
|
||||
}
|
||||
|
||||
console.error('No valid credentials. Please run: idp login');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { IdpCli } from './classes.idpcli.js';
|
||||
|
||||
export { IdpCli } from './classes.idpcli.js';
|
||||
|
||||
const DEFAULT_IDP_URL = 'https://idp.global';
|
||||
|
||||
/**
|
||||
* Run the CLI
|
||||
*/
|
||||
export const runCli = async () => {
|
||||
const smartcliInstance = new plugins.smartcli.Smartcli();
|
||||
smartcliInstance.addVersion('1.0.0');
|
||||
|
||||
const getIdpClient = () => {
|
||||
const idpUrl = process.env.IDP_URL || DEFAULT_IDP_URL;
|
||||
return new IdpCli({ idpBaseUrl: idpUrl });
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Help
|
||||
// ============================================
|
||||
smartcliInstance.addHelp({
|
||||
helpText: `
|
||||
idp - CLI for idp.global identity provider
|
||||
|
||||
USAGE:
|
||||
idp <command> [options]
|
||||
|
||||
COMMANDS:
|
||||
login Login with email and password
|
||||
login-token Login with API token
|
||||
logout Logout and clear credentials
|
||||
whoami Show current user information
|
||||
|
||||
orgs List organizations
|
||||
orgs-create Create a new organization
|
||||
|
||||
members List organization members
|
||||
invite Invite a user to organization
|
||||
|
||||
sessions List active sessions
|
||||
revoke Revoke a session
|
||||
|
||||
admin-check Check if current user is global admin
|
||||
admin-apps List global apps (admin only)
|
||||
admin-suspend Suspend a user (admin only)
|
||||
|
||||
help Show this help message
|
||||
|
||||
ENVIRONMENT:
|
||||
IDP_URL Override IDP server URL (default: https://idp.global)
|
||||
|
||||
EXAMPLES:
|
||||
idp login
|
||||
idp whoami
|
||||
idp orgs
|
||||
idp members --org <org-id>
|
||||
idp invite --org <org-id> --email user@example.com
|
||||
`,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Login Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('login').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
|
||||
const emailAnswer = await interact.askQuestion({
|
||||
type: 'input',
|
||||
name: 'email',
|
||||
message: 'Email:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
const passwordAnswer = await interact.askQuestion({
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Password:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
await client.loginWithPassword(emailAnswer.value as string, passwordAnswer.value as string);
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('login-token').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
|
||||
const tokenAnswer = await interact.askQuestion({
|
||||
type: 'password',
|
||||
name: 'token',
|
||||
message: 'API Token:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
await client.loginWithApiToken(tokenAnswer.value as string);
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('logout').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
await client.logout();
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// User Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('whoami').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const user = await client.whoami();
|
||||
|
||||
if (user) {
|
||||
console.log('\nUser Information:');
|
||||
console.log(` ID: ${user.id}`);
|
||||
console.log(` Name: ${user.data?.name || 'N/A'}`);
|
||||
console.log(` Username: ${user.data?.username || 'N/A'}`);
|
||||
console.log(` Email: ${user.data?.email || 'N/A'}`);
|
||||
console.log(` Status: ${user.data?.status || 'N/A'}`);
|
||||
console.log(` Global Admin: ${user.data?.isGlobalAdmin ? 'Yes' : 'No'}`);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('sessions').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const sessions = await client.getSessions();
|
||||
|
||||
if (sessions) {
|
||||
console.log('\nActive Sessions:');
|
||||
for (const session of sessions) {
|
||||
console.log(` - ${session.id}`);
|
||||
console.log(` Device: ${session.deviceName || 'Unknown'}`);
|
||||
console.log(` Browser: ${session.browser || 'Unknown'}`);
|
||||
console.log(` OS: ${session.os || 'Unknown'}`);
|
||||
console.log(` Last Active: ${new Date(session.lastActive).toLocaleString()}`);
|
||||
console.log(` Current: ${session.isCurrent ? 'Yes' : 'No'}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('revoke').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const sessionId = argv.session || argv.s || argv._[1];
|
||||
|
||||
if (!sessionId) {
|
||||
console.error('Please provide a session ID: idp revoke --session <session-id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await client.revokeSession(sessionId);
|
||||
if (success) {
|
||||
console.log('Session revoked successfully.');
|
||||
} else {
|
||||
console.error('Failed to revoke session.');
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Organization Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('orgs').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const result = await client.getOrganizations();
|
||||
|
||||
if (result) {
|
||||
console.log('\nOrganizations:');
|
||||
for (const org of result.organizations) {
|
||||
const role = result.roles.find((r) => r.data?.organizationId === org.id);
|
||||
console.log(` - ${org.data?.name} (${org.id})`);
|
||||
console.log(` Slug: ${org.data?.slug}`);
|
||||
console.log(` Roles: ${role?.data?.roles?.join(', ') || 'Unknown'}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('orgs-create').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
|
||||
const nameAnswer = await interact.askQuestion({
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Organization Name:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
const slugAnswer = await interact.askQuestion({
|
||||
type: 'input',
|
||||
name: 'slug',
|
||||
message: 'Organization Slug:',
|
||||
default: '',
|
||||
});
|
||||
|
||||
// First check availability
|
||||
const checkResult = await client.createOrganization(
|
||||
nameAnswer.value as string,
|
||||
slugAnswer.value as string,
|
||||
'checkAvailability'
|
||||
);
|
||||
|
||||
if (!checkResult?.nameAvailable) {
|
||||
console.error('Organization name or slug is not available.');
|
||||
await client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Then create
|
||||
const result = await client.createOrganization(
|
||||
nameAnswer.value as string,
|
||||
slugAnswer.value as string,
|
||||
'manifest'
|
||||
);
|
||||
|
||||
if (result?.resultingOrganization) {
|
||||
console.log('\nOrganization created successfully!');
|
||||
console.log(` ID: ${result.resultingOrganization.id}`);
|
||||
console.log(` Name: ${result.resultingOrganization.data?.name}`);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Member Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('members').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const orgId = argv.org || argv.o || argv._[1];
|
||||
|
||||
if (!orgId) {
|
||||
console.error('Please provide an organization ID: idp members --org <org-id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await client.getOrgMembers(orgId);
|
||||
|
||||
if (members) {
|
||||
console.log('\nOrganization Members:');
|
||||
for (const member of members) {
|
||||
console.log(` - ${member.user.data?.name || 'Unknown'}`);
|
||||
console.log(` Email: ${member.user.data?.email || 'N/A'}`);
|
||||
console.log(` Roles: ${member.role.data?.roles?.join(', ') || 'Unknown'}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('invite').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const orgId = argv.org || argv.o;
|
||||
const email = argv.email || argv.e || argv._[1];
|
||||
|
||||
if (!orgId || !email) {
|
||||
console.error('Please provide organization ID and email:');
|
||||
console.error(' idp invite --org <org-id> --email user@example.com');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await client.inviteMember(orgId, email);
|
||||
|
||||
if (result?.success) {
|
||||
console.log(`Invitation sent to ${email}`);
|
||||
} else {
|
||||
console.error(`Failed to send invitation: ${result?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Admin Commands
|
||||
// ============================================
|
||||
smartcliInstance.addCommand('admin-check').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const isAdmin = await client.checkGlobalAdmin();
|
||||
|
||||
if (isAdmin) {
|
||||
console.log('You are a global admin.');
|
||||
} else {
|
||||
console.log('You are not a global admin.');
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('admin-apps').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const apps = await client.getGlobalAppStats();
|
||||
|
||||
if (apps) {
|
||||
console.log('\nGlobal Apps:');
|
||||
for (const appInfo of apps) {
|
||||
console.log(` - ${appInfo.app.data?.name}`);
|
||||
console.log(` ID: ${appInfo.app.id}`);
|
||||
console.log(` Connections: ${appInfo.connectionCount}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
smartcliInstance.addCommand('admin-suspend').subscribe(async (argv) => {
|
||||
const client = getIdpClient();
|
||||
const userId = argv.user || argv.u || argv._[1];
|
||||
|
||||
if (!userId) {
|
||||
console.error('Please provide a user ID: idp admin-suspend --user <user-id>');
|
||||
return;
|
||||
}
|
||||
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
const confirmAnswer = await interact.askQuestion({
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Are you sure you want to suspend user ${userId}?`,
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (confirmAnswer.value) {
|
||||
const success = await client.suspendUser(userId);
|
||||
if (success) {
|
||||
console.log('User suspended successfully.');
|
||||
} else {
|
||||
console.error('Failed to suspend user.');
|
||||
}
|
||||
} else {
|
||||
console.log('Operation cancelled.');
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Default/Standard command
|
||||
// ============================================
|
||||
smartcliInstance.standardCommand().subscribe(async (argv) => {
|
||||
// If no command specified, show help
|
||||
smartcliInstance.triggerCommand('help', argv);
|
||||
});
|
||||
|
||||
// Start parsing
|
||||
smartcliInstance.startParse();
|
||||
};
|
||||
|
||||
// Auto-run if this is the main module
|
||||
runCli().catch(console.error);
|
||||
@@ -0,0 +1,25 @@
|
||||
// node built-ins
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
export { fs, path, os };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartcli from '@push.rocks/smartcli';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartinteract from '@push.rocks/smartinteract';
|
||||
|
||||
export { smartcli, smartpromise, smartrx, smartinteract };
|
||||
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export { typedrequest, typedsocket };
|
||||
|
||||
// local
|
||||
import * as idpInterfaces from '../dist_ts_interfaces/index.js';
|
||||
|
||||
export { idpInterfaces };
|
||||
@@ -126,9 +126,9 @@ export class IdpClient {
|
||||
if (!refreshTokenArg) {
|
||||
extractedJwt = await this.helpers.extractDataFromJwtString(await this.getJwt());
|
||||
}
|
||||
await this.typedsocketDeferred.promise;
|
||||
const refreshJwtReq =
|
||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
this.parsedReceptionUrl.toString(),
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
const response = await refreshJwtReq.fire({
|
||||
@@ -149,9 +149,9 @@ export class IdpClient {
|
||||
public async getTransferToken(appDataArg?: plugins.idpInterfaces.data.IAppLegacy): Promise<string> {
|
||||
const jwt = await this.performJwtHousekeeping();
|
||||
const extractedJwt = await this.helpers.extractDataFromJwtString(jwt);
|
||||
await this.typedsocketDeferred.promise;
|
||||
const getTransferToken =
|
||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
this.parsedReceptionUrl.toString(),
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
const response = await getTransferToken.fire({
|
||||
@@ -187,9 +187,9 @@ export class IdpClient {
|
||||
const url = plugins.smarturl.Smarturl.createFromUrl(href);
|
||||
const transferToken = url.searchParams['transfertoken'];
|
||||
if (transferToken) {
|
||||
await this.typedsocketDeferred.promise;
|
||||
const getTransferToken =
|
||||
new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
this.parsedReceptionUrl.toString(),
|
||||
this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
const response = await getTransferToken.fire({
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IdpClient } from "./classes.idpclient.js";
|
||||
|
||||
/**
|
||||
* this class bundles all the typed requests that are used by the idp
|
||||
* All requests use TypedSocket (WebSocket) transport
|
||||
*/
|
||||
export class IdpRequests {
|
||||
idpClientArg: IdpClient;
|
||||
@@ -11,52 +12,315 @@ export class IdpRequests {
|
||||
}
|
||||
|
||||
public get afterRegistrationEmailClicked () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||
'afterRegistrationEmailClicked'
|
||||
);
|
||||
}
|
||||
|
||||
public get setData() {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||
'setDataForRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
public get mobileNumberVerification () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||
'mobileVerificationForRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public get finishRegistration() {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||
'finishRegistration'
|
||||
);
|
||||
}
|
||||
|
||||
public get loginWithUserNameAndPassword () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public get obtainJwt () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
}
|
||||
|
||||
public get obtainOneTimeToken () {
|
||||
return new plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
this.idpClientArg.parsedReceptionUrl.toString(),
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ExchangeRefreshTokenAndTransferToken>(
|
||||
'exchangeRefreshTokenAndTransferToken'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Login & Authentication
|
||||
// ============================================
|
||||
|
||||
public get loginWithEmail() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||
'loginWithEmail'
|
||||
);
|
||||
}
|
||||
|
||||
public get loginWithEmailAfterToken() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
||||
'loginWithEmailAfterEmailTokenAquired'
|
||||
);
|
||||
}
|
||||
|
||||
public get loginWithApiToken() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithApiToken>(
|
||||
'loginWithApiToken'
|
||||
);
|
||||
}
|
||||
|
||||
public get resetPassword() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||
'resetPassword'
|
||||
);
|
||||
}
|
||||
|
||||
public get setNewPassword() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
||||
'setNewPassword'
|
||||
);
|
||||
}
|
||||
|
||||
public get obtainDeviceId() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ObtainDeviceId>(
|
||||
'obtainDeviceId'
|
||||
);
|
||||
}
|
||||
|
||||
public get attachDeviceId() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AttachDeviceId>(
|
||||
'attachDeviceId'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Registration
|
||||
// ============================================
|
||||
|
||||
public get firstRegistration() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
|
||||
'firstRegistrationRequest'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Management
|
||||
// ============================================
|
||||
|
||||
public get getUserData() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserData>(
|
||||
'getUserData'
|
||||
);
|
||||
}
|
||||
|
||||
public get setUserData() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SetUserData>(
|
||||
'setUserData'
|
||||
);
|
||||
}
|
||||
|
||||
public get getUserSessions() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||
'getUserSessions'
|
||||
);
|
||||
}
|
||||
|
||||
public get revokeSession() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||
'revokeSession'
|
||||
);
|
||||
}
|
||||
|
||||
public get getUserActivity() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
|
||||
'getUserActivity'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Organization Management
|
||||
// ============================================
|
||||
|
||||
public get getOrganizationById() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrganizationById>(
|
||||
'getOrganizationById'
|
||||
);
|
||||
}
|
||||
|
||||
public get updateOrganization() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateOrganization>(
|
||||
'updateOrganization'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Member & Invitation Management
|
||||
// ============================================
|
||||
|
||||
public get createInvitation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||
'createInvitation'
|
||||
);
|
||||
}
|
||||
|
||||
public get getOrgInvitations() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
|
||||
'getOrgInvitations'
|
||||
);
|
||||
}
|
||||
|
||||
public get getOrgMembers() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||
'getOrgMembers'
|
||||
);
|
||||
}
|
||||
|
||||
public get cancelInvitation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CancelInvitation>(
|
||||
'cancelInvitation'
|
||||
);
|
||||
}
|
||||
|
||||
public get resendInvitation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResendInvitation>(
|
||||
'resendInvitation'
|
||||
);
|
||||
}
|
||||
|
||||
public get removeMember() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RemoveMember>(
|
||||
'removeMember'
|
||||
);
|
||||
}
|
||||
|
||||
public get updateMemberRoles() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateMemberRoles>(
|
||||
'updateMemberRoles'
|
||||
);
|
||||
}
|
||||
|
||||
public get transferOwnership() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_TransferOwnership>(
|
||||
'transferOwnership'
|
||||
);
|
||||
}
|
||||
|
||||
public get getInvitationByToken() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetInvitationByToken>(
|
||||
'getInvitationByToken'
|
||||
);
|
||||
}
|
||||
|
||||
public get acceptInvitation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_AcceptInvitation>(
|
||||
'acceptInvitation'
|
||||
);
|
||||
}
|
||||
|
||||
public get bulkCreateInvitations() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
|
||||
'bulkCreateInvitations'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Billing
|
||||
// ============================================
|
||||
|
||||
public get getBillingPlan() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetBillingPlan>(
|
||||
'getBillingPlan'
|
||||
);
|
||||
}
|
||||
|
||||
public get getPaddleConfig() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPaddleConfig>(
|
||||
'getPaddleConfig'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// JWT Verification & Management
|
||||
// ============================================
|
||||
|
||||
public get getPublicKeyForValidation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPublicKeyForValidation>(
|
||||
'getPublicKeyForValidation'
|
||||
);
|
||||
}
|
||||
|
||||
public get pushPublicKeyForValidation() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PushPublicKeyForValidation>(
|
||||
'pushPublicKeyForValidation'
|
||||
);
|
||||
}
|
||||
|
||||
public get pushOrGetJwtIdBlocklist() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PushOrGetJwtIdBlocklist>(
|
||||
'pushOrGetJwtIdBlocklist'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Suspension (Admin)
|
||||
// ============================================
|
||||
|
||||
public get suspendUser() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SuspendUser>(
|
||||
'suspendUser'
|
||||
);
|
||||
}
|
||||
|
||||
public get deleteSuspendedUser() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IDeleteSuspendedUser>(
|
||||
'deleteSuspendedUser'
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Admin (Global Admin Only)
|
||||
// ============================================
|
||||
|
||||
public get checkGlobalAdmin() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
|
||||
'checkGlobalAdmin'
|
||||
);
|
||||
}
|
||||
|
||||
public get getGlobalAppStats() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'getGlobalAppStats'
|
||||
);
|
||||
}
|
||||
|
||||
public get createGlobalApp() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
||||
'createGlobalApp'
|
||||
);
|
||||
}
|
||||
|
||||
public get updateGlobalApp() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
||||
'updateGlobalApp'
|
||||
);
|
||||
}
|
||||
|
||||
public get deleteGlobalApp() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
||||
'deleteGlobalApp'
|
||||
);
|
||||
}
|
||||
|
||||
public get regenerateAppCredentials() {
|
||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||
'regenerateAppCredentials'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +37,19 @@ export interface IReq_GetBillingPlan
|
||||
billingPlan: data.IBillingPlan;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Paddle configuration from environment variables
|
||||
*/
|
||||
export interface IReq_GetPaddleConfig
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetPaddleConfig
|
||||
> {
|
||||
method: 'getPaddleConfig';
|
||||
request: {};
|
||||
response: {
|
||||
paddleToken: string;
|
||||
paddlePriceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,3 +209,39 @@ export interface IReq_GetInvitationByToken
|
||||
requiresRegistration: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create invitations from a list (typically from CSV import)
|
||||
*/
|
||||
export interface IReq_BulkCreateInvitations
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_BulkCreateInvitations
|
||||
> {
|
||||
method: 'bulkCreateInvitations';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
invitations: Array<{
|
||||
email: string;
|
||||
roles?: string[];
|
||||
}>;
|
||||
defaultRoles: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
email: string;
|
||||
success: boolean;
|
||||
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
|
||||
message?: string;
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
invited: number;
|
||||
alreadyMembers: number;
|
||||
invalid: number;
|
||||
errors: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.9.0',
|
||||
version: '1.11.0',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { accountDesignTokens } from './sharedstyles.js';
|
||||
import { IdpState } from '../../states/idp.state.js';
|
||||
|
||||
interface IParsedEmail {
|
||||
email: string;
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IBulkInviteResult {
|
||||
invitedCount: number;
|
||||
failedCount: number;
|
||||
alreadyMemberCount: number;
|
||||
}
|
||||
|
||||
// Internal form element for reactive state management
|
||||
@customElement('idp-bulk-invite-form')
|
||||
export class BulkInviteForm extends DeesElement {
|
||||
@state()
|
||||
accessor organizationId: string = '';
|
||||
|
||||
@state()
|
||||
accessor organizationName: string = '';
|
||||
|
||||
@state()
|
||||
accessor parsedEmails: IParsedEmail[] = [];
|
||||
|
||||
@state()
|
||||
accessor selectedRoles: string[] = ['viewer'];
|
||||
|
||||
@state()
|
||||
accessor submitting: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor error: string = '';
|
||||
|
||||
@state()
|
||||
accessor results: IBulkInviteResult | null = null;
|
||||
|
||||
private static readonly AVAILABLE_ROLES = ['admin', 'editor', 'viewer', 'guest'];
|
||||
|
||||
public resolveWith: ((result: IBulkInviteResult | null) => void) | null = null;
|
||||
public modal: plugins.deesCatalog.DeesModal | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
accountDesignTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.file-upload-area {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.file-upload-area:hover {
|
||||
border-color: var(--muted-foreground);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.file-upload-area.has-data {
|
||||
border-style: solid;
|
||||
border-color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 32px;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.sample-link {
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.preview-stats .valid {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.preview-stats .invalid {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preview-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.preview-item.invalid {
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.preview-email {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.preview-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-status.valid {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.preview-status.invalid {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.role-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-option {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.role-option:hover {
|
||||
border-color: var(--foreground);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-option.selected {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
padding: 16px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.results-section.has-failures {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border-color: rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.results-stats {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.results) {
|
||||
return this.renderResults();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="description">
|
||||
Upload a CSV file with email addresses to invite multiple people at once.
|
||||
</div>
|
||||
|
||||
${this.error ? html`
|
||||
<div class="error-message">${this.error}</div>
|
||||
` : ''}
|
||||
|
||||
${this.renderFileUpload()}
|
||||
${this.parsedEmails.length > 0 ? this.renderPreview() : ''}
|
||||
${this.parsedEmails.length > 0 ? this.renderRoleSelector() : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFileUpload(): TemplateResult {
|
||||
const validCount = this.parsedEmails.filter(e => e.valid).length;
|
||||
const hasData = this.parsedEmails.length > 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="file-upload-area ${hasData ? 'has-data' : ''}"
|
||||
@click=${() => this.triggerFileInput()}
|
||||
@dragover=${(e: DragEvent) => { e.preventDefault(); }}
|
||||
@drop=${(e: DragEvent) => this.handleFileDrop(e)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
@change=${(e: Event) => this.handleFileSelect(e)}
|
||||
/>
|
||||
${hasData ? html`
|
||||
<div class="upload-icon">
|
||||
<dees-icon .icon=${'lucide:check-circle'}></dees-icon>
|
||||
</div>
|
||||
<div class="upload-text">${validCount} valid email(s) loaded</div>
|
||||
<div class="upload-hint">Click to replace with a different file</div>
|
||||
` : html`
|
||||
<div class="upload-icon">
|
||||
<dees-icon .icon=${'lucide:upload'}></dees-icon>
|
||||
</div>
|
||||
<div class="upload-text">Drop CSV file here or click to browse</div>
|
||||
<div class="upload-hint">
|
||||
<span class="sample-link" @click=${(e: Event) => { e.stopPropagation(); this.downloadSampleCSV(); }}>Download sample CSV</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPreview(): TemplateResult {
|
||||
const validCount = this.parsedEmails.filter(e => e.valid).length;
|
||||
const invalidCount = this.parsedEmails.filter(e => !e.valid).length;
|
||||
|
||||
return html`
|
||||
<div class="preview-section">
|
||||
<div class="preview-header">
|
||||
<span class="preview-title">Email Preview</span>
|
||||
<span class="preview-stats">
|
||||
<span class="valid">${validCount} valid</span>
|
||||
${invalidCount > 0 ? html`, <span class="invalid">${invalidCount} invalid</span>` : ''}
|
||||
</span>
|
||||
<button class="clear-button" @click=${() => this.clearEmails()}>Clear</button>
|
||||
</div>
|
||||
<div class="preview-list">
|
||||
${this.parsedEmails.map(item => html`
|
||||
<div class="preview-item ${item.valid ? '' : 'invalid'}">
|
||||
<span class="preview-email">${item.email}</span>
|
||||
<span class="preview-status ${item.valid ? 'valid' : 'invalid'}">
|
||||
${item.valid ? 'Valid' : (item.error || 'Invalid')}
|
||||
</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRoleSelector(): TemplateResult {
|
||||
return html`
|
||||
<div class="role-section">
|
||||
<div class="section-label">Assign Role</div>
|
||||
<div class="role-selector">
|
||||
${BulkInviteForm.AVAILABLE_ROLES.map(role => html`
|
||||
<button
|
||||
class="role-option ${this.selectedRoles.includes(role) ? 'selected' : ''}"
|
||||
@click=${() => this.toggleRole(role)}
|
||||
?disabled=${this.submitting}
|
||||
>
|
||||
${role}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResults(): TemplateResult {
|
||||
const hasFailures = this.results!.failedCount > 0 || this.results!.alreadyMemberCount > 0;
|
||||
|
||||
return html`
|
||||
<div class="results-section ${hasFailures ? 'has-failures' : ''}">
|
||||
<div class="results-title">Bulk Invite Complete</div>
|
||||
<div class="results-stats">
|
||||
${this.results!.invitedCount} invitation(s) sent successfully.
|
||||
${this.results!.alreadyMemberCount > 0 ? html`<br>${this.results!.alreadyMemberCount} already member(s).` : ''}
|
||||
${this.results!.failedCount > 0 ? html`<br>${this.results!.failedCount} failed.` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private triggerFileInput(): void {
|
||||
const input = this.shadowRoot?.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
input?.click();
|
||||
}
|
||||
|
||||
private handleFileDrop(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) {
|
||||
this.parseCSVFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFileSelect(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
this.parseCSVFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
private async parseCSVFile(file: File): Promise<void> {
|
||||
const text = await file.text();
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim());
|
||||
|
||||
const parsed: IParsedEmail[] = [];
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Skip header row if it looks like "email" or similar
|
||||
if (i === 0 && (line.toLowerCase() === 'email' || line.toLowerCase() === 'emails' || line.toLowerCase() === 'e-mail')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email from line (handle quoted values, commas)
|
||||
const email = line.replace(/["']/g, '').split(',')[0].trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(email)) {
|
||||
parsed.push({ email, valid: false, error: 'Duplicate' });
|
||||
continue;
|
||||
}
|
||||
seen.add(email);
|
||||
|
||||
if (!emailRegex.test(email)) {
|
||||
parsed.push({ email, valid: false, error: 'Invalid format' });
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed.push({ email, valid: true });
|
||||
}
|
||||
|
||||
this.parsedEmails = parsed;
|
||||
this.error = '';
|
||||
}
|
||||
|
||||
private downloadSampleCSV(): void {
|
||||
const content = 'email\nuser1@example.com\nuser2@example.com\nuser3@example.com';
|
||||
const blob = new Blob([content], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sample-invite-list.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private clearEmails(): void {
|
||||
this.parsedEmails = [];
|
||||
this.error = '';
|
||||
}
|
||||
|
||||
private toggleRole(role: string): void {
|
||||
if (this.selectedRoles.includes(role)) {
|
||||
this.selectedRoles = this.selectedRoles.filter(r => r !== role);
|
||||
} else {
|
||||
this.selectedRoles = [...this.selectedRoles, role];
|
||||
}
|
||||
if (this.selectedRoles.length === 0) {
|
||||
this.selectedRoles = ['viewer'];
|
||||
}
|
||||
}
|
||||
|
||||
public canSubmit(): boolean {
|
||||
const validEmails = this.parsedEmails.filter(e => e.valid);
|
||||
return validEmails.length > 0 && this.selectedRoles.length > 0 && !this.submitting && !this.results;
|
||||
}
|
||||
|
||||
public async handleSubmit(): Promise<IBulkInviteResult | null> {
|
||||
if (!this.canSubmit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const validEmails = this.parsedEmails.filter(e => e.valid);
|
||||
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
|
||||
'bulkCreateInvitations'
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
invitations: validEmails.map(e => ({ email: e.email })),
|
||||
defaultRoles: this.selectedRoles,
|
||||
});
|
||||
|
||||
this.results = {
|
||||
invitedCount: response.summary.invited,
|
||||
failedCount: response.summary.errors + response.summary.invalid,
|
||||
alreadyMemberCount: response.summary.alreadyMembers,
|
||||
};
|
||||
|
||||
return this.results;
|
||||
} catch (error) {
|
||||
console.error('Error sending bulk invitations:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to send invitations. Please try again.';
|
||||
return null;
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
public handleCancel(): void {
|
||||
this.modal?.destroy();
|
||||
this.resolveWith?.(null);
|
||||
}
|
||||
|
||||
public handleClose(): void {
|
||||
this.modal?.destroy();
|
||||
this.resolveWith?.(this.results);
|
||||
}
|
||||
}
|
||||
|
||||
// Export the modal utility class
|
||||
export class BulkInviteModal {
|
||||
public static async show(options: {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
}): Promise<IBulkInviteResult | null> {
|
||||
return new Promise<IBulkInviteResult | null>((resolve) => {
|
||||
const formElement = new BulkInviteForm();
|
||||
formElement.organizationId = options.organizationId;
|
||||
formElement.organizationName = options.organizationName;
|
||||
formElement.resolveWith = resolve;
|
||||
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Bulk Invite Members',
|
||||
content: html`${formElement}`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async () => {
|
||||
formElement.handleCancel();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Send Invitations',
|
||||
action: async () => {
|
||||
const result = await formElement.handleSubmit();
|
||||
if (result) {
|
||||
// Wait a bit for user to see results, then close
|
||||
setTimeout(() => {
|
||||
formElement.handleClose();
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
width: 520,
|
||||
}).then((modal) => {
|
||||
formElement.modal = modal;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,16 @@ export class IdpAccountContent extends DeesElement {
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
});
|
||||
|
||||
this.subrouter.on('/org/:orgName/users', async () => {
|
||||
viewcontainer.classList.add('changing');
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
console.log('We are viewing the users page');
|
||||
await cleanupViews();
|
||||
viewcontainer.append(new views.UsersView());
|
||||
viewcontainer.classList.remove('changing');
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
});
|
||||
|
||||
this.subrouter.on('/admin', async () => {
|
||||
viewcontainer.classList.add('changing');
|
||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||
|
||||
@@ -267,9 +267,12 @@ class CreateOrgForm extends DeesElement {
|
||||
'manifest'
|
||||
);
|
||||
|
||||
// Update state with new organization
|
||||
// Update state with new organization and role
|
||||
const currentState = accountStateModule.accountState.getState();
|
||||
currentState.organizations.push(result.resultingOrganization);
|
||||
if (result.role) {
|
||||
currentState.roles.push(result.role);
|
||||
}
|
||||
accountStateModule.accountState.dispatchAction(
|
||||
accountStateModule.setSelectedOrg,
|
||||
result.resultingOrganization
|
||||
|
||||
@@ -279,8 +279,8 @@ export class LeleAccountNavigation extends DeesElement {
|
||||
Apps
|
||||
</div>
|
||||
<div
|
||||
class="navigationOption"
|
||||
@click=${async () => {}}
|
||||
class="navigationOption ${this.isActive('users') ? 'active' : ''}"
|
||||
@click=${() => this.navigateToOrgPage('users')}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:users'}></dees-icon>
|
||||
Users
|
||||
|
||||
@@ -617,8 +617,7 @@ export class AdminView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'getGlobalAppStats'
|
||||
);
|
||||
|
||||
@@ -644,8 +643,7 @@ export class AdminView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
||||
'createGlobalApp'
|
||||
);
|
||||
|
||||
@@ -682,8 +680,7 @@ export class AdminView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
||||
'updateGlobalApp'
|
||||
);
|
||||
|
||||
@@ -717,8 +714,7 @@ export class AdminView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||
'regenerateAppCredentials'
|
||||
);
|
||||
|
||||
@@ -739,8 +735,7 @@ export class AdminView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
||||
'deleteGlobalApp'
|
||||
);
|
||||
|
||||
|
||||
@@ -374,8 +374,7 @@ export class AppsView extends DeesElement {
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
// Fetch global apps
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
||||
'getGlobalApps'
|
||||
);
|
||||
|
||||
@@ -384,8 +383,7 @@ export class AppsView extends DeesElement {
|
||||
});
|
||||
|
||||
// Fetch connections for this organization
|
||||
const connectionsRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
||||
'/typedrequest',
|
||||
const connectionsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
||||
'getAppConnections'
|
||||
);
|
||||
|
||||
@@ -424,8 +422,7 @@ export class AppsView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
|
||||
'toggleAppConnection'
|
||||
);
|
||||
|
||||
|
||||
@@ -266,6 +266,19 @@ export class BaseView extends DeesElement {
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #27272a;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-out;
|
||||
opacity: 1;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.session-item.removing {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.session-item:last-child {
|
||||
@@ -566,7 +579,7 @@ export class BaseView extends DeesElement {
|
||||
<div class="org-list">
|
||||
${this.organizations.map((org) => {
|
||||
const roleObj = this.roles.find(r => r.data.organizationId === org.id);
|
||||
const roleName = roleObj?.data.role || 'member';
|
||||
const roleName = roleObj?.data.roles?.[0] || 'member';
|
||||
const roleClass = roleName === 'owner' ? 'owner' :
|
||||
roleName === 'admin' ? 'admin' : '';
|
||||
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
|
||||
@@ -600,7 +613,7 @@ export class BaseView extends DeesElement {
|
||||
return html`
|
||||
<div class="session-list">
|
||||
${this.sessions.map((session) => html`
|
||||
<div class="session-item">
|
||||
<div class="session-item" data-session-id=${session.id}>
|
||||
<div class="session-icon ${session.isCurrent ? 'current' : ''}">
|
||||
<dees-icon .icon=${this.getDeviceIcon(session.os)}></dees-icon>
|
||||
</div>
|
||||
@@ -754,8 +767,7 @@ export class BaseView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||
'getUserSessions'
|
||||
);
|
||||
|
||||
@@ -772,8 +784,7 @@ export class BaseView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
|
||||
'getUserActivity'
|
||||
);
|
||||
|
||||
@@ -794,12 +805,19 @@ export class BaseView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||
'/typedrequest',
|
||||
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||
'revokeSession'
|
||||
);
|
||||
|
||||
await typedRequest.fire({ jwt, sessionId });
|
||||
|
||||
// Animate the session item collapse before removing from DOM
|
||||
const sessionElement = this.shadowRoot?.querySelector(`[data-session-id="${sessionId}"]`) as HTMLElement;
|
||||
if (sessionElement) {
|
||||
sessionElement.classList.add('removing');
|
||||
await new Promise(resolve => setTimeout(resolve, 300)); // Wait for animation
|
||||
}
|
||||
|
||||
await this.loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Error revoking session:', error);
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './orgsetup.js';
|
||||
export * from './orgview.js';
|
||||
export * from './paddlesetup.js';
|
||||
export * from './subscriptions.js';
|
||||
export * from './usersview.js';
|
||||
|
||||
@@ -328,7 +328,7 @@ export class OrgView extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const roleName = this.userRole?.data.role || 'member';
|
||||
const roleName = this.userRole?.data.roles?.[0] || 'member';
|
||||
const roleClass = roleName === 'owner' ? 'owner' : roleName === 'admin' ? 'admin' : '';
|
||||
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
|
||||
|
||||
@@ -472,8 +472,7 @@ export class OrgView extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const connectionsRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
||||
'/typedrequest',
|
||||
const connectionsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
||||
'getAppConnections'
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import sharedStyles from '../sharedstyles.js';
|
||||
import * as state from '../../../states/accountstate.js';
|
||||
import { IdpState } from '../../../states/idp.state.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -61,28 +62,50 @@ export class PaddleSetupView extends DeesElement {
|
||||
public async openPaddle() {
|
||||
await this.domtoolsPromise;
|
||||
const paddleButton = this.shadowRoot.querySelector('dees-button');
|
||||
await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/paddle.js');
|
||||
globalThis.Paddle.Setup({
|
||||
vendor: 30954,
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
|
||||
// Get user email - first try from state, then fetch directly
|
||||
let userEmail = state.accountState.getState().user?.data?.email;
|
||||
|
||||
if (!userEmail) {
|
||||
// State not loaded, fetch user directly
|
||||
const whoIsResponse = await idpState.idpClient.whoIs().catch(() => null);
|
||||
userEmail = whoIsResponse?.user?.data?.email;
|
||||
}
|
||||
|
||||
if (!userEmail) {
|
||||
console.error('Unable to get user email for Paddle checkout');
|
||||
paddleButton.status = 'error';
|
||||
paddleButton.text = 'Error: Not logged in';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch Paddle config from backend
|
||||
const configRequest = idpState.idpClient.typedsocket
|
||||
.createTypedRequest<plugins.idpInterfaces.request.IReq_GetPaddleConfig>('getPaddleConfig');
|
||||
const { paddleToken, paddlePriceId } = await configRequest.fire({});
|
||||
|
||||
await this.domtools.setExternalScript('https://cdn.paddle.com/paddle/v2/paddle.js');
|
||||
globalThis.Paddle.Initialize({
|
||||
token: paddleToken,
|
||||
eventCallback: async (dataArg: any) => {
|
||||
// The data.event will specify the event type
|
||||
if (dataArg.event === 'Checkout.Complete') {
|
||||
const data: plugins.idpInterfaces.data.IPaddleCheckoutData = dataArg.eventData;
|
||||
// Paddle Billing v2 event handling
|
||||
if (dataArg.name === 'checkout.completed') {
|
||||
const paddleIframe = document.body.querySelector('iframe');
|
||||
if (paddleIframe) {
|
||||
document.body.removeChild(paddleIframe);
|
||||
}
|
||||
paddleButton.status = 'pending';
|
||||
paddleButton.text = 'Processing...';
|
||||
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, data.checkout.id);
|
||||
await state.accountState.dispatchAction(state.updatePaddleCheckoutId, dataArg.data.transaction_id);
|
||||
paddleButton.status = 'success';
|
||||
paddleButton.text = 'Paddle connected!'
|
||||
}
|
||||
},
|
||||
});
|
||||
globalThis.Paddle.Checkout.open({
|
||||
product: 561076,
|
||||
email: 'phil@kunz.io',
|
||||
items: [{ priceId: paddlePriceId, quantity: 1 }],
|
||||
customer: { email: userEmail },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
html,
|
||||
cssManager,
|
||||
css,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
|
||||
import * as accountState from '../../../states/accountstate.js';
|
||||
import { IdpState } from '../../../states/idp.state.js';
|
||||
import { BulkInviteModal } from '../bulk-invite-modal.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'lele-accountview-users': UsersView;
|
||||
}
|
||||
}
|
||||
|
||||
interface IMemberDisplay {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
interface IInvitationDisplay {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
invitedAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
@customElement('lele-accountview-users')
|
||||
export class UsersView extends DeesElement {
|
||||
@state()
|
||||
accessor members: IMemberDisplay[] = [];
|
||||
|
||||
@state()
|
||||
accessor invitations: IInvitationDisplay[] = [];
|
||||
|
||||
@state()
|
||||
accessor loading: boolean = true;
|
||||
|
||||
@state()
|
||||
accessor activeTab: 'members' | 'pending' | 'invite' = 'members';
|
||||
|
||||
@state()
|
||||
accessor organizationId: string = '';
|
||||
|
||||
@state()
|
||||
accessor organizationName: string = '';
|
||||
|
||||
@state()
|
||||
accessor inviteEmail: string = '';
|
||||
|
||||
@state()
|
||||
accessor inviteRoles: string[] = ['viewer'];
|
||||
|
||||
@state()
|
||||
accessor isAdmin: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor isOwner: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor currentUserId: string = '';
|
||||
|
||||
@state()
|
||||
accessor submitting: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor actionMessage: { type: 'success' | 'error'; text: string } | null = null;
|
||||
|
||||
private static readonly AVAILABLE_ROLES = ['owner', 'admin', 'editor', 'viewer', 'guest'];
|
||||
|
||||
private emailInputSubscribed: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
accountDesignTokens,
|
||||
cardStyles,
|
||||
typographyStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 48px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--foreground);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.member-card:hover {
|
||||
border-color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.member-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.member-email {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.member-roles {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.role-badge.owner {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.role-badge.editor {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.role-badge.viewer {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.role-badge.guest {
|
||||
background: rgba(168, 162, 158, 0.2);
|
||||
color: #a8a29e;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
border-color: var(--foreground);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.action-button.danger:hover {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.invitation-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.invitation-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.invitation-email {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.invitation-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.invite-form {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-option {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.role-option:hover {
|
||||
border-color: var(--foreground);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-option.selected {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.you-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<h1>Users</h1>
|
||||
<p>Manage members and invitations for ${this.organizationName || 'your organization'}.</p>
|
||||
|
||||
${this.actionMessage ? html`
|
||||
<div class="message ${this.actionMessage.type}">${this.actionMessage.text}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab ${this.activeTab === 'members' ? 'active' : ''}"
|
||||
@click=${() => this.activeTab = 'members'}
|
||||
>
|
||||
Members (${this.members.length})
|
||||
</button>
|
||||
<button
|
||||
class="tab ${this.activeTab === 'pending' ? 'active' : ''}"
|
||||
@click=${() => this.activeTab = 'pending'}
|
||||
>
|
||||
Pending (${this.invitations.length})
|
||||
</button>
|
||||
${this.isAdmin ? html`
|
||||
<button
|
||||
class="tab ${this.activeTab === 'invite' ? 'active' : ''}"
|
||||
@click=${() => this.activeTab = 'invite'}
|
||||
>
|
||||
Invite
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${this.renderTabContent()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTabContent() {
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="loading">
|
||||
<span>Loading users...</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
switch (this.activeTab) {
|
||||
case 'members':
|
||||
return this.renderMembers();
|
||||
case 'pending':
|
||||
return this.renderPendingInvitations();
|
||||
case 'invite':
|
||||
return this.renderInviteForm();
|
||||
}
|
||||
}
|
||||
|
||||
private renderMembers() {
|
||||
if (this.members.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:users'}></dees-icon>
|
||||
<h2>No Members</h2>
|
||||
<p>This organization has no members yet.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="member-list">
|
||||
${this.members.map(member => html`
|
||||
<div class="member-card">
|
||||
<div class="member-info">
|
||||
<div class="member-avatar">
|
||||
${member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<span class="member-name">
|
||||
${member.name}
|
||||
${member.userId === this.currentUserId ? html`<span class="you-badge">You</span>` : ''}
|
||||
</span>
|
||||
<span class="member-email">${member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-roles">
|
||||
${member.roles.map(role => html`
|
||||
<span class="role-badge ${role}">${role}</span>
|
||||
`)}
|
||||
</div>
|
||||
${member.userId !== this.currentUserId ? html`
|
||||
<div class="member-actions">
|
||||
${this.isOwner && !member.isOwner ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${() => this.handleTransferOwnership(member.userId, member.name)}
|
||||
?disabled=${this.submitting}
|
||||
title="Transfer ownership to this member"
|
||||
>
|
||||
Transfer Ownership
|
||||
</button>
|
||||
` : ''}
|
||||
${this.isAdmin ? html`
|
||||
<button
|
||||
class="action-button danger"
|
||||
@click=${() => this.handleRemoveMember(member.userId, member.name)}
|
||||
?disabled=${this.submitting || member.isOwner}
|
||||
title=${member.isOwner ? 'Cannot remove owner' : 'Remove member'}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPendingInvitations() {
|
||||
if (this.invitations.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:mail'}></dees-icon>
|
||||
<h2>No Pending Invitations</h2>
|
||||
<p>There are no pending invitations for this organization.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="member-list">
|
||||
${this.invitations.map(inv => html`
|
||||
<div class="invitation-card">
|
||||
<div class="invitation-info">
|
||||
<span class="invitation-email">${inv.email}</span>
|
||||
<span class="invitation-meta">
|
||||
Invited ${this.formatDate(inv.invitedAt)} · Expires ${this.formatDate(inv.expiresAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="member-roles">
|
||||
${inv.roles.map(role => html`
|
||||
<span class="role-badge ${role}">${role}</span>
|
||||
`)}
|
||||
</div>
|
||||
${this.isAdmin ? html`
|
||||
<div class="member-actions">
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${() => this.handleResendInvitation(inv.id)}
|
||||
?disabled=${this.submitting}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
<button
|
||||
class="action-button danger"
|
||||
@click=${() => this.handleCancelInvitation(inv.id, inv.email)}
|
||||
?disabled=${this.submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderInviteForm(): TemplateResult {
|
||||
return html`
|
||||
<div class="invite-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email Address</label>
|
||||
<dees-input-text
|
||||
.label=${''}
|
||||
.placeholder=${'Enter email address'}
|
||||
.value=${this.inviteEmail}
|
||||
?disabled=${this.submitting}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Role</label>
|
||||
<div class="role-selector">
|
||||
${UsersView.AVAILABLE_ROLES.filter(r => r !== 'owner').map(role => html`
|
||||
<button
|
||||
class="role-option ${this.inviteRoles.includes(role) ? 'selected' : ''}"
|
||||
@click=${() => this.toggleRole(role)}
|
||||
?disabled=${this.submitting}
|
||||
>
|
||||
${role}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dees-button
|
||||
.text=${'Send Invitation'}
|
||||
.status=${this.submitting ? 'pending' : 'normal'}
|
||||
@click=${() => this.handleSendInvitation()}
|
||||
></dees-button>
|
||||
|
||||
<div style="margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--border);">
|
||||
<p style="color: var(--muted-foreground); font-size: 13px; margin: 0 0 12px 0;">
|
||||
Need to invite multiple people at once?
|
||||
</p>
|
||||
<dees-button
|
||||
.text=${'Import from CSV'}
|
||||
.type=${'secondary'}
|
||||
@click=${() => this.handleBulkImport()}
|
||||
></dees-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
public updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// Subscribe to email input when Invite tab is shown
|
||||
if (this.activeTab === 'invite' && !this.emailInputSubscribed) {
|
||||
const emailInput = this.shadowRoot?.querySelector('.invite-form dees-input-text') as any;
|
||||
if (emailInput?.changeSubject) {
|
||||
emailInput.changeSubject.subscribe((element: any) => {
|
||||
this.inviteEmail = element.value;
|
||||
});
|
||||
this.emailInputSubscribed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
// Get the organization from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const orgSlug = pathParts[3];
|
||||
|
||||
const currentState = accountState.accountState.getState();
|
||||
const selectedOrg = currentState.organizations.find(org => org.data.slug === orgSlug);
|
||||
|
||||
if (!selectedOrg) {
|
||||
console.error('Organization not found');
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.organizationId = selectedOrg.id;
|
||||
this.organizationName = selectedOrg.data.name;
|
||||
this.currentUserId = currentState.user?.id || '';
|
||||
|
||||
// Check if current user is admin/owner
|
||||
const currentUserRole = currentState.roles.find(
|
||||
r => r.data.organizationId === this.organizationId && r.data.userId === this.currentUserId
|
||||
);
|
||||
this.isAdmin = currentUserRole?.data?.roles?.some(r => ['owner', 'admin'].includes(r)) ?? false;
|
||||
this.isOwner = currentUserRole?.data?.roles?.includes('owner') ?? false;
|
||||
|
||||
// Get JWT from IdpState
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
// Fetch members
|
||||
const membersRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||
'getOrgMembers'
|
||||
);
|
||||
|
||||
const membersResponse = await membersRequest.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
});
|
||||
|
||||
this.members = membersResponse.members.map(m => ({
|
||||
userId: m.user.id,
|
||||
name: m.user.data.name || m.user.data.username || 'Unknown',
|
||||
email: m.user.data.email,
|
||||
roles: m.role.data.roles || [],
|
||||
isOwner: m.role.data.roles?.includes('owner') ?? false,
|
||||
}));
|
||||
|
||||
// Fetch invitations if admin
|
||||
if (this.isAdmin) {
|
||||
const invitationsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
|
||||
'getOrgInvitations'
|
||||
);
|
||||
|
||||
const invitationsResponse = await invitationsRequest.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
});
|
||||
|
||||
this.invitations = invitationsResponse.invitations.map(inv => {
|
||||
const orgRef = inv.data.organizationRefs.find(ref => ref.organizationId === this.organizationId);
|
||||
return {
|
||||
id: inv.id,
|
||||
email: inv.data.email,
|
||||
roles: orgRef?.roles || [],
|
||||
invitedAt: orgRef?.invitedAt || inv.data.createdAt,
|
||||
expiresAt: inv.data.expiresAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private toggleRole(role: string) {
|
||||
if (this.inviteRoles.includes(role)) {
|
||||
this.inviteRoles = this.inviteRoles.filter(r => r !== role);
|
||||
} else {
|
||||
this.inviteRoles = [...this.inviteRoles, role];
|
||||
}
|
||||
// Ensure at least one role is selected
|
||||
if (this.inviteRoles.length === 0) {
|
||||
this.inviteRoles = ['viewer'];
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSendInvitation() {
|
||||
if (!this.inviteEmail.trim()) {
|
||||
this.showMessage('error', 'Please enter an email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inviteRoles.length === 0) {
|
||||
this.showMessage('error', 'Please select at least one role.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.actionMessage = null;
|
||||
|
||||
try {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||
'createInvitation'
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
email: this.inviteEmail.trim(),
|
||||
roles: this.inviteRoles,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage('success', response.message || 'Invitation sent successfully!');
|
||||
this.inviteEmail = '';
|
||||
this.inviteRoles = ['viewer'];
|
||||
await this.loadData();
|
||||
this.activeTab = 'pending';
|
||||
} else {
|
||||
this.showMessage('error', response.message || 'Failed to send invitation.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending invitation:', error);
|
||||
this.showMessage('error', 'Failed to send invitation. Please try again.');
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResendInvitation(invitationId: string) {
|
||||
this.submitting = true;
|
||||
this.actionMessage = null;
|
||||
|
||||
try {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResendInvitation>(
|
||||
'resendInvitation'
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage('success', 'Invitation resent successfully!');
|
||||
await this.loadData();
|
||||
} else {
|
||||
this.showMessage('error', response.message || 'Failed to resend invitation.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resending invitation:', error);
|
||||
this.showMessage('error', 'Failed to resend invitation. Please try again.');
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCancelInvitation(invitationId: string, email: string) {
|
||||
if (!confirm(`Cancel invitation for ${email}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.actionMessage = null;
|
||||
|
||||
try {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CancelInvitation>(
|
||||
'cancelInvitation'
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage('success', 'Invitation cancelled.');
|
||||
await this.loadData();
|
||||
} else {
|
||||
this.showMessage('error', response.message || 'Failed to cancel invitation.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling invitation:', error);
|
||||
this.showMessage('error', 'Failed to cancel invitation. Please try again.');
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRemoveMember(userId: string, name: string) {
|
||||
if (!confirm(`Remove ${name} from this organization?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.actionMessage = null;
|
||||
|
||||
try {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RemoveMember>(
|
||||
'removeMember'
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage('success', `${name} has been removed from the organization.`);
|
||||
await this.loadData();
|
||||
} else {
|
||||
this.showMessage('error', response.message || 'Failed to remove member.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing member:', error);
|
||||
this.showMessage('error', 'Failed to remove member. Please try again.');
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTransferOwnership(newOwnerId: string, name: string) {
|
||||
const confirmed = await this.showTransferConfirmation(name);
|
||||
if (!confirmed) return;
|
||||
|
||||
this.submitting = true;
|
||||
this.actionMessage = null;
|
||||
|
||||
try {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const jwt = await idpState.idpClient.getJwt();
|
||||
|
||||
const request = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_TransferOwnership>(
|
||||
'transferOwnership'
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
jwt,
|
||||
organizationId: this.organizationId,
|
||||
newOwnerId,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage('success', `Ownership transferred to ${name}. You are now an admin.`);
|
||||
await this.loadData();
|
||||
} else {
|
||||
this.showMessage('error', response.message || 'Failed to transfer ownership.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error transferring ownership:', error);
|
||||
this.showMessage('error', 'Failed to transfer ownership. Please try again.');
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async showTransferConfirmation(name: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Transfer Ownership',
|
||||
content: html`
|
||||
<div style="padding: 16px 0;">
|
||||
<p style="margin: 0 0 12px 0;">Are you sure you want to transfer ownership to <strong>${name}</strong>?</p>
|
||||
<p style="margin: 0; color: var(--muted-foreground);">
|
||||
You will be demoted to admin role and will no longer be the owner of this organization.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal) => { modal.destroy(); resolve(false); } },
|
||||
{ name: 'Transfer Ownership', action: async (modal) => { modal.destroy(); resolve(true); } },
|
||||
],
|
||||
width: 420,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleBulkImport() {
|
||||
const result = await BulkInviteModal.show({
|
||||
organizationId: this.organizationId,
|
||||
organizationName: this.organizationName,
|
||||
});
|
||||
|
||||
if (result && result.invitedCount > 0) {
|
||||
this.showMessage('success', `${result.invitedCount} invitation(s) sent successfully.`);
|
||||
await this.loadData();
|
||||
this.activeTab = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
private showMessage(type: 'success' | 'error', text: string) {
|
||||
this.actionMessage = { type, text };
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.actionMessage = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -174,13 +174,11 @@ export class IdpLoginPrompt extends DeesElement {
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
||||
const loginRequestWithUsernameAndPassword =
|
||||
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'/typedrequest',
|
||||
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword'
|
||||
);
|
||||
const loginRequestWithEmail =
|
||||
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||
'/typedrequest',
|
||||
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||
'loginWithEmail'
|
||||
);
|
||||
|
||||
|
||||
@@ -170,9 +170,9 @@ export class IdpRegistrationPrompt extends DeesElement {
|
||||
private register = async (valueArg: { emailAddress: string }) => {
|
||||
const registrationForm: DeesForm = this.shadowRoot.querySelector('#registrationForm');
|
||||
registrationForm.setStatus('pending', 'registering...');
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const firstSignupRequest =
|
||||
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
|
||||
'/typedrequest',
|
||||
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
|
||||
'firstRegistrationRequest'
|
||||
);
|
||||
const response = await firstSignupRequest
|
||||
@@ -209,8 +209,8 @@ export class IdpRegistrationPrompt extends DeesElement {
|
||||
public async handleRefreshToken(refreshTokenArg: string, delayDispatchMillisArg = 0) {
|
||||
// a refreshToken binds directly to a session.
|
||||
// the refresh token is used on a continuous basis to get fresh and short-lived jwts
|
||||
const refreshJwt = new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'/typedrequest',
|
||||
const idpState = await IdpState.getSingletonInstance();
|
||||
const refreshJwt = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
const responseJwt = await refreshJwt.fire({
|
||||
|
||||
+3
-5
@@ -12,7 +12,7 @@ const run = async () => {
|
||||
metaObject: {
|
||||
title: 'idp.global',
|
||||
description:
|
||||
'the code that runs idp.global',
|
||||
'Your permanent identity on the web',
|
||||
canonicalDomain: 'https://idp.global',
|
||||
ldCompany: {
|
||||
name: 'Task Venture Capital GmbH',
|
||||
@@ -29,9 +29,7 @@ const run = async () => {
|
||||
description: 'work',
|
||||
name: 'Task Venture Capital GmbH',
|
||||
type: 'company',
|
||||
facebookUrl: 'https://www.facebook.com/undefined variable',
|
||||
twitterUrl: 'https://twitter.com/undefined variable',
|
||||
website: 'https://Task Venture Capital GmbH',
|
||||
website: 'https://task.vc',
|
||||
phone: '+49 421 16767 548',
|
||||
},
|
||||
closedDate: null,
|
||||
@@ -44,7 +42,7 @@ const run = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
// const serviceWorker = await serviceworker.getServiceworkerClient();
|
||||
await serviceworker.getServiceworkerClient();
|
||||
|
||||
const mainTemplate = html`
|
||||
<style>
|
||||
|
||||
@@ -87,6 +87,9 @@ export const manifestNewOrgName = accountState.createAction(async (statePartArg,
|
||||
'manifest'
|
||||
);
|
||||
currentState.organizations.push(result.resultingOrganization);
|
||||
if (result.role) {
|
||||
currentState.roles.push(result.role);
|
||||
}
|
||||
currentState.selectedOrg = result.resultingOrganization;
|
||||
return currentState;
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export class IdpState {
|
||||
}>
|
||||
|
||||
public async init() {
|
||||
this.idpClient.enableTypedSocket();
|
||||
await this.idpClient.enableTypedSocket();
|
||||
const domtoolsInstance = await domtools.DomTools.setupDomTools();
|
||||
this.domtools = domtoolsInstance;
|
||||
const state = new plugins.deesDomtools.plugins.smartstate.Smartstate<'main'>();
|
||||
|
||||
Reference in New Issue
Block a user