11 Commits

Author SHA1 Message Date
jkunz dddd968796 v1.10.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-07 20:45:30 +00:00
jkunz 2cdf86744e feat(billingplan): Add Paddle v2 checkout support and backend config endpoint; add CSP headers and bump typedserver 2025-12-07 20:45:30 +00:00
jkunz 9d9f90c1d5 feat(account): enhance session item removal animation and update metadata description 2025-12-05 10:23:49 +00:00
jkunz 833cf3b4b8 feat: Update organization member management and bulk invite functionality
- Marked the status of "Invite and Manage Team Members" story as Complete in README.
- Updated the status of ORG-002 to Complete in the corresponding markdown file.
- Modified OrganizationManager to assign roles as 'owner' during organization creation.
- Implemented bulk invitation feature in UserInvitationManager, allowing multiple users to be invited via CSV upload.
- Added IReq_BulkCreateInvitations interface for bulk invitation requests.
- Enhanced CreateOrgForm to update state with new roles upon organization creation.
- Introduced BulkInviteModal for bulk inviting users, including email validation and role assignment.
- Updated UsersView to support ownership transfer and bulk invitation functionality.
- Improved account state management to handle new roles and organizations.
2025-12-05 09:34:19 +00:00
jkunz 8df44b99b9 feat: Enhance WebSocket integration and add SPA fallback for routing 2025-12-04 18:06:49 +00:00
jkunz d32103618f update 2025-12-04 17:45:40 +00:00
jkunz a83858beb0 v1.9.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 20:03:34 +00:00
jkunz 5f29edf449 feat(account): Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking 2025-12-01 20:03:34 +00:00
jkunz 173735a84e v1.8.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-12-01 18:56:16 +00:00
jkunz 8756258324 feat(reception): Add activity logging, session metadata and org-selection UI (backend and frontend) 2025-12-01 18:56:16 +00:00
jkunz d11f5a0c72 fix(deps): update @push.rocks/smartdata and @git.zone/tswatch versions; refactor App and Jwt manager instantiation 2025-12-01 18:07:34 +00:00
52 changed files with 6036 additions and 1112 deletions
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## 2025-12-07 - 1.10.0 - feat(billingplan)
Add Paddle v2 checkout support and backend config endpoint; add CSP headers and bump typedserver
- Add getPaddleConfig typedrequest handler in BillingPlanManager to expose PADDLE_TOKEN and PADDLE_PRICE_ID from environment.
- Introduce IReq_GetPaddleConfig typedrequest interface.
- Update frontend paddlesetup to use Paddle v2: load v2 script, call Paddle.Initialize with token, open Checkout using items.priceId and customer.email, and handle checkout.completed events (store transaction_id).
- Attempt to obtain user email from account state or via idpClient.whoIs before starting checkout; show error if email unavailable.
- Add Content Security Policy securityHeaders to website server configuration to allow Paddle, ProfitWell, Sentry and related assets/connections.
- Bump dependency @api.global/typedserver from ^7.8.17 to ^7.10.2.
## 2025-12-01 - 1.9.0 - feat(account)
Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking
- Replace inline modal elements with programmatic / static show() calls for OrgSelectModal and CreateOrgModal; navigation now reacts to the results returned from show() and pushes appropriate URLs.
- Remove embedded <idp-org-select-modal> and <idp-create-org-modal> elements from the account template to use on-demand modal invocation.
- Navigation component now exposes currentPath state, listens to popstate, and watches for external URL changes (requestAnimationFrame loop) to keep UI in sync with location changes.
- Updated readme.hints.md with guidance for dees-catalog components and clarified dees-input-* event pattern (use RxJS Subjects, subscribe to changeSubject and access element.value).
## 2025-12-01 - 1.8.0 - feat(reception)
Add activity logging, session metadata and org-selection UI (backend and frontend)
- Introduce ActivityLog and ActivityLogManager to track user actions (TActivityAction, IActivityLog) for audit/display.
- Export new activity interface (IActivityLog) from ts_interfaces and add type TActivityAction.
- Wire ActivityLogManager into Reception so activity logging is available via the typed router.
- Enhance LoginSession data model with deviceInfo, createdAt and lastActive fields for richer session metadata.
- Add getUserSessions typed handler to return detailed session list (device, browser, os, ip, createdAt, lastActive, isCurrent).
- Revoke session endpoint now logs a 'session_revoked' activity when a session is revoked (and blocks revoking the current session).
- Add request interfaces IReq_GetUserSessions and IReq_GetUserActivity to typed request definitions.
- Frontend: account element now includes org-select and create-org modals, OrgView route, and handlers to open modals and navigate to new org/billing pages.
- Frontend: organization dropdown adds a '+ Create new...' option and wiring to open the creation modal.
- Minor refactors and routing exports: account index exports new modal components and views updated (OrgView).
## 2025-12-01 - 1.7.0 - feat(admin)
Add global admin functionality: backend admin APIs, model fields and UI integration
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@idp.global/idp.global",
"version": "1.7.0",
"version": "1.10.0",
"description": "An identity provider software managing user authentications, registrations, and sessions.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
@@ -16,17 +16,17 @@
"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.10.2",
"@api.global/typedsocket": "^4.1.0",
"@consent.software/catalog": "^2.0.1",
"@design.estate/dees-catalog": "^2.0.2",
"@design.estate/dees-catalog": "^2.0.3",
"@design.estate/dees-domtools": "^2.3.6",
"@design.estate/dees-element": "^2.1.3",
"@push.rocks/lik": "^6.2.2",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartdata": "^7.0.14",
"@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smarthash": "^3.2.6",
"@push.rocks/smartjson": "^5.2.0",
@@ -40,19 +40,19 @@
"@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/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/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tswatch": "^2.2.1",
"@git.zone/tswatch": "^2.2.3",
"@push.rocks/projectinfo": "^5.0.1",
"@types/node": "^24.10.1"
},
+640 -791
View File
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -1,3 +1,23 @@
# Project Readme Hints
This is the initial readme hints file.
## UI Components
Always check dees-catalog for available elements before implementing custom solutions:
- Documentation: https://code.foss.global/design.estate/dees-catalog
- Key components: `dees-modal`, `dees-button`, `dees-input-*`, `dees-form`, etc.
### dees-input-* Event Pattern
All dees-input components use **RxJS Subjects** for value changes, NOT DOM events:
```typescript
// Subscribe to value changes in firstUpdated():
const inputElement = this.shadowRoot.querySelector('dees-input-text');
inputElement.changeSubject.subscribe((element) => {
const value = element.value;
// handle value change
});
```
- Do NOT use `@changeValue` or similar DOM events - they don't exist
- The Subject emits the element itself, access value via `element.value`
## Project Structure
- `ts_web/elements/account/` - Account dashboard components
- `ts_web/states/` - State management (accountstate, idp.state)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.7.0',
version: '1.10.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.'
}
+15
View File
@@ -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
+62
View File
@@ -0,0 +1,62 @@
import * as plugins from '../plugins.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
/**
* ActivityLog tracks user actions for audit and display purposes
*/
@plugins.smartdata.Manager()
export class ActivityLog extends plugins.smartdata.SmartDataDbDoc<
ActivityLog,
plugins.idpInterfaces.data.IActivityLog,
ActivityLogManager
> {
// ======
// static
// ======
public static async createActivityLog(
managerArg: ActivityLogManager,
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
const activityLog = new managerArg.CActivityLog();
activityLog.id = plugins.smartunique.shortId();
activityLog.data = {
userId,
action,
timestamp: Date.now(),
metadata: {
description,
...metadata,
},
};
await activityLog.save();
return activityLog;
}
// ========
// INSTANCE
// ========
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IActivityLog['data'] = {
userId: null,
action: null,
timestamp: null,
metadata: {
description: null,
},
};
constructor() {
super();
}
}
@@ -0,0 +1,77 @@
import * as plugins from '../plugins.js';
import { ActivityLog } from './classes.activitylog.js';
import { Reception } from './classes.reception.js';
export class ActivityLogManager {
// refs
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public CActivityLog = plugins.smartdata.setDefaultManagerForDoc(this, ActivityLog);
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
// Get user activity handler
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'getUserActivity',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
const limit = requestArg.limit || 20;
const offset = requestArg.offset || 0;
// Get activities for this user
const activities = await this.CActivityLog.getInstances({
'data.userId': jwt.data.userId,
});
// Sort by timestamp descending
const sortedActivities = activities
.sort((a, b) => b.data.timestamp - a.data.timestamp)
.slice(offset, offset + limit);
return {
activities: sortedActivities.map((a) => ({
id: a.id,
data: a.data,
})),
total: activities.length,
};
}
)
);
}
/**
* Log a user activity
*/
public async logActivity(
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
return await ActivityLog.createActivityLog(
this,
userId,
action,
description,
metadata
);
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js';
import type { Reception } from './classes.reception.js';
import { App } from './classes.app.js';
// Note: App class is imported for use with setDefaultManagerForDoc
export class AppManager {
public receptionRef: Reception;
@@ -95,7 +96,7 @@ export class AppManager {
const clientSecret = plugins.smartunique.shortId(32);
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
const app = new App();
const app = new this.CApp();
app.id = `app-${plugins.smartunique.shortId(8)}`;
app.type = 'global';
app.data = {
@@ -304,7 +305,7 @@ export class AppManager {
for (const appData of defaultGlobalApps) {
const existing = await this.CApp.getInstance({ id: appData.id });
if (!existing) {
const app = new App();
const app = new this.CApp();
app.id = appData.id!;
app.type = appData.type!;
app.data = appData.data as any;
+12 -1
View File
@@ -59,6 +59,17 @@ export class BillingPlanManager {
}
}
}
}))
}));
// Paddle configuration endpoint
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPaddleConfig>(
'getPaddleConfig',
async () => ({
paddleToken: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PADDLE_TOKEN'),
paddlePriceId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PADDLE_PRICE_ID'),
})
)
);
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ export class JwtManager {
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> {
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
const jwt = await Jwt.getInstance({
const jwt = await this.CJwt.getInstance({
id: jwtData.id,
});
if (jwt.blocked) {
+4 -1
View File
@@ -60,7 +60,10 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
invalidated: false,
refreshToken: null,
deviceId: null
deviceId: null,
deviceInfo: null,
createdAt: Date.now(),
lastActive: Date.now(),
};
public transferToken: string;
@@ -259,6 +259,83 @@ export class LoginSessionManager {
ok: false
}
})
);
// Get all sessions for the current user
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'getUserSessions',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
// Get the current session's refresh token to identify the current session
const currentRefreshToken = jwt.data.refreshToken;
// Get all sessions for this user
const sessions = await this.CLoginSession.getInstances({
'data.userId': jwt.data.userId,
'data.invalidated': false,
});
return {
sessions: sessions.map((session) => ({
id: session.id,
deviceId: session.data.deviceId || 'unknown',
deviceName: session.data.deviceInfo?.deviceName || 'Unknown Device',
browser: session.data.deviceInfo?.browser || 'Unknown Browser',
os: session.data.deviceInfo?.os || 'Unknown OS',
ip: session.data.deviceInfo?.ip || 'Unknown',
lastActive: session.data.lastActive || session.data.createdAt || Date.now(),
createdAt: session.data.createdAt || Date.now(),
isCurrent: session.data.refreshToken === currentRefreshToken,
})),
};
}
)
);
// Revoke a specific session
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokeSession>(
'revokeSession',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
// Get the session to revoke
const sessionToRevoke = await this.CLoginSession.getInstance({
id: requestArg.sessionId,
'data.userId': jwt.data.userId, // Ensure user can only revoke their own sessions
});
if (!sessionToRevoke) {
throw new plugins.typedrequest.TypedResponseError('Session not found');
}
// Don't allow revoking the current session via this method
if (sessionToRevoke.data.refreshToken === jwt.data.refreshToken) {
throw new plugins.typedrequest.TypedResponseError(
'Cannot revoke current session. Use logout instead.'
);
}
await sessionToRevoke.invalidate();
// Log the activity
await this.receptionRef.activityLogManager.logActivity(
jwt.data.userId,
'session_revoked',
`Revoked session on ${sessionToRevoke.data.deviceInfo?.deviceName || 'unknown device'}`
);
return { success: true };
}
)
);
}
}
+1 -1
View File
@@ -35,6 +35,6 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<
public async checkIfUserIsAdmin(userArg: User) {
const role = await this.manager.receptionRef.roleManager.getRoleForUserAndOrg(userArg, this);
return role.data.role === 'admin';
return role.data.roles?.includes('admin') || role.data.roles?.includes('owner');
}
}
+3 -2
View File
@@ -50,13 +50,14 @@ export class OrganizationManager {
action: 'create',
organizationId: newOrg.id,
userId: userData.id,
role: 'admin',
roles: ['owner'],
});
newOrg.data.roleIds.push(role.id);
await newOrg.save();
return {
nameAvailable: true,
resultingOrganization: await newOrg.createSavableObject()
resultingOrganization: await newOrg.createSavableObject(),
role: await role.createSavableObject(),
}
break;
}
+4
View File
@@ -15,6 +15,8 @@ import { RoleManager } from './classes.rolemanager.js';
import { BillingPlanManager } from './classes.billingplanmanager.js';
import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
import { UserInvitationManager } from './classes.userinvitationmanager.js';
export interface IReceptionOptions {
/**
@@ -45,6 +47,8 @@ export class Reception {
public billingPlanManager = new BillingPlanManager(this);
public appManager = new AppManager(this);
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
public userInvitationManager = new UserInvitationManager(this);
housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) {
+29
View File
@@ -268,4 +268,33 @@ export class ReceptionMailer {
`),
});
}
public sendInvitationEmail(
email: string,
organizationName: string,
invitationToken: string,
baseUrl: string
) {
const invitationUrl = `${baseUrl}/invite?token=${encodeURI(invitationToken)}`;
this.receptionRef.szPlatformClient.emailConnector.sendEmail({
from: `idp.global@${this.receptionRef.options.baseUrl} <noreply@mail.workspace.global>`,
title: `You've been invited to join ${organizationName}`,
to: email,
body: this.createBodyString(`
<h1>You're Invited!</h1>
<p>You've been invited to join <b>${organizationName}</b> on idp.global.</p>
<p>Click the button below to accept the invitation and join the organization.</p>
<a href="${invitationUrl}"><div class="button">
Accept Invitation
</div></a>
<p style="color: #888888; font-size: 12px; margin-top: 20px;">
If you don't have an account yet, you'll be able to create one when you accept the invitation.
</p>
<p style="color: #888888; font-size: 12px;">
This invitation will expire in 90 days.
</p>
`),
});
}
}
+48 -2
View File
@@ -15,13 +15,24 @@ export class RoleManager {
this.receptionRef = receptionRefArg;
}
/**
* Create, change, or delete a role for a user in an organization.
* Supports both old single-role and new multi-role patterns.
*/
public async modifyRoleForUserAtOrg(optionsArg: {
action: 'create' | 'change' | 'delete';
userId: string;
organizationId: string;
role: plugins.idpInterfaces.data.IRole['data']['role'];
/** @deprecated Use `roles` instead */
role?: string;
/** Array of roles to assign */
roles?: string[];
}) {
let returnRole: Role;
// Support both old single role and new roles array
const roles = optionsArg.roles || (optionsArg.role ? [optionsArg.role] : ['viewer']);
switch (optionsArg.action) {
case 'create':
returnRole = new this.CRole();
@@ -29,9 +40,35 @@ export class RoleManager {
returnRole.data = {
userId: optionsArg.userId,
organizationId: optionsArg.organizationId,
role: optionsArg.role,
roles: roles,
};
await returnRole.save();
break;
case 'change':
returnRole = await this.CRole.getInstance({
data: {
userId: optionsArg.userId,
organizationId: optionsArg.organizationId,
},
});
if (returnRole) {
returnRole.data.roles = roles;
await returnRole.save();
}
break;
case 'delete':
returnRole = await this.CRole.getInstance({
data: {
userId: optionsArg.userId,
organizationId: optionsArg.organizationId,
},
});
if (returnRole) {
await returnRole.delete();
}
break;
}
return returnRole;
}
@@ -54,4 +91,13 @@ export class RoleManager {
});
return roles;
}
public async getAllRolesForOrg(organizationId: string) {
const roles = await this.CRole.getInstances({
data: {
organizationId: organizationId
}
});
return roles;
}
}
+136
View File
@@ -0,0 +1,136 @@
import * as plugins from '../plugins.js';
/**
* UserInvitation represents an invitation to join one or more organizations.
*
* Key characteristics:
* - Unique by email (multiple orgs can share the same invitation)
* - Converts to real User on registration
* - Can fold into existing user if they add the email as secondary
* - Auto-expires after 90 days
*/
@plugins.smartdata.Manager()
export class UserInvitation extends plugins.smartdata.SmartDataDbDoc<
UserInvitation,
plugins.idpInterfaces.data.IUserInvitation
> {
// STATIC
public static readonly EXPIRY_DAYS = 90;
public static generateToken(): string {
return plugins.smartunique.shortId() + '-' + plugins.smartunique.shortId();
}
public static async createNewInvitation(
email: string,
organizationId: string,
invitedByUserId: string,
roles: string[]
): Promise<UserInvitation> {
const invitation = new UserInvitation();
invitation.id = plugins.smartunique.shortId();
const now = Date.now();
const expiresAt = now + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000);
invitation.data = {
email: email.toLowerCase().trim(),
token: UserInvitation.generateToken(),
status: 'pending',
createdAt: now,
expiresAt: expiresAt,
organizationRefs: [{
organizationId,
invitedByUserId,
invitedAt: now,
roles,
}],
};
await invitation.save();
return invitation;
}
// INSTANCE
@plugins.smartdata.unI()
id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IUserInvitation['data'];
constructor() {
super();
}
/**
* Add another organization to this invitation
*/
public async addOrganization(
organizationId: string,
invitedByUserId: string,
roles: string[]
): Promise<void> {
// Check if org already exists
const existingRef = this.data.organizationRefs.find(
ref => ref.organizationId === organizationId
);
if (existingRef) {
// Update roles for existing org ref
existingRef.roles = roles;
existingRef.invitedAt = Date.now();
existingRef.invitedByUserId = invitedByUserId;
} else {
// Add new org ref
this.data.organizationRefs.push({
organizationId,
invitedByUserId,
invitedAt: Date.now(),
roles,
});
}
await this.save();
}
/**
* Remove an organization from this invitation
*/
public async removeOrganization(organizationId: string): Promise<void> {
this.data.organizationRefs = this.data.organizationRefs.filter(
ref => ref.organizationId !== organizationId
);
// If no more org refs, cancel the invitation
if (this.data.organizationRefs.length === 0) {
this.data.status = 'cancelled';
}
await this.save();
}
/**
* Check if invitation is expired
*/
public isExpired(): boolean {
return Date.now() > this.data.expiresAt || this.data.status === 'expired';
}
/**
* Mark invitation as accepted and record the user ID
*/
public async accept(userId: string): Promise<void> {
this.data.status = 'accepted';
this.data.acceptedAt = Date.now();
this.data.convertedToUserId = userId;
await this.save();
}
/**
* Regenerate token and extend expiry (for resend)
*/
public async regenerateToken(): Promise<void> {
this.data.token = UserInvitation.generateToken();
this.data.expiresAt = Date.now() + (UserInvitation.EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await this.save();
}
}
@@ -0,0 +1,717 @@
import * as plugins from '../plugins.js';
import { Reception } from './classes.reception.js';
import { UserInvitation } from './classes.userinvitation.js';
import { Organization } from './classes.organization.js';
import { User } from './classes.user.js';
import { Role } from './classes.role.js';
export class UserInvitationManager {
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedrouter = new plugins.typedrequest.TypedRouter();
public CUserInvitation = plugins.smartdata.setDefaultManagerForDoc(this, UserInvitation);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
this.setupHandlers();
}
private setupHandlers() {
// Create invitation
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreateInvitation>(
'createInvitation',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
const email = requestArg.email.toLowerCase().trim();
// Check if user with this email already exists
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
data: { email },
});
if (existingUser) {
// User already exists - just add them to the org directly
const existingRole = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: existingUser.id,
organizationId: requestArg.organizationId,
},
});
if (existingRole) {
return {
success: false,
isNew: false,
message: 'User is already a member of this organization.',
};
}
// Add user to org with the specified roles
await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
action: 'create',
userId: existingUser.id,
organizationId: requestArg.organizationId,
roles: requestArg.roles,
});
return {
success: true,
isNew: false,
message: 'Existing user has been added to the organization.',
};
}
// Check if invitation already exists for this email
let invitation = await this.CUserInvitation.getInstance({
data: { email },
});
let isNew = false;
if (invitation) {
// Add org to existing invitation
await invitation.addOrganization(requestArg.organizationId, user.id, requestArg.roles);
} else {
// Create new invitation
invitation = await UserInvitation.createNewInvitation(
email,
requestArg.organizationId,
user.id,
requestArg.roles
);
isNew = true;
}
// Send invitation email
await this.sendInvitationEmail(invitation, requestArg.organizationId);
return {
success: true,
invitation: await invitation.createSavableObject(),
isNew,
};
}
)
);
// Get org invitations
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
'getOrgInvitations',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
const allInvitations = await this.CUserInvitation.getInstances({});
const orgInvitations = allInvitations.filter(inv =>
inv.data.status === 'pending' &&
!inv.isExpired() &&
inv.data.organizationRefs.some(ref => ref.organizationId === requestArg.organizationId)
);
return {
invitations: await Promise.all(orgInvitations.map(inv => inv.createSavableObject())),
};
}
)
);
// Get org members
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
'getOrgMembers',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
await this.verifyUserIsMemberOfOrg(user.id, requestArg.organizationId);
const roles = await this.receptionRef.roleManager.CRole.getInstances({
data: { organizationId: requestArg.organizationId },
});
const members: Array<{
user: plugins.idpInterfaces.data.IUser;
role: plugins.idpInterfaces.data.IRole;
}> = [];
for (const role of roles) {
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
id: role.data.userId,
});
if (memberUser) {
members.push({
user: await memberUser.createSavableObject(),
role: await role.createSavableObject(),
});
}
}
return { members };
}
)
);
// Cancel invitation
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CancelInvitation>(
'cancelInvitation',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
const invitation = await this.CUserInvitation.getInstance({ id: requestArg.invitationId });
if (!invitation) {
return { success: false, message: 'Invitation not found.' };
}
await invitation.removeOrganization(requestArg.organizationId);
return { success: true };
}
)
);
// Resend invitation
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResendInvitation>(
'resendInvitation',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
const invitation = await this.CUserInvitation.getInstance({ id: requestArg.invitationId });
if (!invitation) {
return { success: false, message: 'Invitation not found.' };
}
await invitation.regenerateToken();
await this.sendInvitationEmail(invitation, requestArg.organizationId);
return { success: true, message: 'Invitation resent.' };
}
)
);
// Remove member
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RemoveMember>(
'removeMember',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
// Cannot remove yourself if you're the only owner
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: requestArg.userId,
organizationId: requestArg.organizationId,
},
});
if (!role) {
return { success: false, message: 'Member not found.' };
}
// Check if trying to remove an owner
if (role.data.roles.includes('owner')) {
// Count owners
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
data: { organizationId: requestArg.organizationId },
});
const ownerCount = allRoles.filter(r => r.data.roles.includes('owner')).length;
if (ownerCount <= 1) {
return {
success: false,
message: 'Cannot remove the last owner. Transfer ownership first.',
};
}
}
await role.delete();
// Remove org from user's connectedOrgs
const memberUser = await this.receptionRef.userManager.CUser.getInstance({
id: requestArg.userId,
});
if (memberUser && memberUser.data.connectedOrgs) {
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
orgId => orgId !== requestArg.organizationId
);
await memberUser.save();
}
return { success: true };
}
)
);
// Update member roles
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateMemberRoles>(
'updateMemberRoles',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: requestArg.userId,
organizationId: requestArg.organizationId,
},
});
if (!role) {
return { success: false, message: 'Member not found.' };
}
// If removing owner role, check we're not removing the last owner
if (role.data.roles.includes('owner') && !requestArg.roles.includes('owner')) {
const allRoles = await this.receptionRef.roleManager.CRole.getInstances({
data: { organizationId: requestArg.organizationId },
});
const ownerCount = allRoles.filter(r => r.data.roles.includes('owner')).length;
if (ownerCount <= 1) {
return {
success: false,
message: 'Cannot remove owner role from the last owner.',
};
}
}
role.data.roles = requestArg.roles;
await role.save();
return { success: true, role: await role.createSavableObject() };
}
)
);
// Transfer ownership
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_TransferOwnership>(
'transferOwnership',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
// Verify current user is an owner
const currentUserRole = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: user.id,
organizationId: requestArg.organizationId,
},
});
if (!currentUserRole || !currentUserRole.data.roles.includes('owner')) {
throw new plugins.typedrequest.TypedResponseError(
'Only owners can transfer ownership.'
);
}
// Get new owner's role
const newOwnerRole = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: requestArg.newOwnerId,
organizationId: requestArg.organizationId,
},
});
if (!newOwnerRole) {
return { success: false, message: 'New owner must be a member of the organization.' };
}
// Add owner role to new owner
if (!newOwnerRole.data.roles.includes('owner')) {
newOwnerRole.data.roles.push('owner');
await newOwnerRole.save();
}
// Remove owner role from current user (but keep other roles)
currentUserRole.data.roles = currentUserRole.data.roles.filter(r => r !== 'owner');
if (currentUserRole.data.roles.length === 0) {
currentUserRole.data.roles = ['admin']; // Demote to admin
}
await currentUserRole.save();
return { success: true };
}
)
);
// Get invitation by token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetInvitationByToken>(
'getInvitationByToken',
async (requestArg) => {
const invitation = await this.CUserInvitation.getInstance({
data: { token: requestArg.token },
});
if (!invitation) {
return { isExpired: true, requiresRegistration: false };
}
if (invitation.isExpired()) {
return { isExpired: true, requiresRegistration: false };
}
// Get organization names
const organizations: Array<{ id: string; name: string }> = [];
for (const ref of invitation.data.organizationRefs) {
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: ref.organizationId,
});
if (org) {
organizations.push({ id: org.id, name: org.data.name });
}
}
// Check if user with this email exists
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
data: { email: invitation.data.email },
});
return {
invitation: await invitation.createSavableObject(),
organizations,
isExpired: false,
requiresRegistration: !existingUser,
};
}
)
);
// Accept invitation
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AcceptInvitation>(
'acceptInvitation',
async (requestArg) => {
const invitation = await this.CUserInvitation.getInstance({
data: { token: requestArg.token },
});
if (!invitation) {
return { success: false, message: 'Invalid invitation token.' };
}
if (invitation.isExpired()) {
return { success: false, message: 'This invitation has expired.' };
}
const user = await this.receptionRef.userManager.CUser.getInstance({
id: requestArg.userId,
});
if (!user) {
return { success: false, message: 'User not found.' };
}
// Create roles for each organization
const organizations: plugins.idpInterfaces.data.IOrganization[] = [];
const roles: plugins.idpInterfaces.data.IRole[] = [];
for (const ref of invitation.data.organizationRefs) {
// Check if role already exists
let role = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: user.id,
organizationId: ref.organizationId,
},
});
if (!role) {
role = await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
action: 'create',
userId: user.id,
organizationId: ref.organizationId,
roles: ref.roles,
});
}
roles.push(await role.createSavableObject());
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: ref.organizationId,
});
if (org) {
// Add role to org's roleIds if not already there
if (!org.data.roleIds.includes(role.id)) {
org.data.roleIds.push(role.id);
await org.save();
}
organizations.push(await org.createSavableObject());
}
// Update user's connectedOrgs
if (!user.data.connectedOrgs) {
user.data.connectedOrgs = [];
}
if (!user.data.connectedOrgs.includes(ref.organizationId)) {
user.data.connectedOrgs.push(ref.organizationId);
}
}
await user.save();
await invitation.accept(user.id);
return { success: true, organizations, roles };
}
)
);
// Bulk create invitations
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
'bulkCreateInvitations',
async (requestArg) => {
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: requestArg.organizationId,
});
const orgName = org?.data.name || 'an organization';
const results: Array<{
email: string;
success: boolean;
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
message?: string;
}> = [];
const summary = {
total: 0,
invited: 0,
alreadyMembers: 0,
invalid: 0,
errors: 0,
};
// Deduplicate emails in the batch
const processedEmails = new Set<string>();
for (const inv of requestArg.invitations) {
summary.total++;
const email = inv.email?.toLowerCase().trim();
// Validate email format
if (!email || !this.isValidEmail(email)) {
results.push({
email: inv.email || '',
success: false,
status: 'invalid_email',
message: 'Invalid email format',
});
summary.invalid++;
continue;
}
// Skip duplicates within batch
if (processedEmails.has(email)) {
results.push({
email,
success: false,
status: 'invalid_email',
message: 'Duplicate email in batch',
});
summary.invalid++;
continue;
}
processedEmails.add(email);
try {
// Check if user with this email already exists
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
data: { email },
});
if (existingUser) {
// Check if already a member
const existingRole = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: existingUser.id,
organizationId: requestArg.organizationId,
},
});
if (existingRole) {
results.push({
email,
success: false,
status: 'already_member',
message: 'Already a member of this organization',
});
summary.alreadyMembers++;
continue;
}
// Add existing user to org
const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles;
await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
action: 'create',
userId: existingUser.id,
organizationId: requestArg.organizationId,
roles,
});
results.push({
email,
success: true,
status: 'invited',
message: 'Existing user added to organization',
});
summary.invited++;
continue;
}
// Check if invitation already exists
let invitation = await this.CUserInvitation.getInstance({
data: { email },
});
const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles;
if (invitation) {
// Add org to existing invitation
await invitation.addOrganization(requestArg.organizationId, user.id, roles);
} else {
// Create new invitation
invitation = await UserInvitation.createNewInvitation(
email,
requestArg.organizationId,
user.id,
roles
);
}
// Send invitation email
await this.receptionRef.receptionMailer.sendInvitationEmail(
email,
orgName,
invitation.data.token,
this.receptionRef.options.baseUrl
);
results.push({
email,
success: true,
status: 'invited',
});
summary.invited++;
} catch (error: any) {
results.push({
email,
success: false,
status: 'error',
message: error.message || 'Unknown error',
});
summary.errors++;
}
}
return { success: true, results, summary };
}
)
);
}
/**
* Find invitation by email
*/
public async getInvitationByEmail(email: string): Promise<UserInvitation | null> {
return this.CUserInvitation.getInstance({
data: { email: email.toLowerCase().trim() },
});
}
/**
* Get pending invitations for an email (for registration flow)
*/
public async getPendingInvitationsForEmail(email: string): Promise<UserInvitation | null> {
const invitation = await this.getInvitationByEmail(email);
if (invitation && invitation.data.status === 'pending' && !invitation.isExpired()) {
return invitation;
}
return null;
}
/**
* Clean up expired invitations
*/
public async cleanupExpiredInvitations(): Promise<number> {
const allInvitations = await this.CUserInvitation.getInstances({
data: { status: 'pending' },
});
let cleanedCount = 0;
for (const invitation of allInvitations) {
if (invitation.isExpired()) {
invitation.data.status = 'expired';
await invitation.save();
cleanedCount++;
}
}
return cleanedCount;
}
/**
* Send invitation email
*/
private async sendInvitationEmail(
invitation: UserInvitation,
organizationId: string
): Promise<void> {
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
id: organizationId,
});
const orgName = org?.data.name || 'an organization';
await this.receptionRef.receptionMailer.sendInvitationEmail(
invitation.data.email,
orgName,
invitation.data.token,
this.receptionRef.options.baseUrl
);
}
/**
* Verify user is admin/owner of organization
*/
private async verifyUserIsAdminOfOrg(userId: string, organizationId: string): Promise<void> {
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: { userId, organizationId },
});
if (!role) {
throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.');
}
const hasAdminRole = role.data.roles.some(r =>
['owner', 'admin'].includes(r)
);
if (!hasAdminRole) {
throw new plugins.typedrequest.TypedResponseError(
'You do not have permission to perform this action.'
);
}
}
/**
* Verify user is member of organization
*/
private async verifyUserIsMemberOfOrg(userId: string, organizationId: string): Promise<void> {
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: { userId, organizationId },
});
if (!role) {
throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.');
}
}
/**
* Validate email format
*/
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}
+6 -6
View File
@@ -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({
+8 -14
View File
@@ -3,6 +3,7 @@ import type { IdpClient } from "./classes.idpclient.js";
/**
* this class bundles all the typed requests that are used by the idp
* All requests use TypedSocket (WebSocket) transport
*/
export class IdpRequests {
idpClientArg: IdpClient;
@@ -11,51 +12,44 @@ 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'
);
}
+2
View File
@@ -1,3 +1,4 @@
export * from './loint-reception.activity.js';
export * from './loint-reception.app.js';
export * from './loint-reception.appconnection.js';
export * from './loint-reception.billingplan.js';
@@ -8,3 +9,4 @@ export * from './loint-reception.organization.js';
export * from './loint-reception.paddlecheckoutdata.js';
export * from './loint-reception.role.js';
export * from './loint-reception.user.js';
export * from './loint-reception.userinvitation.js';
@@ -0,0 +1,28 @@
export type TActivityAction =
| 'login'
| 'logout'
| 'session_created'
| 'session_revoked'
| 'org_created'
| 'org_joined'
| 'org_left'
| 'role_changed'
| 'profile_updated'
| 'app_connected'
| 'app_disconnected';
export interface IActivityLog {
id: string;
data: {
userId: string;
action: TActivityAction;
timestamp: number;
metadata: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
description: string;
};
};
}
@@ -10,5 +10,22 @@ export interface ILoginSession {
* in different contexts on the same device
*/
deviceId: string;
/**
* Device metadata for session display
*/
deviceInfo?: {
deviceName: string;
browser: string;
os: string;
ip: string;
};
/**
* When this session was created
*/
createdAt?: number;
/**
* Last time this session was active (e.g., refreshed)
*/
lastActive?: number;
};
}
+7 -2
View File
@@ -1,13 +1,18 @@
import * as plugins from '../loint-reception.plugins.js';
/** Standard role types available in all organizations */
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
/**
* a role describes a
* A role describes a user's permissions within an organization.
* Users can have multiple roles (e.g., ['owner', 'billing-admin']).
*/
export interface IRole {
id: string;
data: {
userId: string;
organizationId: string;
role: 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
/** Array of roles - supports standard roles and custom role names */
roles: string[];
};
}
@@ -0,0 +1,58 @@
import * as plugins from '../loint-reception.plugins.js';
/**
* A UserInvitation represents an invitation to join an organization.
* Key characteristics:
* - Unique by email (multiple orgs can share the same invitation)
* - Converts to real User on registration or folds into existing user
* - Auto-expires after 90 days
*/
export interface IUserInvitation {
id: string;
data: {
/** The invited email address - unique key for sharing across orgs */
email: string;
/** Secure token for invitation link validation */
token: string;
/** Current status of the invitation */
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
/** When the invitation was first created */
createdAt: number;
/** When the invitation expires (createdAt + 90 days) */
expiresAt: number;
/**
* Organizations that have invited this email.
* Multiple orgs can link to the same invitation.
*/
organizationRefs: IOrganizationInvitationRef[];
/** When the invitation was accepted (user registered/folded) */
acceptedAt?: number;
/** The User ID after conversion (when accepted) */
convertedToUserId?: string;
};
}
/**
* Represents one organization's invitation to the user.
* Stored as part of IUserInvitation.organizationRefs array.
*/
export interface IOrganizationInvitationRef {
/** The organization that sent this invitation */
organizationId: string;
/** The user who sent the invitation */
invitedByUserId: string;
/** When this org invited the user */
invitedAt: number;
/** Roles to assign when the invitation is accepted */
roles: string[];
}
+1
View File
@@ -9,3 +9,4 @@ export * from './loint-reception.organization.js';
export * from './loint-reception.plan.js';
export * from './loint-reception.registration.js';
export * from './loint-reception.user.js';
export * from './loint-reception.userinvitation.js';
@@ -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;
};
}
@@ -84,3 +84,59 @@ export interface IReq_WhoIs {
user: data.IUser;
};
}
export interface IReq_GetUserSessions
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetUserSessions
> {
method: 'getUserSessions';
request: {
jwt: string;
};
response: {
sessions: Array<{
id: string;
deviceId: string;
deviceName: string;
browser: string;
os: string;
ip: string;
lastActive: number;
createdAt: number;
isCurrent: boolean;
}>;
};
}
export interface IReq_RevokeSession
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RevokeSession
> {
method: 'revokeSession';
request: {
jwt: string;
sessionId: string;
};
response: {
success: boolean;
};
}
export interface IReq_GetUserActivity
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetUserActivity
> {
method: 'getUserActivity';
request: {
jwt: string;
limit?: number;
offset?: number;
};
response: {
activities: data.IActivityLog[];
total: number;
};
}
@@ -0,0 +1,247 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
/**
* Create an invitation to join an organization
*/
export interface IReq_CreateInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CreateInvitation
> {
method: 'createInvitation';
request: {
jwt: string;
organizationId: string;
email: string;
roles: string[];
};
response: {
success: boolean;
invitation?: data.IUserInvitation;
message?: string;
/** True if a new invitation was created, false if email was added to existing */
isNew: boolean;
};
}
/**
* Get pending invitations for an organization
*/
export interface IReq_GetOrgInvitations
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetOrgInvitations
> {
method: 'getOrgInvitations';
request: {
jwt: string;
organizationId: string;
};
response: {
invitations: data.IUserInvitation[];
};
}
/**
* Get members of an organization (users with roles)
*/
export interface IReq_GetOrgMembers
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetOrgMembers
> {
method: 'getOrgMembers';
request: {
jwt: string;
organizationId: string;
};
response: {
members: Array<{
user: data.IUser;
role: data.IRole;
}>;
};
}
/**
* Cancel a pending invitation
*/
export interface IReq_CancelInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CancelInvitation
> {
method: 'cancelInvitation';
request: {
jwt: string;
organizationId: string;
invitationId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Resend invitation email
*/
export interface IReq_ResendInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_ResendInvitation
> {
method: 'resendInvitation';
request: {
jwt: string;
organizationId: string;
invitationId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Remove a member from an organization
*/
export interface IReq_RemoveMember
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RemoveMember
> {
method: 'removeMember';
request: {
jwt: string;
organizationId: string;
userId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Update a member's roles
*/
export interface IReq_UpdateMemberRoles
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_UpdateMemberRoles
> {
method: 'updateMemberRoles';
request: {
jwt: string;
organizationId: string;
userId: string;
roles: string[];
};
response: {
success: boolean;
role?: data.IRole;
message?: string;
};
}
/**
* Transfer organization ownership to another member
*/
export interface IReq_TransferOwnership
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_TransferOwnership
> {
method: 'transferOwnership';
request: {
jwt: string;
organizationId: string;
newOwnerId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Accept an invitation (called during registration or email verification)
*/
export interface IReq_AcceptInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_AcceptInvitation
> {
method: 'acceptInvitation';
request: {
token: string;
userId: string;
};
response: {
success: boolean;
organizations?: data.IOrganization[];
roles?: data.IRole[];
message?: string;
};
}
/**
* Get invitation by token (for invitation landing page)
*/
export interface IReq_GetInvitationByToken
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetInvitationByToken
> {
method: 'getInvitationByToken';
request: {
token: string;
};
response: {
invitation?: data.IUserInvitation;
organizations?: Array<{
id: string;
name: string;
}>;
isExpired: boolean;
requiresRegistration: boolean;
};
}
/**
* Bulk create invitations from a list (typically from CSV import)
*/
export interface IReq_BulkCreateInvitations
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_BulkCreateInvitations
> {
method: 'bulkCreateInvitations';
request: {
jwt: string;
organizationId: string;
invitations: Array<{
email: string;
roles?: string[];
}>;
defaultRoles: string[];
};
response: {
success: boolean;
results: Array<{
email: string;
success: boolean;
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
message?: string;
}>;
summary: {
total: number;
invited: number;
alreadyMembers: number;
invalid: number;
errors: number;
};
};
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@idp.global/idp.global',
version: '1.7.0',
version: '1.10.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;
});
});
}
}
+41
View File
@@ -12,6 +12,8 @@ import {
} from '@design.estate/dees-element';
import { LeleAccountNavigation } from './navigation.js';
import { OrgSelectModal, type IOrgSelectResult } from './org-select-modal.js';
import { CreateOrgModal } from './create-org-modal.js';
import { accountDesignTokens } from './sharedstyles.js';
import * as views from './views/index.js';
@@ -100,6 +102,25 @@ export class IdpAccountContent extends DeesElement {
this.subrouter = this.domtools.router.createSubRouter('/account');
const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer');
// Setup event listeners for modals
this.addEventListener('open-org-select-modal', (async (e: CustomEvent) => {
const result = await OrgSelectModal.show({
targetPath: e.detail.targetPath,
title: e.detail.title,
description: e.detail.description,
});
if (result) {
this.subrouter.pushUrl(result.path);
}
}) as EventListener);
this.addEventListener('open-create-org-modal', async () => {
const org = await CreateOrgModal.show();
if (org) {
this.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
}
});
const cleanupViews = async () => {
for (const child of Array.from(viewcontainer.children)) {
viewcontainer.removeChild(child);
@@ -139,6 +160,16 @@ export class IdpAccountContent extends DeesElement {
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the org overview page');
await cleanupViews();
viewcontainer.append(new views.OrgView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName/apps', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
@@ -149,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);
+329
View File
@@ -0,0 +1,329 @@
import * as plugins from '../../plugins.js';
import {
customElement,
DeesElement,
html,
css,
cssManager,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from './sharedstyles.js';
import * as accountStateModule from '../../states/accountstate.js';
import { IdpState } from '../../states/idp.state.js';
// Internal form element for reactive state management
@customElement('idp-create-org-form')
class CreateOrgForm extends DeesElement {
@state()
accessor orgName: string = '';
@state()
accessor orgSlug: string = '';
@state()
accessor validating: boolean = false;
@state()
accessor validationResult: { available: boolean; message: string } | null = null;
@state()
accessor creating: boolean = false;
@state()
accessor error: string = '';
private validationDebounceTimer: any = null;
public resolveWith: ((org: plugins.idpInterfaces.data.IOrganization | null) => void) | null = null;
public modal: plugins.deesCatalog.DeesModal | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.slug-preview {
margin-top: 12px;
padding: 12px 16px;
background: var(--dees-color-background);
border: 1px solid var(--dees-color-line);
border-radius: 8px;
}
.slug-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--dees-color-muted);
margin-bottom: 4px;
}
.slug-value {
font-family: 'Geist Mono', monospace;
font-size: 14px;
color: var(--dees-color-text);
}
.validation-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.validation-status.validating {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.validation-status.available {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.validation-status.unavailable {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.validation-status dees-icon {
font-size: 16px;
}
.error-message {
margin-top: 16px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
font-size: 13px;
}
.description {
color: var(--dees-color-muted);
font-size: 14px;
margin-bottom: 20px;
}
`,
];
public render(): TemplateResult {
return html`
<div class="description">Create a new organization to manage apps, users, and billing.</div>
<dees-input-text
.label=${'Organization Name'}
.placeholder=${'e.g., Acme Inc.'}
.value=${this.orgName}
?disabled=${this.creating}
></dees-input-text>
${this.orgSlug ? html`
<div class="slug-preview">
<div class="slug-label">Organization URL Slug</div>
<div class="slug-value">${this.orgSlug}</div>
</div>
` : ''}
${this.renderValidationStatus()}
${this.error ? html`
<div class="error-message">${this.error}</div>
` : ''}
`;
}
private renderValidationStatus(): TemplateResult | null {
if (!this.orgSlug) {
return null;
}
if (this.validating) {
return html`
<div class="validation-status validating">
<dees-icon .icon=${'lucide:loader-2'}></dees-icon>
Checking availability...
</div>
`;
}
if (this.validationResult) {
if (this.validationResult.available) {
return html`
<div class="validation-status available">
<dees-icon .icon=${'lucide:check-circle'}></dees-icon>
${this.validationResult.message}
</div>
`;
} else {
return html`
<div class="validation-status unavailable">
<dees-icon .icon=${'lucide:x-circle'}></dees-icon>
${this.validationResult.message}
</div>
`;
}
}
return null;
}
public async firstUpdated() {
const inputElement = this.shadowRoot.querySelector('dees-input-text') as any;
if (inputElement) {
inputElement.changeSubject.subscribe((element: any) => {
this.handleNameInput(element.value);
});
}
}
private handleNameInput(value: string) {
this.orgName = value;
this.orgSlug = this.generateSlug(this.orgName);
this.error = '';
// Debounce validation
if (this.validationDebounceTimer) {
clearTimeout(this.validationDebounceTimer);
}
if (this.orgSlug) {
this.validating = true;
this.validationResult = null;
this.validationDebounceTimer = setTimeout(() => {
this.validateSlug();
}, 500);
} else {
this.validating = false;
this.validationResult = null;
}
}
private generateSlug(name: string): string {
return name
.replace(/[^a-zA-Z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
}
private async validateSlug() {
if (!this.orgSlug) {
this.validating = false;
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const result = await idpState.idpClient.createOrganization(
this.orgName,
this.orgSlug,
'checkAvailability'
);
this.validationResult = {
available: result.nameAvailable,
message: result.nameAvailable
? 'This name is available!'
: 'This name is already taken. Please choose another.',
};
} catch (error) {
console.error('Validation error:', error);
this.validationResult = {
available: false,
message: 'Unable to validate. Please try again.',
};
} finally {
this.validating = false;
}
}
public canCreate(): boolean {
return this.orgName.length > 0 &&
this.validationResult?.available === true &&
!this.validating &&
!this.creating;
}
public async handleCreate(): Promise<void> {
if (!this.canCreate()) {
return;
}
this.creating = true;
this.error = '';
try {
const idpState = await IdpState.getSingletonInstance();
const result = await idpState.idpClient.createOrganization(
this.orgName,
this.orgSlug,
'manifest'
);
// Update state with new organization and role
const currentState = accountStateModule.accountState.getState();
currentState.organizations.push(result.resultingOrganization);
if (result.role) {
currentState.roles.push(result.role);
}
accountStateModule.accountState.dispatchAction(
accountStateModule.setSelectedOrg,
result.resultingOrganization
);
this.modal?.destroy();
this.resolveWith?.(result.resultingOrganization);
} catch (error) {
console.error('Error creating organization:', error);
this.error = error instanceof Error ? error.message : 'Failed to create organization. Please try again.';
this.creating = false;
}
}
public handleCancel(): void {
if (this.validationDebounceTimer) {
clearTimeout(this.validationDebounceTimer);
}
this.modal?.destroy();
this.resolveWith?.(null);
}
}
// Export the modal utility class
export class CreateOrgModal {
public static async show(): Promise<plugins.idpInterfaces.data.IOrganization | null> {
return new Promise<plugins.idpInterfaces.data.IOrganization | null>((resolve) => {
const formElement = new CreateOrgForm();
formElement.resolveWith = resolve;
plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Create Organization',
content: html`${formElement}`,
menuOptions: [
{
name: 'Cancel',
action: async () => {
formElement.handleCancel();
},
},
{
name: 'Create Organization',
action: async () => {
await formElement.handleCreate();
},
},
],
width: 480,
}).then((modal) => {
formElement.modal = modal;
});
});
}
}
+2
View File
@@ -1,2 +1,4 @@
export * from './content.js';
export * from './navigation.js';
export * from './org-select-modal.js';
export * from './create-org-modal.js';
+123 -43
View File
@@ -14,6 +14,8 @@ import * as plugins from '../../plugins.js';
import * as states from '../../states/accountstate.js';
import { IdpState } from '../../states/idp.state.js';
import { accountDesignTokens } from './sharedstyles.js';
import { CreateOrgModal } from './create-org-modal.js';
import { OrgSelectModal } from './org-select-modal.js';
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
@@ -28,10 +30,39 @@ export class LeleAccountNavigation extends DeesElement {
@state()
accessor isGlobalAdmin: boolean = false;
@state()
accessor currentPath: string = window.location.pathname;
constructor() {
super();
}
private async navigateTo(path: string) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(path);
// Update state after navigation to trigger re-render
this.currentPath = window.location.pathname;
}
private async navigateToOrgPage(page: string) {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const path = page ? `/org/${currentState.selectedOrg.data.slug}/${page}` : `/org/${currentState.selectedOrg.data.slug}`;
await this.navigateTo(path);
} else {
const targetPath = page ? `/org/:orgName/${page}` : '/org/:orgName';
const description = page ? `Choose an organization to view its ${page}.` : 'Choose an organization to view its overview.';
const result = await OrgSelectModal.show({
targetPath,
title: 'Select Organization',
description,
});
if (result) {
await this.navigateTo(result.path.replace('/account', ''));
}
}
}
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
@@ -136,6 +167,15 @@ export class LeleAccountNavigation extends DeesElement {
opacity: 1;
}
.navigationOption.active {
background: var(--muted);
color: var(--foreground);
}
.navigationOption.active dees-icon {
opacity: 1;
}
.divider {
height: 1px;
background: var(--border);
@@ -165,11 +205,8 @@ export class LeleAccountNavigation extends DeesElement {
<div class="navContent">
<div class="navigationGroupLabel">Account</div>
<div
class="navigationOption"
@click=${async () => {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl('');
}}
class="navigationOption ${this.isActive('') ? 'active' : ''}"
@click=${() => this.navigateTo('')}
>
<dees-icon .icon=${'lucide:home'}></dees-icon>
Overview
@@ -183,14 +220,6 @@ export class LeleAccountNavigation extends DeesElement {
<dees-icon .icon=${'lucide:shield'}></dees-icon>
Manage Roles
</div>
<div
class="navigationOption"
@click=${async () => {
}}
>
<dees-icon .icon=${'lucide:plus'}></dees-icon>
Create Organization
</div>
<div
class="navigationOption"
@click=${async () => {
@@ -207,31 +236,51 @@ export class LeleAccountNavigation extends DeesElement {
<div class="navigationGroupLabel">Organization</div>
<dees-input-dropdown
.label=${'Select organization'}
@selectedOption=${(eventArg: CustomEvent) => {
@selectedOption=${async (eventArg: CustomEvent) => {
// Handle "Create new..." option
if (eventArg.detail.key === '__create_new__') {
const org = await CreateOrgModal.show();
if (org) {
await this.navigateTo(`/org/${org.data.slug}/billing`);
}
return;
}
const currentState = states.accountState.getState();
states.accountState.dispatchAction(
states.setSelectedOrg,
currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload)
);
const newOrg = currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload);
states.accountState.dispatchAction(states.setSelectedOrg, newOrg);
// Auto-navigate to new org's current page type (reactivity)
const currentPath = window.location.pathname;
if (currentPath.includes('/org/') && newOrg) {
// Extract the page type (apps, billing, etc.) and navigate to new org
const pathParts = currentPath.split('/');
const pageType = pathParts[5]; // /account/org/:orgName/:pageType
if (pageType) {
await this.navigateTo(`/org/${newOrg.data.slug}/${pageType}`);
} else {
await this.navigateTo(`/org/${newOrg.data.slug}`);
}
}
}}
></dees-input-dropdown>
<div
class="navigationOption"
@click=${async () => {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/apps`);
}
}}
class="navigationOption ${this.isActive('org-overview') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('')}
>
<dees-icon .icon=${'lucide:home'}></dees-icon>
Overview
</div>
<div
class="navigationOption ${this.isActive('apps') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('apps')}
>
<dees-icon .icon=${'lucide:box'}></dees-icon>
Apps
</div>
<div
class="navigationOption"
@click=${async () => {}}
class="navigationOption ${this.isActive('users') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('users')}
>
<dees-icon .icon=${'lucide:users'}></dees-icon>
Users
@@ -244,14 +293,8 @@ export class LeleAccountNavigation extends DeesElement {
Activity
</div>
<div
class="navigationOption"
@click=${async () => {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/billing`);
}
}}
class="navigationOption ${this.isActive('billing') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('billing')}
>
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
Billing
@@ -272,11 +315,8 @@ export class LeleAccountNavigation extends DeesElement {
<div class="divider"></div>
<div class="navigationGroupLabel">Platform</div>
<div
class="navigationOption"
@click=${async () => {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl('/admin');
}}
class="navigationOption ${this.isActive('admin') ? 'active' : ''}"
@click=${() => this.navigateTo('/admin')}
>
<dees-icon .icon=${'lucide:shield'}></dees-icon>
Global Admin
@@ -284,7 +324,37 @@ export class LeleAccountNavigation extends DeesElement {
`;
}
public firstUpdated() {
private isActive(page: string): boolean {
const path = this.currentPath;
if (page === '') {
// Account overview - exact match
return path === '/account' || path === '/account/';
}
if (page === 'org-overview') {
// Org overview - /account/org/:slug without trailing page type
return /^\/account\/org\/[^\/]+\/?$/.test(path);
}
// For other pages, check if the path contains the page segment
return path.includes(`/${page}`);
}
public async firstUpdated() {
// Listen for popstate (browser back/forward)
window.addEventListener('popstate', () => {
this.currentPath = window.location.pathname;
});
// Watch for URL changes from external navigation (e.g., modals)
let lastPath = this.currentPath;
const checkPath = () => {
if (window.location.pathname !== lastPath) {
lastPath = window.location.pathname;
this.currentPath = lastPath;
}
requestAnimationFrame(checkPath);
};
requestAnimationFrame(checkPath);
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
if (!orgArg) {
@@ -296,11 +366,21 @@ export class LeleAccountNavigation extends DeesElement {
payload: orgArg.data.slug,
};
};
// "Create new..." option to add at the end
const createNewOption = {
option: '+ Create new...',
key: '__create_new__',
payload: '__create_new__',
};
states.accountState
.select((stateArg) => stateArg.organizations)
.pipe(
plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => {
return orgArrayArg.map(orgToMenuEntry);
const orgEntries = orgArrayArg.map(orgToMenuEntry);
// Add "Create new..." at the end
return [...orgEntries, createNewOption];
})
)
.subscribe((menuEntries) => {
+209
View File
@@ -0,0 +1,209 @@
import * as plugins from '../../plugins.js';
import {
html,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from './sharedstyles.js';
import * as accountStateModule from '../../states/accountstate.js';
export interface IOrgSelectResult {
org: plugins.idpInterfaces.data.IOrganization;
path: string;
}
const modalStyles = css`
.org-list {
display: flex;
flex-direction: column;
}
.org-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid var(--dees-color-line);
cursor: pointer;
transition: background 0.15s ease;
}
.org-item:last-child {
border-bottom: none;
}
.org-item:hover {
background: var(--dees-color-softBackground);
}
.org-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--dees-color-softBackground);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.org-item:hover .org-icon {
background: var(--dees-color-line);
}
.org-icon dees-icon {
opacity: 0.7;
}
.org-info {
flex: 1;
min-width: 0;
}
.org-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
color: var(--dees-color-text);
}
.org-slug {
font-size: 12px;
color: var(--dees-color-muted);
}
.org-arrow {
opacity: 0.5;
}
.empty-state {
text-align: center;
padding: 40px 24px;
color: var(--dees-color-muted);
}
.empty-state dees-icon {
font-size: 40px;
opacity: 0.5;
margin-bottom: 12px;
}
.empty-state p {
margin: 0 0 16px 0;
font-size: 14px;
}
.description {
color: var(--dees-color-muted);
font-size: 14px;
margin-bottom: 16px;
padding: 0 20px;
}
`;
export class OrgSelectModal {
public static async show(options: {
targetPath: string;
title?: string;
description?: string;
}): Promise<IOrgSelectResult | null> {
const title = options.title || 'Select Organization';
const description = options.description || 'Choose an organization to continue.';
// Load organizations from state
const state = accountStateModule.accountState.getState();
const organizations = state.organizations;
return new Promise<IOrgSelectResult | null>((resolve) => {
let modal: plugins.deesCatalog.DeesModal | null = null;
let resolved = false;
const handleSelectOrg = (org: plugins.idpInterfaces.data.IOrganization) => {
if (resolved) return;
resolved = true;
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
const path = options.targetPath.replace(':orgName', org.data.slug);
modal?.destroy();
resolve({ org, path });
};
const handleCreateOrg = async () => {
if (resolved) return;
resolved = true;
modal?.destroy();
// Import dynamically to avoid circular dependency
const { CreateOrgModal } = await import('./create-org-modal.js');
const createdOrg = await CreateOrgModal.show();
if (createdOrg) {
const path = options.targetPath.replace(':orgName', createdOrg.data.slug);
resolve({ org: createdOrg, path });
} else {
resolve(null);
}
};
const renderOrgList = (): TemplateResult => {
return html`
<style>${modalStyles}</style>
<div class="description">${description}</div>
<div class="org-list">
${organizations.map((org) => html`
<div class="org-item" @click=${() => handleSelectOrg(org)}>
<div class="org-icon">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
</div>
<div class="org-info">
<div class="org-name">${org.data.name}</div>
<div class="org-slug">${org.data.slug}</div>
</div>
<dees-icon class="org-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
`)}
</div>
`;
};
const renderEmptyState = (): TemplateResult => {
return html`
<style>${modalStyles}</style>
<div class="empty-state">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
<p>You don't have any organizations yet.</p>
<dees-button @clicked=${handleCreateOrg}>
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
Create Organization
</dees-button>
</div>
`;
};
const content = organizations.length === 0 ? renderEmptyState() : renderOrgList();
plugins.deesCatalog.DeesModal.createAndShow({
heading: title,
content,
menuOptions: [
{
name: 'Cancel',
action: async (modalRef) => {
if (!resolved) {
resolved = true;
resolve(null);
}
modalRef.destroy();
},
},
],
width: 420,
}).then((m) => {
modal = m;
});
});
}
}
+5 -10
View File
@@ -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'
);
+3 -6
View File
@@ -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'
);
File diff suppressed because it is too large Load Diff
+2
View File
@@ -2,5 +2,7 @@ export * from './adminview.js';
export * from './appsview.js';
export * from './baseview.js';
export * from './orgsetup.js';
export * from './orgview.js';
export * from './paddlesetup.js';
export * from './subscriptions.js';
export * from './usersview.js';
+513
View File
@@ -0,0 +1,513 @@
import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from '../sharedstyles.js';
import * as accountStateModule from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
declare global {
interface HTMLElementTagNameMap {
'lele-accountview-orgview': OrgView;
}
}
interface IOrgStats {
memberCount: number;
appCount: number;
}
@customElement('lele-accountview-orgview')
export class OrgView extends DeesElement {
@state()
accessor loading: boolean = true;
@state()
accessor organization: plugins.idpInterfaces.data.IOrganization | null = null;
@state()
accessor userRole: plugins.idpInterfaces.data.IRole | null = null;
@state()
accessor stats: IOrgStats = { memberCount: 0, appCount: 0 };
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: block;
min-height: 100%;
background: var(--background);
color: var(--foreground);
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 32px 24px;
}
.header {
margin-bottom: 32px;
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 12px;
}
h1 dees-icon {
opacity: 0.7;
}
.subtitle {
color: #71717a;
margin-top: 8px;
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
.stat-card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
overflow: hidden;
}
.card.full-width {
grid-column: 1 / -1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #27272a;
}
.card-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card-title dees-icon {
opacity: 0.7;
}
.card-body {
padding: 16px 20px;
}
.card-body.no-padding {
padding: 0;
}
/* Info rows */
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #27272a;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: 13px;
color: #71717a;
}
.info-value {
font-size: 14px;
font-weight: 500;
}
.info-value.slug {
font-family: 'Geist Mono', monospace;
background: #27272a;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
}
/* Role badge */
.role-badge {
padding: 4px 12px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.role-badge.admin {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.role-badge.owner {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
/* Quick actions */
.action-list {
display: flex;
flex-direction: column;
}
.action-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid #27272a;
cursor: pointer;
transition: background 0.15s ease;
}
.action-item:last-child {
border-bottom: none;
}
.action-item:hover {
background: #27272a;
}
.action-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-item:hover .action-icon {
background: #3f3f46;
}
.action-icon dees-icon {
opacity: 0.7;
}
.action-info {
flex: 1;
}
.action-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
}
.action-description {
font-size: 12px;
color: #71717a;
}
.action-arrow {
color: #71717a;
}
/* Billing status */
.billing-status {
display: flex;
align-items: center;
gap: 8px;
}
.billing-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #71717a;
}
.billing-indicator.active {
background: #22c55e;
}
.billing-indicator.none {
background: #f59e0b;
}
/* Loading state */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: #71717a;
}
`,
];
public render(): TemplateResult {
if (this.loading) {
return html`
<div class="container">
<div class="loading">Loading organization...</div>
</div>
`;
}
if (!this.organization) {
return html`
<div class="container">
<div class="loading">Organization not found</div>
</div>
`;
}
const roleName = this.userRole?.data.roles?.[0] || 'member';
const roleClass = roleName === 'owner' ? 'owner' : roleName === 'admin' ? 'admin' : '';
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
return html`
<div class="container">
<div class="header">
<h1>
<dees-icon .icon=${'lucide:building2'}></dees-icon>
${this.organization.data.name}
</h1>
<p class="subtitle">Organization dashboard and settings</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${this.stats.memberCount}</div>
<div class="stat-label">Members</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.stats.appCount}</div>
<div class="stat-label">Connected Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">
<span class="role-badge ${roleClass}">${roleDisplay}</span>
</div>
<div class="stat-label">Your Role</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Organization Info -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:info'}></dees-icon>
Organization Info
</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">${this.organization.data.name}</span>
</div>
<div class="info-row">
<span class="info-label">Slug</span>
<span class="info-value slug">${this.organization.data.slug}</span>
</div>
<div class="info-row">
<span class="info-label">Billing</span>
<span class="info-value">
<div class="billing-status">
<span class="billing-indicator ${this.organization.data.billingPlanId ? 'active' : 'none'}"></span>
${this.organization.data.billingPlanId ? 'Active' : 'Not configured'}
</div>
</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:zap'}></dees-icon>
Quick Actions
</span>
</div>
<div class="card-body no-padding">
<div class="action-list">
<div class="action-item" @click=${this.navigateToApps}>
<div class="action-icon">
<dees-icon .icon=${'lucide:box'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">Manage Apps</div>
<div class="action-description">Connect and configure applications</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
<div class="action-item" @click=${this.navigateToBilling}>
<div class="action-icon">
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">View Billing</div>
<div class="action-description">Manage subscription and invoices</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
<div class="action-item" @click=${this.handleInviteUser}>
<div class="action-icon">
<dees-icon .icon=${'lucide:user-plus'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">Invite Member</div>
<div class="action-description">Add team members to your organization</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
</div>
</div>
</div>
</div>
</div>
`;
}
public async firstUpdated() {
await this.loadOrgData();
}
private async loadOrgData() {
this.loading = true;
try {
// Get the organization slug from the URL
const pathParts = window.location.pathname.split('/');
const orgSlug = pathParts[3];
const currentState = accountStateModule.accountState.getState();
const selectedOrg = currentState.organizations.find(org => org.data.slug === orgSlug);
if (!selectedOrg) {
console.error('Organization not found');
this.loading = false;
return;
}
this.organization = selectedOrg;
// Find user's role in this org
this.userRole = currentState.roles.find(r => r.data.organizationId === selectedOrg.id) || null;
// Calculate stats
const memberCount = selectedOrg.data.roleIds?.length || 1;
// Get app connections count
let appCount = 0;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const connectionsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'getAppConnections'
);
const connectionsResponse = await connectionsRequest.fire({
jwt,
organizationId: selectedOrg.id,
});
appCount = connectionsResponse.connections?.filter(c => c.data.status === 'active').length || 0;
} catch (error) {
console.error('Error loading app connections:', error);
}
this.stats = { memberCount, appCount };
} catch (error) {
console.error('Error loading org data:', error);
} finally {
this.loading = false;
}
}
private async navigateToApps() {
if (!this.organization) return;
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${this.organization.data.slug}/apps`);
}
private async navigateToBilling() {
if (!this.organization) return;
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${this.organization.data.slug}/billing`);
}
private handleInviteUser() {
// TODO: Implement invite user modal
alert('Invite member functionality coming soon');
}
}
+32 -9
View File
@@ -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 },
});
}
}
+941
View File
@@ -0,0 +1,941 @@
import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import 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',
});
}
}
+2 -4
View File
@@ -174,13 +174,11 @@ export class IdpLoginPrompt extends DeesElement {
const idpState = await IdpState.getSingletonInstance();
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
const loginRequestWithUsernameAndPassword =
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'/typedrequest',
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
'loginWithEmailOrUsernameAndPassword'
);
const loginRequestWithEmail =
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
'/typedrequest',
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
'loginWithEmail'
);
+4 -4
View File
@@ -170,9 +170,9 @@ export class IdpRegistrationPrompt extends DeesElement {
private register = async (valueArg: { emailAddress: string }) => {
const registrationForm: DeesForm = this.shadowRoot.querySelector('#registrationForm');
registrationForm.setStatus('pending', 'registering...');
const idpState = await IdpState.getSingletonInstance();
const firstSignupRequest =
new domtools.TypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
'/typedrequest',
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_FirstRegistration>(
'firstRegistrationRequest'
);
const response = await firstSignupRequest
@@ -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
View File
@@ -12,7 +12,7 @@ const run = async () => {
metaObject: {
title: 'idp.global',
description:
'the code that runs idp.global',
'Your permanent identity on the web',
canonicalDomain: 'https://idp.global',
ldCompany: {
name: 'Task Venture Capital GmbH',
@@ -29,9 +29,7 @@ const run = async () => {
description: 'work',
name: 'Task Venture Capital GmbH',
type: 'company',
facebookUrl: 'https://www.facebook.com/undefined variable',
twitterUrl: 'https://twitter.com/undefined variable',
website: 'https://Task Venture Capital GmbH',
website: 'https://task.vc',
phone: '+49 421 16767 548',
},
closedDate: null,
@@ -44,7 +42,7 @@ const run = async () => {
},
});
// const serviceWorker = await serviceworker.getServiceworkerClient();
await serviceworker.getServiceworkerClient();
const mainTemplate = html`
<style>
+3
View File
@@ -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;
});
+1 -1
View File
@@ -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'>();