diff --git a/backend/src/scripts/migrations/5.16/index.js b/backend/src/scripts/migrations/5.16/index.js
new file mode 100644
index 00000000000..8e5760a450e
--- /dev/null
+++ b/backend/src/scripts/migrations/5.16/index.js
@@ -0,0 +1,24 @@
+/**
+ * Copyright (C) 2023 3D Repo Ltd
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+const migrateUsers = require('./migrateUsersToFrontegg');
+
+const scripts = [
+ { script: migrateUsers, desc: 'migrate users and teamspaces to Frontegg' },
+];
+
+module.exports = scripts;
diff --git a/backend/src/scripts/migrations/5.16/migrateUsersToFrontegg.js b/backend/src/scripts/migrations/5.16/migrateUsersToFrontegg.js
new file mode 100644
index 00000000000..8c21391c916
--- /dev/null
+++ b/backend/src/scripts/migrations/5.16/migrateUsersToFrontegg.js
@@ -0,0 +1,85 @@
+/**
+ * Copyright (C) 2023 3D Repo Ltd
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+const { v5Path } = require('../../../interop');
+
+const { getTeamspaceList } = require('../../utils');
+
+const { getTeamspaceRefId, setTeamspaceRefId, getAllUsersInTeamspace } = require(`${v5Path}/models/teamspaceSettings`);
+const { addUserToAccount, createAccount, createUser, getAllUsersInAccount } = require(`${v5Path}/services/sso/frontegg`);
+
+const { logger } = require(`${v5Path}/utils/logger`);
+const { updateUserId } = require(`${v5Path}/models/users`);
+
+const processTeamspace = async (ts) => {
+ let refId = await getTeamspaceRefId(ts);
+ const isNew = !refId;
+ if (isNew) {
+ logger.logInfo('\t creating Frontegg account...');
+ refId = await createAccount(ts);
+ await setTeamspaceRefId(ts, refId);
+ }
+
+ const projection = {
+ user: 1,
+ 'customData.email': 1,
+ 'customData.userId': 1,
+ 'customData.firstName': 1,
+ 'customData.lastName': 1,
+ };
+
+ const [membersKnown, membersInTs] = await Promise.all([
+ isNew ? Promise.resolve([]) : getAllUsersInAccount(refId),
+ getAllUsersInTeamspace(ts, projection),
+ ]);
+
+ const emailToUserId = {};
+
+ membersKnown.forEach(({ id, email }) => {
+ emailToUserId[email] = id;
+ });
+
+ await Promise.all(membersInTs.map(async ({ user, customData: { email, userId, firstName, lastName } }) => {
+ if (emailToUserId[email]) {
+ // This user is a registered user of the frontegg account
+
+ if (emailToUserId[email] !== userId) {
+ throw new Error(`User ID mismatched for ${user}. Expected ${userId}, found ${emailToUserId}`);
+ }
+ } else if (!userId) {
+ // user does not exist in frontegg, we need to create an entry
+ logger.logInfo(`\tCreating ${user}...`);
+ const newUserId = await createUser(refId, email, [firstName, lastName].join(' '));
+ console.log(newUserId);
+ if (newUserId) await updateUserId(user, newUserId);
+ } else {
+ logger.logInfo(`\tAdding ${user} to account...`);
+ await addUserToAccount(refId, userId, false);
+ }
+ }));
+};
+
+const run = async () => {
+ const teamspaces = await getTeamspaceList();
+ for (const teamspace of teamspaces) {
+ logger.logInfo(`-${teamspace}`);
+ // eslint-disable-next-line no-await-in-loop
+ await processTeamspace(teamspace);
+ }
+};
+
+module.exports = run;
diff --git a/backend/src/v5/handler/db.js b/backend/src/v5/handler/db.js
index 5a7d42ebda4..38339d5ec1f 100644
--- a/backend/src/v5/handler/db.js
+++ b/backend/src/v5/handler/db.js
@@ -320,9 +320,9 @@ DBHandler.storeFileInGridFS = async (database, collection, filename, buffer) =>
});
};
-DBHandler.createIndex = async (database, colName, indexDef, { runInBackground: background } = {}) => {
+DBHandler.createIndex = async (database, colName, indexDef, { runInBackground: background, unique } = {}) => {
const collection = await getCollection(database, colName);
- const options = deleteIfUndefined({ background });
+ const options = deleteIfUndefined({ background, unique });
await collection.createIndex(indexDef, options);
};
diff --git a/backend/src/v5/models/teamspaceSettings.js b/backend/src/v5/models/teamspaceSettings.js
index 63d332cb672..2d4cd75aae3 100644
--- a/backend/src/v5/models/teamspaceSettings.js
+++ b/backend/src/v5/models/teamspaceSettings.js
@@ -219,6 +219,10 @@ TeamspaceSetting.createTeamspaceSettings = async (teamspace, teamspaceId) => {
await db.insertOne(teamspace, TEAMSPACE_SETTINGS_COL, settings);
};
+TeamspaceSetting.setTeamspaceRefId = async (teamspace, refId) => {
+ await teamspaceSettingUpdate(teamspace, { _id: teamspace }, { $set: { refId } });
+};
+
TeamspaceSetting.getTeamspaceRefId = async (teamspace) => {
const { refId } = await teamspaceSettingQuery(teamspace, { _id: teamspace }, { refId: 1 });
return refId;
@@ -232,11 +236,11 @@ const grantPermissionToUser = async (teamspace, username, permission) => {
TeamspaceSetting.grantAdminToUser = (teamspace, username) => grantPermissionToUser(teamspace,
username, TEAMSPACE_ADMIN);
-TeamspaceSetting.getAllUsersInTeamspace = async (teamspace) => {
+TeamspaceSetting.getAllUsersInTeamspace = async (teamspace, projection) => {
const query = { 'roles.db': teamspace, 'roles.role': TEAM_MEMBER };
- const users = await findMany(query, { user: 1 });
+ const users = await findMany(query, projection ?? { user: 1 });
- return users.map(({ user }) => user);
+ return projection ? users : users.map(({ user }) => user);
};
TeamspaceSetting.removeUserFromAdminPrivilege = async (teamspace, user) => {
diff --git a/backend/src/v5/models/users.js b/backend/src/v5/models/users.js
index e2d6546e538..1873f3d6a82 100644
--- a/backend/src/v5/models/users.js
+++ b/backend/src/v5/models/users.js
@@ -161,4 +161,8 @@ User.addUser = async (newUserData) => {
User.removeUser = (user) => db.deleteOne(USERS_DB_NAME, USERS_COL, { user });
User.removeUsers = (users) => db.deleteMany(USERS_DB_NAME, USERS_COL, { user: { $in: users } });
+User.ensureIndicesExist = async () => {
+ await db.createIndex(USERS_DB_NAME, USERS_COL, { 'customData.userId': 1 }, { runInBackground: true, unique: true });
+};
+
module.exports = User;
diff --git a/backend/src/v5/services/sessions.js b/backend/src/v5/services/sessions.js
index a06521f069a..23566ce07c3 100644
--- a/backend/src/v5/services/sessions.js
+++ b/backend/src/v5/services/sessions.js
@@ -23,10 +23,12 @@ const { events } = require('./eventsManager/eventsManager.constants');
const expressSession = require('express-session');
const { generateUUID } = require('../utils/helper/uuids');
const { publish } = require('./eventsManager/eventsManager');
+const { ensureIndicesExist } = require('../models/users');
const Sessions = { };
const initialiseSession = async () => {
const store = await db.getSessionStore(expressSession);
+ await ensureIndicesExist();
const secure = config.public_protocol === 'https';
const { secret, maxAge, domain } = config.cookie;
diff --git a/backend/src/v5/services/sso/frontegg/index.js b/backend/src/v5/services/sso/frontegg/index.js
index e25c14a3d8b..1f2c1a37836 100644
--- a/backend/src/v5/services/sso/frontegg/index.js
+++ b/backend/src/v5/services/sso/frontegg/index.js
@@ -19,6 +19,7 @@ const { HEADER_TENANT_ID, META_LABEL_TEAMSPACE } = require('./frontegg.constants
const { get, delete: httpDelete, post } = require('../../../utils/webRequests');
const { IdentityClient } = require('@frontegg/client');
const Yup = require('yup');
+const { deleteIfUndefined } = require('../../../utils/helper/objects');
const { generateUUIDString } = require('../../../utils/helper/uuids');
const { logger } = require('../../../utils/logger');
const { sso: { frontegg } } = require('../../../utils/config');
@@ -123,6 +124,27 @@ Frontegg.getUserById = async (userId) => {
}
};
+Frontegg.createUser = async (accountId, email, name, userData, privateUserData, bypassVerification = false) => {
+ try {
+ const payload = deleteIfUndefined({
+ email,
+ name,
+ tenantId: accountId,
+ metadata: userData,
+ vendorMetadata: privateUserData,
+ roleIds: ['APP_USER'],
+
+ });
+
+ // using the migration endpoint will automatically activate the user
+ const url = bypassVerification ? `${config.vendorDomain}/identity/resources/migrations/v1/local` : `${config.vendorDomain}/identity/resources/vendor-only/users/v1`;
+ const { data } = await post(url, payload, { headers: await getBearerHeader() });
+ return data.id;
+ } catch (err) {
+ throw new Error(`Failed to create user(${email}) on Frontegg: ${err.message}`);
+ }
+};
+
Frontegg.getTeamspaceByAccount = async (accountId) => {
try {
const { data: { metadata } } = await get(`${config.vendorDomain}/tenants/resources/tenants/v2/${accountId}`, await getBearerHeader());
@@ -159,12 +181,48 @@ Frontegg.createAccount = async (name) => {
}
};
-Frontegg.addUserToAccount = async (accountId, userId) => {
+Frontegg.getAllUsersInAccount = async (accountId) => {
+ try {
+ const header = {
+ ...await getBearerHeader(),
+ 'frontegg-tenant-id': accountId,
+
+ };
+
+ const initialQuery = {
+ _limit: 200,
+ _offset: 0,
+ _sortBy: 'email',
+ _order: 'ASC',
+ };
+
+ let query = new URLSearchParams(initialQuery).toString();
+ const entries = [];
+
+ while (query?.length) {
+ // eslint-disable-next-line no-await-in-loop
+ const { data: { items, _links } } = await get(`${config.vendorDomain}/identity/resources/users/v3?${query}`,
+ header);
+
+ items.forEach(({ id, email }) => {
+ entries.push({ id, email });
+ });
+
+ query = _links.next;
+ }
+
+ return entries;
+ } catch (err) {
+ throw new Error(`Failed to get users from account(${accountId}) from Frontegg: ${err.message}`);
+ }
+};
+
+Frontegg.addUserToAccount = async (accountId, userId, sendInvite = true) => {
try {
const payload = {
tenantId: accountId,
validateTenantExist: true,
- skipInviteEmail: false,
+ skipInviteEmail: !sendInvite,
};
await post(`${config.vendorDomain}/identity/resources/users/v1/${userId}/tenant`, payload, { headers: await getBearerHeader() });
} catch (err) {