e9eb9b4172
Enforce geofenced location evidence for passport challenges and extend admin alerting so mobile devices can review, dismiss, and act on real org and security events.
815 lines
28 KiB
TypeScript
815 lines
28 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);
|
|
|
|
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';
|
|
}
|
|
|
|
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);
|
|
|
|
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,
|
|
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);
|
|
|
|
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',
|
|
});
|
|
|
|
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();
|
|
|
|
const removedUser = await this.receptionRef.userManager.CUser.getInstance({
|
|
id: requestArg.userId,
|
|
});
|
|
|
|
// Remove org from user's connectedOrgs
|
|
const memberUser = removedUser;
|
|
if (memberUser && memberUser.data.connectedOrgs) {
|
|
memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter(
|
|
orgId => orgId !== requestArg.organizationId
|
|
);
|
|
await memberUser.save();
|
|
}
|
|
|
|
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',
|
|
});
|
|
|
|
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();
|
|
|
|
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',
|
|
});
|
|
|
|
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();
|
|
|
|
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',
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|