Files
app/ts/reception/classes.userinvitationmanager.ts
T
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

718 lines
24 KiB
TypeScript

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);
}
}