Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions config/templates/org-member-added.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html lang="en"><head><title>{{appName}} invitation</title></head>
<body>
<p>Hello {{displayName}},</p>
<p>You have been invited to join <b>{{orgName}}</b> on {{appName}}.</p>
<p>Review and accept the invitation here: {{url}}</p>
<p>The <b>{{appName}}</b> Team.</p>
</body></html>
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,32 @@ const accept = async (req, res) => {
}
};

/**
* @function decline
* @description Endpoint for the INVITED USER to decline a pending owner_add
* membership — deletes the row (the user can be re-invited later). Same
* auth-only surface and consent gate as accept: the service returns null on any
* mismatch (wrong user, a join_request, an already-active or unknown
* membership) → 404, never leaking which condition failed.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<void>}
*/
const decline = async (req, res) => {
try {
const membership = await MembershipService.declineMembership(
req.params.membershipId,
req.user._id || req.user.id,
);
if (!membership) {
return responses.error(res, 404, 'Not Found', 'No pending invitation with that identifier has been found')();
}
responses.success(res, 'membership invitation declined')(membership);
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};

/**
* @function requestByID
* @description Middleware to fetch a pending membership by its ID.
Expand Down Expand Up @@ -180,5 +206,6 @@ export default {
listMine,
listMinePending,
accept,
decline,
requestByID,
};
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ const update = (membership) => membership.save().then((doc) => doc.populate(defa
*/
const remove = (membership) => Membership.deleteOne({ _id: membership.id || membership._id }).exec();

/**
* @function findOneAndDelete
* @description Atomically find a single membership matching a filter and delete it.
* Returns the deleted document (populated) or null if no document matched, allowing
* callers to implement consent-gated deletes in a single round-trip that is free
* of read-then-delete race conditions.
* @param {Object} filter - The filter to match the document to delete.
* @returns {Promise<Object|null>} The deleted membership (populated) or null.
*/
const findOneAndDelete = (filter) =>
Membership.findOneAndDelete(filter).populate(defaultPopulate).exec();

/**
* @function count
* @description Data access operation to count memberships matching a filter.
Expand Down Expand Up @@ -124,6 +136,7 @@ export default {
create,
get,
findOne,
findOneAndDelete,
update,
remove,
count,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export default (app) => {
.all(passport.authenticate('jwt', { session: false }))
.put(membershipRequests.accept);

// The INVITED USER declines a pending owner_add membership (deletes the row so
// they can be re-invited). Auth-only — same consent gate as accept, enforced in
// the service. Registered AFTER /mine and /mine/pending so those literals are
// never captured as a :membershipId. NOTE: there is intentionally no app.param
// for :membershipId — the controller reads req.params.membershipId raw, exactly
// like accept does.
app
.route('/api/membership-requests/:membershipId')
.all(passport.authenticate('jwt', { session: false }))
.delete(membershipRequests.decline);

// Create a request to join an organization / list pending requests for an organization
app
.route('/api/organizations/:organizationId/requests')
Expand Down
90 changes: 85 additions & 5 deletions modules/organizations/services/organizations.membership.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,26 @@ const validateLastOwnerProtection = async (organizationId) => {

/**
* @function list
* @description Service to retrieve active memberships for an organization.
* @description Service to retrieve an organization's members surface: ACTIVE
* memberships PLUS pending owner_add invitations — so the inviting owner/admin
* can SEE (and cancel via DELETE /members/:memberId) an invite they created.
* Pending join_requests stay OUT: they have their own approval surface
* (listPending) and must never look like members. Rows carry status + source
* so callers can render the pending state.
* @param {String} organizationId - The ID of the organization.
* @param {String} [search] - Optional search string to filter by user name/email.
* @param {Number} [page] - Optional page number for pagination.
* @param {Number} [perPage] - Optional items per page for pagination.
* @returns {Promise<Array>} A promise that resolves to the list of memberships.
*/
const list = async (organizationId, search, page, perPage) => {
const filter = { organizationId, status: MEMBERSHIP_STATUSES.ACTIVE };
const filter = {
organizationId,
$or: [
{ status: MEMBERSHIP_STATUSES.ACTIVE },
{ status: MEMBERSHIP_STATUSES.PENDING, source: PENDING_SOURCES.OWNER_ADD },
],
};
if (search) {
const matchingUsers = await UserService.searchByNameOrEmail(search);
filter.userId = { $in: matchingUsers.map((u) => u._id) };
Expand Down Expand Up @@ -128,7 +139,10 @@ const updateRole = async (membership, role) => {
* @returns {Promise<Object>} A promise resolving to a confirmation of the deletion.
*/
const remove = async (membership) => {
if (membership.role === MEMBERSHIP_ROLES.OWNER) {
// Last-owner protection applies ONLY to ACTIVE owner rows: cancelling a PENDING
// owner-role invite never reduces the active-owner count, so it must not be
// blocked when the org has a single active owner (the inviter) — #3831.
if (membership.role === MEMBERSHIP_ROLES.OWNER && membership.status === MEMBERSHIP_STATUSES.ACTIVE) {
const orgId = membership.organizationId._id || membership.organizationId;
await validateLastOwnerProtection(orgId);
}
Expand Down Expand Up @@ -352,7 +366,10 @@ const findUserByExactEmail = async (email) => {
* @param {String} userId - The user being invited.
* @param {String} [role] - The role to grant on acceptance (defaults to MEMBER).
* @param {String} addedBy - The id of the owner/admin performing the add (provenance).
* @returns {Promise<Object>} The created PENDING owner_add membership.
* @returns {Promise<Object>} The created PENDING owner_add membership. When the
* mailer is configured, also notifies the invited user by email (invitation
* wording — the membership awaits THEIR acceptance) with a link to their
* organizations page. Fire-and-forget: a mail failure never fails the add.
* @throws {Error} If userId is missing, the user does not exist, or a membership already exists.
*/
const addMember = async (organizationId, userId, role, addedBy) => {
Expand All @@ -373,14 +390,42 @@ const addMember = async (organizationId, userId, role, addedBy) => {
}

// status EXPLICIT — never rely on the schema 'active' default (consent bypass).
return MembershipRepository.create({
const membership = await MembershipRepository.create({
userId,
organizationId,
role: role || MEMBERSHIP_ROLES.MEMBER,
status: MEMBERSHIP_STATUSES.PENDING,
source: PENDING_SOURCES.OWNER_ADD,
addedBy,
});

// Invitation notification (parity with the join-request emails). The membership
// is PENDING — only the invitee can activate it via acceptMembership (consent
// invariant #1) — so the wording is an invitation, never a "you were added"
// fait accompli. Fire-and-forget: any failure (DB read or mailer) must never
// fail the add — the entire block is in a try/catch for that guarantee.
if (mailer.isConfigured() && user?.email) {
try {
const org = await OrganizationRepository.get(organizationId);
if (org?.name) {
mailer.sendMail({
to: user.email,
subject: `You have been invited to join ${org.name}`,
template: 'org-member-added',
params: {
displayName: [user.firstName, user.lastName].filter(Boolean).join(' '),
orgName: org.name,
appName: config.app.title,
url: `${getBaseUrl()}/users/organizations`,
},
}).catch((err) => logger.warn('organizations.membership.addMember: invitation email failed', { message: err?.message, stack: err?.stack }));
}
} catch (err) {
logger.warn('organizations.membership.addMember: invitation email setup failed', { message: err?.message, stack: err?.stack });
}
}

return membership;
};

/**
Expand Down Expand Up @@ -430,6 +475,40 @@ const acceptMembership = async (membershipId, acceptingUserId) => {
return result;
};

/**
* @function declineMembership
* @description The INVITED USER declines a pending owner_add membership, deleting
* the row. CONSENT-CRITICAL — the gate is IDENTICAL to acceptMembership: the
* membership must be PENDING + source 'owner_add' AND belong to the
* authenticated caller; any mismatch (wrong user, a join_request, an
* already-active or unknown membership) returns null so the controller can
* answer 404 without leaking which condition failed. Deleting (not flagging)
* mirrors rejectRequest — the user can be re-invited later. This makes the
* createJoinRequest copy ('Please accept or decline it') honest.
* @param {String} membershipId - The pending owner_add membership to decline.
* @param {String} decliningUserId - The authenticated user declining (must be the invitee).
* @returns {Promise<Object|null>} The deleted membership row, or null if not declinable by this user.
*/
const declineMembership = async (membershipId, decliningUserId) => {
// Self-defending consent gate, same rationale as acceptMembership: reject an
// absent caller up-front so a null/undefined id never accidentally matches a
// document (MongoDB $eq null matches null + missing fields).
if (!decliningUserId) return null;

// Atomic consent-gated delete: the filter encodes ALL consent conditions so no
// separate read is needed. A concurrent accept that flipped status→ACTIVE will
// cause the filter to miss the document (status !== PENDING), safely returning
// null — the accept wins and the decline is silently a no-op, which is correct
// (the invitee accepted; there is nothing left to decline).
const deleted = await MembershipRepository.findOneAndDelete({
_id: membershipId,
userId: decliningUserId,
status: MEMBERSHIP_STATUSES.PENDING,
source: PENDING_SOURCES.OWNER_ADD,
});
return deleted || null;
};

/**
* @function leave
* @description Leave an organization. Prevents the last owner from leaving.
Expand Down Expand Up @@ -505,6 +584,7 @@ export default {
findUserByExactEmail,
addMember,
acceptMembership,
declineMembership,
count,
aggregateCountByOrganizations,
deleteMany,
Expand Down
Loading
Loading