update
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user