2025-12-04 17:45:40 +00:00
|
|
|
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);
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
private async emitOrganizationAlert(optionsArg: {
|
|
|
|
|
organizationId: string;
|
|
|
|
|
eventType: string;
|
|
|
|
|
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
|
|
|
|
title: string;
|
|
|
|
|
body: string;
|
|
|
|
|
actorUserId: string;
|
|
|
|
|
relatedEntityId?: string;
|
|
|
|
|
relatedEntityType?: string;
|
|
|
|
|
}) {
|
|
|
|
|
await this.receptionRef.alertManager.createAlertsForEvent({
|
|
|
|
|
category: 'admin',
|
|
|
|
|
organizationId: optionsArg.organizationId,
|
|
|
|
|
eventType: optionsArg.eventType,
|
|
|
|
|
severity: optionsArg.severity,
|
|
|
|
|
title: optionsArg.title,
|
|
|
|
|
body: optionsArg.body,
|
|
|
|
|
actorUserId: optionsArg.actorUserId,
|
|
|
|
|
relatedEntityId: optionsArg.relatedEntityId,
|
|
|
|
|
relatedEntityType: optionsArg.relatedEntityType,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getOrganizationName(organizationIdArg: string) {
|
|
|
|
|
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
|
|
|
|
id: organizationIdArg,
|
|
|
|
|
});
|
|
|
|
|
return organization?.data.name || 'this organization';
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 17:45:40 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
// Send invitation email
|
|
|
|
|
await this.sendInvitationEmail(invitation, requestArg.organizationId);
|
2025-12-04 17:45:40 +00:00
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
await this.emitOrganizationAlert({
|
|
|
|
|
organizationId: requestArg.organizationId,
|
|
|
|
|
eventType: 'org_invitation_created',
|
|
|
|
|
severity: 'low',
|
|
|
|
|
title: 'Organization invitation created',
|
|
|
|
|
body: `${user.data.email} invited ${email} to ${await this.getOrganizationName(
|
|
|
|
|
requestArg.organizationId
|
|
|
|
|
)}.`,
|
|
|
|
|
actorUserId: user.id,
|
|
|
|
|
relatedEntityId: invitation.id,
|
|
|
|
|
relatedEntityType: 'invitation',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
2025-12-04 17:45:40 +00:00
|
|
|
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);
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
await this.emitOrganizationAlert({
|
|
|
|
|
organizationId: requestArg.organizationId,
|
|
|
|
|
eventType: 'org_invitation_resent',
|
|
|
|
|
severity: 'low',
|
|
|
|
|
title: 'Organization invitation resent',
|
|
|
|
|
body: `${user.data.email} resent an invitation to ${invitation.data.email}.`,
|
|
|
|
|
actorUserId: user.id,
|
|
|
|
|
relatedEntityId: invitation.id,
|
|
|
|
|
relatedEntityType: 'invitation',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-04 17:45:40 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
const removedUser = await this.receptionRef.userManager.CUser.getInstance({
|
2025-12-04 17:45:40 +00:00
|
|
|
id: requestArg.userId,
|
|
|
|
|
});
|
2026-04-20 13:21:28 +00:00
|
|
|
|
|
|
|
|
// Remove org from user's connectedOrgs
|
|
|
|
|
const memberUser = removedUser;
|
2025-12-04 17:45:40 +00:00
|
|
|
if (memberUser && memberUser.data.connectedOrgs) {
|
|
|
|
|
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
|
|
|
|
orgId => orgId !== requestArg.organizationId
|
|
|
|
|
);
|
|
|
|
|
await memberUser.save();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
await this.emitOrganizationAlert({
|
|
|
|
|
organizationId: requestArg.organizationId,
|
|
|
|
|
eventType: 'org_member_removed',
|
|
|
|
|
severity: 'high',
|
|
|
|
|
title: 'Organization member removed',
|
|
|
|
|
body: `${user.data.email} removed ${removedUser?.data?.email || requestArg.userId} from ${await this.getOrganizationName(
|
|
|
|
|
requestArg.organizationId
|
|
|
|
|
)}.`,
|
|
|
|
|
actorUserId: user.id,
|
|
|
|
|
relatedEntityId: requestArg.userId,
|
|
|
|
|
relatedEntityType: 'user',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-04 17:45:40 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
const updatedUser = await this.receptionRef.userManager.CUser.getInstance({
|
|
|
|
|
id: requestArg.userId,
|
|
|
|
|
});
|
|
|
|
|
await this.emitOrganizationAlert({
|
|
|
|
|
organizationId: requestArg.organizationId,
|
|
|
|
|
eventType: 'org_member_roles_updated',
|
|
|
|
|
severity: 'high',
|
|
|
|
|
title: 'Organization member roles updated',
|
|
|
|
|
body: `${user.data.email} changed roles for ${updatedUser?.data?.email || requestArg.userId} to ${requestArg.roles.join(', ')}.`,
|
|
|
|
|
actorUserId: user.id,
|
|
|
|
|
relatedEntityId: requestArg.userId,
|
|
|
|
|
relatedEntityType: 'user',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-04 17:45:40 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-04-20 13:21:28 +00:00
|
|
|
const newOwner = await this.receptionRef.userManager.CUser.getInstance({
|
|
|
|
|
id: requestArg.newOwnerId,
|
|
|
|
|
});
|
|
|
|
|
await this.emitOrganizationAlert({
|
|
|
|
|
organizationId: requestArg.organizationId,
|
|
|
|
|
eventType: 'org_ownership_transferred',
|
|
|
|
|
severity: 'critical',
|
|
|
|
|
title: 'Organization ownership transferred',
|
|
|
|
|
body: `${user.data.email} transferred ownership to ${newOwner?.data?.email || requestArg.newOwnerId}.`,
|
|
|
|
|
actorUserId: user.id,
|
|
|
|
|
relatedEntityId: requestArg.newOwnerId,
|
|
|
|
|
relatedEntityType: 'user',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-04 17:45:40 +00:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
2025-12-05 09:34:19 +00:00
|
|
|
|
|
|
|
|
// 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 };
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
2025-12-04 17:45:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.');
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-05 09:34:19 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate email format
|
|
|
|
|
*/
|
|
|
|
|
private isValidEmail(email: string): boolean {
|
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
|
return emailRegex.test(email);
|
|
|
|
|
}
|
2025-12-04 17:45:40 +00:00
|
|
|
}
|