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
5 changes: 5 additions & 0 deletions netlify/functions/updateWorkspaceMember.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

const extrovert = require('extrovert');

module.exports = extrovert.toNetlifyFunction(require('../../src/actions/updateWorkspaceMember'));
61 changes: 61 additions & 0 deletions src/actions/updateWorkspaceMember.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use strict';

const Archetype = require('archetype');
const connect = require('../../src/db');
const mongoose = require('mongoose');
const stripe = require('../integrations/stripe');

const UpdateWorkspaceMemberParams = new Archetype({
authorization: {
$type: 'string',
$required: true
},
workspaceId: {
$type: mongoose.Types.ObjectId,
$required: true
},
userId: {
$type: mongoose.Types.ObjectId,
$required: true
},
role: {
$type: 'string',
$required: true,
$enum: ['admin', 'member', 'readonly', 'dashboards']
}
}).compile('UpdateWorkspaceMemberParams');

module.exports = async function updateWorkspaceMember(params) {
const db = await connect();
const { AccessToken, User, Workspace } = db.models;

const { authorization, workspaceId, userId, role } = new UpdateWorkspaceMemberParams(params);

const accessToken = await AccessToken.findById(authorization).orFail(new Error('Invalid or expired access token'));
if (accessToken.expiresAt < new Date()) {
throw new Error('Access token has expired');
}
const initiatedByUserId = accessToken.userId;

const workspace = await Workspace.findById(workspaceId).orFail(new Error('Workspace not found'));
const initiatedByUserRoles = workspace.members.find(member => member.userId.toString() === initiatedByUserId.toString())?.roles;
if (initiatedByUserRoles == null || (!initiatedByUserRoles.includes('admin') && !initiatedByUserRoles.includes('owner'))) {
throw new Error('Forbidden');
}

const member = workspace.members.find(currentMember => currentMember.userId.toString() === userId.toString());
if (member == null) {
throw new Error('Member not found in the workspace');
}

member.roles = [role];
await workspace.save();

Comment on lines +51 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce subscription limits before promoting members

The new action sets member.roles and saves the workspace without checking whether the workspace has a paid subscription or available seats when elevating someone from a free dashboards role to admin/member. inviteToWorkspace explicitly blocks adding paid members on non‑pro plans (subscriptionTier !== 'pro' and numPaidUsers > 0), but this update path skips that validation, so an admin can invite a user as a free dashboards member and immediately promote them to a paid role without triggering any billing enforcement or Stripe provisioning.

Useful? React with 👍 / 👎.

const users = await User.find({ _id: { $in: workspace.members.map(currentMember => currentMember.userId) } });
if (workspace.stripeSubscriptionId) {
const seats = users.filter(user => !user.isFreeUser).length;
await stripe.updateSubscriptionSeats(workspace.stripeSubscriptionId, seats);
}

return { workspace, users };
};
75 changes: 75 additions & 0 deletions test/updateWorkspaceMember.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use strict';

const { afterEach, beforeEach, describe, it } = require('mocha');
const assert = require('assert');
const connect = require('../src/db');
const updateWorkspaceMember = require('../src/actions/updateWorkspaceMember');

describe('updateWorkspaceMember', function() {
let db, AccessToken, User, Workspace;
let user, workspace, accessToken, memberUser;

beforeEach(async function() {
db = await connect();
({ AccessToken, User, Workspace } = db.models);

await AccessToken.deleteMany({});
await User.deleteMany({});
await Workspace.deleteMany({});

user = await User.create({
name: 'John Doe',
email: 'johndoe@example.com',
githubUsername: 'johndoe',
githubUserId: '1234'
});

memberUser = await User.create({
name: 'Jane Smith',
email: 'janesmith@example.com',
githubUsername: 'janesmith',
githubUserId: '5678'
});

accessToken = await AccessToken.create({
userId: user._id,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30)
});

workspace = await Workspace.create({
name: 'Test Workspace',
ownerId: user._id,
apiKey: 'test-api-key',
baseUrl: 'https://example.com',
members: [
{ userId: user._id, roles: ['owner'] },
{ userId: memberUser._id, roles: ['member'] }
],
subscriptionTier: 'pro'
});
});

afterEach(async function() {
await AccessToken.deleteMany({});
await User.deleteMany({});
await Workspace.deleteMany({});
});

it('updates the role of a workspace member', async function() {
const result = await updateWorkspaceMember({
authorization: accessToken._id.toString(),
workspaceId: workspace._id,
userId: memberUser._id,
role: 'admin'
});

assert.ok(result.workspace);

const updatedMember = result.workspace.members.find(member => member.userId.toString() === memberUser._id.toString());
assert.deepStrictEqual(updatedMember.roles, ['admin']);

const workspaceInDb = await Workspace.findById(workspace._id);
const updatedMemberInDb = workspaceInDb.members.find(member => member.userId.toString() === memberUser._id.toString());
assert.deepStrictEqual(updatedMemberInDb.roles, ['admin']);
});
});