This commit is contained in:
2025-12-04 17:45:40 +00:00
parent a83858beb0
commit d32103618f
21 changed files with 2174 additions and 367 deletions
+8 -8
View File
@@ -16,12 +16,12 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"dependencies": {
"@api.global/typedrequest": "^3.1.10",
"@api.global/typedrequest": "^3.1.11",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^3.0.80",
"@api.global/typedsocket": "^3.0.1",
"@api.global/typedserver": "^7.7.0",
"@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",
@@ -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.2",
"@git.zone/tswatch": "^2.2.3",
"@push.rocks/projectinfo": "^5.0.1",
"@types/node": "^24.10.1"
},
+411 -306
View File
File diff suppressed because it is too large Load Diff
@@ -2,27 +2,127 @@
**ID:** ORG-002
**Priority:** Critical
**Status:** Planned
**Status:** In Development
## 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
- [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
- [ ] Owner can transfer ownership to another member
- [ ] 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
@@ -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');
}
}
+2
View File
@@ -16,6 +16,7 @@ import { BillingPlanManager } from './classes.billingplanmanager.js';
import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
import { UserInvitationManager } from './classes.userinvitationmanager.js';
export interface IReceptionOptions {
/**
@@ -47,6 +48,7 @@ export class Reception {
public appManager = new AppManager(this);
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
public userInvitationManager = new UserInvitationManager(this);
housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) {
+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,556 @@
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 };
}
)
);
}
/**
* 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.');
}
}
}
+10
View File
@@ -180,6 +180,16 @@ export class IdpAccountContent extends DeesElement {
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName/users', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the users page');
await cleanupViews();
viewcontainer.append(new views.UsersView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/admin', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
+2 -2
View File
@@ -279,8 +279,8 @@ export class LeleAccountNavigation extends DeesElement {
Apps
</div>
<div
class="navigationOption"
@click=${async () => {}}
class="navigationOption ${this.isActive('users') ? 'active' : ''}"
@click=${() => this.navigateToOrgPage('users')}
>
<dees-icon .icon=${'lucide:users'}></dees-icon>
Users
+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'
);
+4 -7
View File
@@ -566,7 +566,7 @@ export class BaseView extends DeesElement {
<div class="org-list">
${this.organizations.map((org) => {
const roleObj = this.roles.find(r => r.data.organizationId === org.id);
const roleName = roleObj?.data.role || 'member';
const roleName = roleObj?.data.roles?.[0] || 'member';
const roleClass = roleName === 'owner' ? 'owner' :
roleName === 'admin' ? 'admin' : '';
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
@@ -754,8 +754,7 @@ export class BaseView extends DeesElement {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'/typedrequest',
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'getUserSessions'
);
@@ -772,8 +771,7 @@ export class BaseView extends DeesElement {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'/typedrequest',
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'getUserActivity'
);
@@ -794,8 +792,7 @@ export class BaseView extends DeesElement {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
'/typedrequest',
const typedRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
'revokeSession'
);
+1
View File
@@ -5,3 +5,4 @@ export * from './orgsetup.js';
export * from './orgview.js';
export * from './paddlesetup.js';
export * from './subscriptions.js';
export * from './usersview.js';
+2 -3
View File
@@ -328,7 +328,7 @@ export class OrgView extends DeesElement {
`;
}
const roleName = this.userRole?.data.role || 'member';
const roleName = this.userRole?.data.roles?.[0] || 'member';
const roleClass = roleName === 'owner' ? 'owner' : roleName === 'admin' ? 'admin' : '';
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
@@ -472,8 +472,7 @@ export class OrgView extends DeesElement {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const connectionsRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'/typedrequest',
const connectionsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'getAppConnections'
);
+836
View File
@@ -0,0 +1,836 @@
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';
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 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'];
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>
${this.isAdmin && member.userId !== this.currentUserId ? html`
<div class="member-actions">
<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>
`;
}
public async firstUpdated() {
// Subscribe to email input changes
await this.updateComplete;
const emailInput = this.shadowRoot?.querySelector('dees-input-text') as any;
if (emailInput) {
emailInput.changeSubject?.subscribe((element: any) => {
this.inviteEmail = element.value;
});
}
await this.loadData();
}
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
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;
// 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 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({
+1 -1
View File
@@ -44,7 +44,7 @@ const run = async () => {
},
});
// const serviceWorker = await serviceworker.getServiceworkerClient();
await serviceworker.getServiceworkerClient();
const mainTemplate = html`
<style>
+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'>();