New Drive UI initial version

This commit is contained in:
Romaric Mourgues
2023-03-27 18:30:36 +02:00
parent fcb0f993fe
commit 42420df02d
261 changed files with 1496 additions and 16604 deletions
@@ -1,14 +0,0 @@
import { CommandModule } from "yargs";
const command: CommandModule = {
describe: "Manage Twake Console",
command: "console <command>",
builder: yargs =>
yargs.commandDir("console_cmds", {
visit: commandModule => commandModule.default,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
handler: () => {},
};
export default command;
@@ -1,257 +0,0 @@
import yargs from "yargs";
import ora from "ora";
import twake from "../../../twake";
import { CompanyReport, UserReport } from "../../../services/console/types";
import Company from "../../../services/user/entities/company";
import gr from "../../../services/global-resolver";
import { ConsoleServiceImpl } from "../../../services/console/service";
/**
* Merge command parameters. Check the builder definition below for more details.
*/
type MergeParams = {
url: string;
concurrent: number;
dry: boolean;
console: string;
link: boolean;
csv: boolean;
client: string;
secret: string;
};
const services = [
"platform-services",
"user",
"channels",
"notifications",
"database",
"webserver",
"message-queue",
"console",
];
const command: yargs.CommandModule<MergeParams, MergeParams> = {
command: "merge",
describe: "Merge Twake Chat users in the Twake Console",
builder: {
url: {
default: "http://localhost:8080",
type: "string",
description: "URL of the Twake console",
},
concurrent: {
default: 1,
type: "number",
description: "Number of concurrent imports",
},
dry: {
default: false,
type: "boolean",
description: "Make a dry run without creating anything on the Twake console",
},
csv: {
default: false,
type: "boolean",
description: "Generate result as CSV",
},
console: {
default: "console",
type: "string",
description: "The console service identifier to user to link user and companies",
},
link: {
default: true,
type: "boolean",
description:
"Link the companies/users to external companies/user. Works with the --console parameter",
implies: "console",
},
client: {
default: "twake",
type: "string",
description: "Client identifier to be used to authenticate calls to the Console",
},
secret: {
default: "secret",
type: "string",
description: "Client secret to be used to authenticate calls to the Console",
},
},
handler: async argv => {
const spinner = ora({ text: `Importing Twake data on ${argv.url}` }).start();
const platform = await twake.run(services);
await gr.doInit(platform);
const consoleService = platform.getProvider<ConsoleServiceImpl>("console");
const merge = consoleService.merge(
argv.url,
argv.concurrent,
argv.dry,
argv.console,
argv.link,
argv.client,
argv.secret,
);
const start = Date.now();
let stop: number = start;
const userReports: UserReport[] = [];
const companyReports: CompanyReport[] = [];
const ownerReports: UserReport[] = [];
let companiesWithoutAdmin: Company[] = [];
const process = merge.subscribe({
next: report => {
if (report.type === "company:created") {
const companyReport = report.data as CompanyReport;
companyReports.push(companyReport);
if (companyReport.status === "success") {
spinner.succeed(
`Company created: ${companyReport.company.source.id} ${companyReport.company.source.displayName}`,
);
} else {
spinner.fail(
`Creation error for company ${companyReport.company.source.id} (${companyReport.company.source.displayName}): ${companyReport?.error?.message}`,
);
}
}
if (report.type === "user:created") {
const userReport = report.data as UserReport;
userReports.push(userReport);
if (userReport.status === "success") {
spinner.succeed(
`Company ${userReport.user.source.company.id}: User created ${userReport.user.destination.id}`,
);
} else {
spinner.fail(
`Company ${userReport.user.source.company.id}: User creation error ${userReport.user.destination.id}: ${userReport?.error?.message}`,
);
}
}
if (report.type === "user:updated") {
const userReport = report.data as UserReport;
ownerReports.push(userReport);
if (userReport.status === "success") {
spinner.succeed(
`Company ${userReport.destinationCompanyCode} owner updated to user ${userReport.destinationId}`,
);
} else {
spinner.fail(
`Company ${userReport.destinationCompanyCode}: Owner update error for user ${userReport.destinationId}: ${userReport?.error?.message}`,
);
}
}
if (report.type === "processing:owner") {
spinner.start("Computing company owners, please wait...");
}
if (report.type === "user:updating") {
//spinner.succeed(`Updating owner for company ${report?.company?.sourceId}`);
}
if (report.type === "log") {
report.message && (spinner.text = report.message);
}
if (report.type === "company:withoutadmin") {
report.message && (spinner.text = report.message);
const companies = (report.data || []) as Company[];
if (companies.length) {
companiesWithoutAdmin = companies;
}
}
},
error: () => {
spinner.fail("Fatal error");
},
complete: async () => {
stop = Date.now();
spinner.succeed("Merge is complete");
displayStats();
await tearDown();
},
});
async function tearDown() {
await platform.stop();
process.unsubscribe();
}
function reportAsCSV() {
const users = userReports.map(
user =>
`${user.sourceId},${user.destinationId},${user.destinationCompanyCode},${user.status}`,
);
const userCSV = [...["sourceId,destinationId,destinationCompanyCode,status"], ...users].join(
"\n",
);
const companies = companyReports.map(
company => `${company.sourceId},${company.destinationCode},${company.status}`,
);
const companyCSV = [...["sourceId,destinationCode,status"], ...companies].join("\n");
console.log(userCSV);
console.log(companyCSV);
}
function displayStats() {
const userFailures = userReports.filter(user => user.error);
const companyFailures = companyReports.filter(company => company.error);
const ownerFailures = ownerReports.filter(report => report.error);
console.log("# Import report");
console.log(`Data imported in ${(stop - start) / 1000} seconds`);
console.log("## Company");
console.log("- Companies success:", companyReports.filter(company => !company.error).length);
console.log("- Companies failure:", companyFailures.length);
if (companyFailures.length) {
console.log("### Failures");
companyFailures.forEach(failure =>
console.log(
`- Company ${failure.company.source.id} error: ${failure.company.error?.message}`,
),
);
}
console.log("## User");
console.log("- Users success:", userReports.filter(user => !user.error).length);
console.log("- Users failure:", userFailures.length);
if (userFailures.length) {
console.log("### Failures");
userFailures.forEach(failure =>
console.log(
`- User ${failure.user.source.user.user_id} error: ${failure.user.error?.message}`,
),
);
}
console.log("## Owner");
console.log("- Owners success:", ownerReports.filter(report => !report.error).length);
console.log("- Owners failure:", ownerFailures.length);
if (ownerFailures.length) {
console.log("### Failures");
ownerFailures.forEach(report =>
console.log(`- User ${report.destinationId} error: ${report?.error?.message}`),
);
}
console.log("## Warnings");
console.log("### Companies witout admins");
companiesWithoutAdmin.forEach(company => {
console.log(`- ${company.id} - ${company.displayName}`);
});
if (argv.csv) {
reportAsCSV();
}
}
},
};
export default command;
@@ -60,7 +60,7 @@ export interface ConsoleServiceClient {
fetchCompanyInfo(consoleCompanyCode: string): Promise<ConsoleHookCompany>;
getUserByAccessToken(accessToken: string): Promise<ConsoleHookUser>;
getUserByAccessToken(idToken: string): Promise<ConsoleHookUser>;
resendVerificationEmail(email: string): Promise<void>;
}
@@ -95,7 +95,7 @@ export class ConsoleInternalClient implements ConsoleServiceClient {
throw new Error("Method should not be implemented.");
}
getUserByAccessToken(_accessToken: string): Promise<ConsoleHookUser> {
getUserByAccessToken(_idToken: string): Promise<ConsoleHookUser> {
logger.info("Internal: getUserByAccessToken");
throw new Error("Method should not be implemented.");
}
@@ -1,5 +1,5 @@
import axios, { AxiosInstance } from "axios";
import { v1 as uuidv1 } from "uuid";
import { AxiosInstance } from "axios";
import { Issuer } from "openid-client";
import { ConsoleServiceClient } from "../client-interface";
import {
ConsoleCompany,
@@ -14,16 +14,12 @@ import {
UpdatedConsoleUserRole,
} from "../types";
import User, { getInstance } from "../../user/entities/user";
import Company, {
CompanySearchKey,
getInstance as getCompanyInstance,
} from "../../user/entities/company";
import OktaJwtVerifier from "@okta/jwt-verifier";
import { CrudException } from "../../../core/platform/framework/api/crud-service";
import coalesce from "../../../utils/coalesce";
import { logger } from "../../../core/platform/framework/logger";
import { CompanyFeaturesEnum, CompanyLimitsEnum } from "../../user/web/types";
import gr from "../../global-resolver";
import Company, { CompanySearchKey } from "../../user/entities/company";
import User from "../../user/entities/user";
import { ConsoleServiceImpl } from "../service";
export class ConsoleRemoteClient implements ConsoleServiceClient {
@@ -31,92 +27,30 @@ export class ConsoleRemoteClient implements ConsoleServiceClient {
client: AxiosInstance;
private infos: ConsoleOptions;
private verifier: OktaJwtVerifier;
constructor(consoleInstance: ConsoleServiceImpl, private dryRun: boolean) {
this.infos = consoleInstance.consoleOptions;
this.client = axios.create({ baseURL: this.infos.url });
this.verifier = new OktaJwtVerifier({
issuer: this.infos.issuer,
});
}
fetchCompanyInfo(consoleCompanyCode: string): Promise<ConsoleHookCompany> {
throw new Error("Method not implemented.");
}
resendVerificationEmail(email: string): Promise<void> {
throw new Error("Method not implemented.");
}
private auth() {
return { username: this.infos.username, password: this.infos.password };
return {};
}
async addUserToCompany(
company: ConsoleCompany,
user: CreateConsoleUser,
): Promise<CreatedConsoleUser> {
logger.info("Remote: addUserToCompany");
const isNewConsole = this.infos.new_console;
if (this.dryRun) {
return {
_id: uuidv1(),
};
}
if (user.skipInvite && user.name && user.email && user.password) {
return this.client
.post(`/api/companies/${company.code}/users`, user, {
auth: this.auth(),
headers: {
"Content-Type": "application/json",
},
params: {
skipInvite: user.skipInvite,
},
})
.then(({ data }) => data);
} else {
const invitationData = {
role: user.role,
emails: [
{
email: user.email,
role: user.role,
},
],
inviter: { email: user.inviterEmail },
...(isNewConsole
? {
applicationCodes: ["twake"],
}
: {}),
};
const result = await this.client
.post(
`/api/companies/${company.code}/${isNewConsole ? "invitations" : "users/invitation"}`,
invitationData,
{
auth: this.auth(),
headers: {
"Content-Type": "application/json",
},
},
)
.then(async ({ data, status }) => {
//Fixme: When console solve https://gitlab.com/COMPANY_LINAGORA/software/saas/twake-console-account/-/issues/36
// we can remove this fallback
if ([200, 201].indexOf(status) >= 0) {
return data;
} else {
return this.client
.post(`/api/companies/${company.code}/users`, user, {
auth: this.auth(),
headers: {
"Content-Type": "application/json",
},
params: {
skipInvite: user.skipInvite,
},
})
.then(({ data }) => data);
}
});
return result;
}
return null;
}
async updateUserRole(
@@ -124,47 +58,12 @@ export class ConsoleRemoteClient implements ConsoleServiceClient {
user: UpdateConsoleUserRole,
): Promise<UpdatedConsoleUserRole> {
logger.info("Remote: updateUserRole");
if (this.dryRun) {
return {
id: user.id,
role: user.role,
};
}
const result = await this.client
.put(
`/api/companies/${company.code}/users/${user.id}`,
{ role: user.role },
{
auth: this.auth(),
headers: {
"Content-Type": "application/json",
},
},
)
.then(({ data }) => data);
return result;
return null;
}
async createCompany(company: CreateConsoleCompany): Promise<CreatedConsoleCompany> {
logger.info("Remote: createCompany");
if (this.dryRun) {
return company;
}
const result = await this.client
.post("/api/companies", company, {
auth: this.auth(),
headers: {
"Content-Type": "application/json",
},
})
.then(({ data }) => data);
return result;
return null;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -175,216 +74,11 @@ export class ConsoleRemoteClient implements ConsoleServiceClient {
}
async updateLocalCompanyFromConsole(partialCompanyDTO: ConsoleHookCompany): Promise<Company> {
logger.info("Remote: updateLocalCompanyFromConsole");
const companyDTO = await this.fetchCompanyInfo(partialCompanyDTO.details.code);
let company = await gr.services.companies.getCompany({
identity_provider_id: companyDTO.details.code,
});
if (!company) {
const newCompany = getCompanyInstance({
id: uuidv1(),
identity_provider: "console",
identity_provider_id: companyDTO.details.code,
});
company = await gr.services.companies.createCompany(newCompany);
}
const details = companyDTO.details;
if (details) {
company.name = coalesce(details.name, company.name);
company.displayName = coalesce(details.name, company.displayName);
const avatar = details.avatar;
company.logo =
details.logo ||
(avatar && avatar.type && avatar.type !== "url"
? this.infos.url.replace(/\/$/, "") + "/api/avatars/" + avatar.value
: companyDTO.value || "");
}
if (!company.plan) {
company.plan = { name: "", limits: undefined, features: undefined };
}
const limits = companyDTO.limits.twake || companyDTO.limits;
//FIXME this is a hack right now!
let planFeatures: any = {
[CompanyFeaturesEnum.CHAT_GUESTS]: true,
[CompanyFeaturesEnum.CHAT_MESSAGE_HISTORY]: true,
[CompanyFeaturesEnum.CHAT_MULTIPLE_WORKSPACES]: true,
[CompanyFeaturesEnum.CHAT_EDIT_FILES]: true,
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]: true,
[CompanyFeaturesEnum.COMPANY_INVITE_MEMBER]: true,
};
if (limits.members < 0 && this.infos.type === "remote") {
//Hack to say this is free version
planFeatures = {
[CompanyFeaturesEnum.CHAT_GUESTS]: false,
[CompanyFeaturesEnum.CHAT_MESSAGE_HISTORY]: false,
[CompanyFeaturesEnum.CHAT_MULTIPLE_WORKSPACES]: false,
[CompanyFeaturesEnum.CHAT_EDIT_FILES]: false,
[CompanyFeaturesEnum.CHAT_UNLIMITED_STORAGE]: false, // Currently inactive
};
company.plan.name = "free";
} else {
company.plan.name = "standard";
}
company.plan.features = { ...planFeatures };
company.plan.limits = {
[CompanyLimitsEnum.CHAT_MESSAGE_HISTORY_LIMIT]: 10000, // To remove duplicata since we define this in formatCompany function
[CompanyLimitsEnum.COMPANY_MEMBERS_LIMIT]: limits["members"],
[CompanyLimitsEnum.COMPANY_GUESTS_LIMIT]: limits["guests"],
};
company.stats = coalesce(companyDTO.stats, company.stats);
await gr.services.companies.updateCompany(company);
return company;
return null;
}
async updateLocalUserFromConsole(code: string): Promise<User> {
logger.info("Remote: updateLocalUserFromConsole");
const userDTO = await this.fetchUserInfo(code);
if (!userDTO) {
throw CrudException.badRequest("User not found on Console");
}
const roles = userDTO.roles.filter(
role => role.applications === undefined || role.applications.find(a => a.code === "twake"),
);
let user = await gr.services.users.getByConsoleId(userDTO._id);
if (!user) {
if (!userDTO.email) {
throw CrudException.badRequest("Email is required");
}
let username = userDTO.email
.split("@")[0]
.toLocaleLowerCase()
.replace(/[^a-zA-Z0-9]/g, "")
.replace(/ +/g, "_");
if (await gr.services.users.isEmailAlreadyInUse(userDTO.email)) {
const user = await gr.services.users.getByEmail(userDTO.email);
if (user.identity_provider === "console") {
let emailUserOnConsole = null;
try {
emailUserOnConsole = await this.fetchUserInfo(user.identity_provider_id);
} catch (err) {}
if (emailUserOnConsole?._id) {
throw CrudException.badRequest(
`Console user not created because email already exists on console with different id: ${emailUserOnConsole._id} while requested to provision user with id: ${userDTO._id}`,
);
}
//If not present on console, then anonymise this one and create a new one
await gr.services.users.anonymizeAndDelete(
{ id: user.id },
{
user: { id: user.id, server_request: true },
},
);
//Now we can create the new user
}
}
username = await gr.services.users.getAvailableUsername(username);
if (!username) {
throw CrudException.badRequest("Console user not created because username already exists");
}
user = getInstance({});
user.username_canonical = (username || "").toLocaleLowerCase();
user.email_canonical = userDTO.email;
user.deleted = false;
}
user.email_canonical = coalesce(userDTO.email, user.email_canonical);
user.phone = "";
user.first_name = coalesce(userDTO.name, user.first_name);
user.last_name = coalesce(userDTO.surname, user.last_name);
user.identity_provider = "console";
user.identity_provider_id = userDTO._id;
user.mail_verified = coalesce(userDTO.isVerified, user.mail_verified);
if (userDTO.preference) {
user.preferences = user.preferences || {};
user.preferences.allow_tracking = coalesce(
userDTO.preference.allowTrackingPersonalInfo,
user.preferences?.allow_tracking,
);
user.preferences.language = coalesce(userDTO.preference.locale, user.preferences?.language);
user.preferences.timezone = coalesce(userDTO.preference.timeZone, user.preferences?.timezone);
}
const avatar = userDTO.avatar;
user.picture =
avatar && avatar.type && avatar.type !== "url"
? this.infos.url.replace(/\/$/, "") + "/api/avatars/" + avatar.value
: "";
await gr.services.users.save(user);
const getCompanyByCode = async (companyCode: string) => {
let company = await gr.services.companies.getCompany({
identity_provider_id: companyCode,
});
if (!company) {
const companyDTO = await this.fetchCompanyInfo(companyCode);
await this.updateLocalCompanyFromConsole(companyDTO);
company = await gr.services.companies.getCompany({
identity_provider_id: companyCode,
});
}
return company;
};
const updatedListOfCompanies = [];
if (roles) {
for (const role of roles) {
const companyConsoleCode = role.targetCode;
const roleName = role.roleCode;
const company = await getCompanyByCode(companyConsoleCode);
if (!company) {
throw CrudException.notFound(`Company ${companyConsoleCode} not found`);
}
//Make sure user is active, if not we remove it
if (role.status !== "deactivated") {
updatedListOfCompanies.push(company);
const applications = (role.applications || []).map(a => a.code);
await gr.services.companies.setUserRole(company.id, user.id, roleName, applications);
}
}
}
// Remove user from companies not in the console
const currentCompanies = await gr.services.companies.getAllForUser(user.id);
for (const company of currentCompanies) {
if (!updatedListOfCompanies.map(c => c.id).includes(company.group_id)) {
await gr.services.companies.removeUserFromCompany(
{ id: company.group_id },
{ id: user.id },
);
}
}
await gr.services.users.save(user, { user: { id: user.id, server_request: true } });
return user;
return null;
}
async removeCompanyUser(consoleUserId: string, company: Company): Promise<void> {
@@ -419,81 +113,25 @@ export class ConsoleRemoteClient implements ConsoleServiceClient {
await gr.services.companies.removeCompany(companySearchKey);
}
fetchCompanyInfo(consoleCompanyCode: string): Promise<ConsoleHookCompany> {
logger.info(`Remote: fetchCompanyInfo ${consoleCompanyCode}`);
return this.client
.get(`/api/companies/${consoleCompanyCode}`, {
auth: this.auth(),
headers: {
"Content-Type": "application/json",
},
})
.then(({ data }) => data.company)
.catch(e => {
if (e.response.status === 401) {
throw CrudException.forbidden("Bad console credentials");
}
throw e;
});
}
fetchUserInfo(consoleUserId: string): Promise<ConsoleHookUser> {
logger.info(`Remote: fetchUserInfo ${consoleUserId}`);
return this.client
.get(`/api/users/${consoleUserId}`, {
auth: this.auth(),
headers: {
"Content-Type": "application/json",
},
})
.then(({ data }) => data)
.catch(e => {
if (e.response.status === 401) {
throw CrudException.forbidden("Bad console credentials");
}
throw e;
});
}
getUserByAccessToken(accessToken: string): Promise<ConsoleHookUser> {
logger.info("Remote: getUserByAccessToken");
return this.client
.get("/api/users/profile", {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
})
.then(({ data }) => data)
.catch(e => {
if (e.response?.status === 401) {
throw CrudException.forbidden("Bad access token credentials");
}
throw e;
});
}
async resendVerificationEmail(email: string) {
logger.info("Remote: resendVerificationEmail");
return this.client
.post(
"/api/users/resend-verification-email",
{
email,
},
{
auth: this.auth(),
headers: {
"Content-Type": "application/json",
},
},
)
.then(({ data }) => data)
.catch(e => {
if (e.response.status === 401) {
throw CrudException.forbidden("Bad credentials");
}
throw e;
});
async getUserByAccessToken(idToken: string): Promise<ConsoleHookUser> {
const user = (await this.verifier.verifyIdToken(idToken, this.infos.audience)).claims as any;
console.log("user", user);
return {
_id: user.sub,
roles: [] as any,
email: user.email,
name: user.given_name,
surname: user.family_name,
isVerified: true,
preference: {
locale: user.locale,
timeZone: 0,
allowTrackingPersonalInfo: true,
},
avatar: {
type: "url",
value: user.picture,
},
};
}
}
@@ -1,426 +0,0 @@
import passwordGenerator from "generate-password";
import { concat, EMPTY, forkJoin, from, Observable, ReplaySubject } from "rxjs";
import {
count,
distinct,
filter,
groupBy,
map,
mergeMap,
min,
share,
tap,
toArray,
} from "rxjs/operators";
import { getLogger } from "../../../core/platform/framework";
import {
ExecutionContext,
Paginable,
Pagination,
} from "../../../core/platform/framework/api/crud-service";
import Company from "../../user/entities/company";
import User from "../../user/entities/user";
import {
CompanyCreatedStreamObject,
CompanyReport,
ConsoleOptions,
CreatedConsoleCompany,
CreatedConsoleUser,
MergeProgress,
ProcessReport,
UpdateConsoleUserRole,
UserCreatedStreamObject,
UserReport,
} from "../types";
import { getInstance as getExternalUserInstance } from "../../user/entities/external_user";
import { getInstance as getExternalGroupInstance } from "../../user/entities/external_company";
import CompanyUser from "../../user/entities/company_user";
import { ConsoleRemoteClient } from "../clients/remote";
import { ConsoleServiceClient } from "../client-interface";
import { DatabaseServiceAPI } from "../../../core/platform/services/database/api";
import { CompanyUserRole } from "../../user/web/types";
import gr from "../../global-resolver";
import { ConsoleServiceImpl } from "../service";
const logger = getLogger("console.process.merge");
export class MergeProcess {
private client: ConsoleServiceClient;
constructor(
private database: DatabaseServiceAPI,
private dryRun: boolean,
private consoleId: string = "console",
private linkExternal: boolean = true,
consoleClientOptions: ConsoleOptions,
) {
const consoleService = new ConsoleServiceImpl(consoleClientOptions);
this.client = new ConsoleRemoteClient(consoleService, dryRun);
}
merge(concurrent: number = 1): MergeProgress {
const progress$ = new ReplaySubject<ProcessReport>();
const { users$, companies$ } = this.getStreams(concurrent);
const owners$ = users$.pipe(
// get only the admins
filter(userInCompany => userInCompany.source.user.level.valueOf() === 3),
// group by company id
groupBy(userInCompany => userInCompany.source.company.id),
mergeMap(group =>
group.pipe(
// get the admin with the smaller creation date, let's say that this is the first one in the company so it is the owner
min((a, b) => {
return (a.source?.user?.dateAdded || 0) <= (b.source?.user?.dateAdded || 0) ? -1 : 1;
}),
),
),
);
const numberOfAdmins$ = users$.pipe(count(user => user.source.user.level.valueOf() === 3));
const companyWithoutAdmin$ = users$.pipe(
// group by company
groupBy(user => user.source.company.id),
mergeMap(group =>
group.pipe(
// get users as array
// TODO: we may be able to do this without doing a toArray but just by playing with the group
toArray(),
// filter groups where there is no user with level === 3
filter(users => !users.some(user => user.source.user.level.valueOf() === 3)),
// take first element to get the company
map(users => users[0].source.company),
),
),
toArray(),
);
const companiesSubscription = companies$.subscribe({
next(company) {
progress$.next({
type: "company:created",
data: {
sourceId: company.source.id,
destinationCode: company.destination.code,
status: company.error ? "failure" : "success",
company,
error: company.error,
} as CompanyReport,
});
},
});
const usersSubscription = users$.subscribe({
next(user) {
progress$.next({
type: "user:created",
data: {
sourceId: user.source.user.id,
destinationId: user.destination.id,
destinationCompanyCode: user.destination.companyCode,
status: user.error ? "failure" : "success",
user,
error: user.error,
} as UserReport,
});
},
error(err: Error) {
console.error(err);
},
});
const updateOwners$ = owners$.pipe(
mergeMap(owner => this.updateUserRole(owner, "owner"), concurrent),
tap(user =>
progress$.next({
type: "user:updated",
data: {
sourceId: user.source.source.user.id,
destinationId: user.source.destination.id,
destinationCompanyCode: user.source.destination.companyCode,
status: user.result.error ? "failure" : "success",
user: user.source,
error: user.result.error,
} as UserReport,
}),
),
);
const process$ = forkJoin([users$, companies$, numberOfAdmins$, companyWithoutAdmin$]).pipe(
tap(result => {
progress$.next({ type: "log", message: `There are ${result[2]} admins` });
progress$.next({
type: "company:withoutadmin",
message: `Companies without admins ${result[3].length}`,
data: result[3],
});
progress$.next({ type: "processing:owner" });
}),
mergeMap(() => from(updateOwners$), concurrent),
);
const process = process$.subscribe({
complete() {
usersSubscription.unsubscribe();
companiesSubscription.unsubscribe();
process.unsubscribe();
progress$.complete();
},
});
return progress$;
}
private getStreams(
concurrent: number = 1,
context?: ExecutionContext,
): {
// hot companies observable
companies$: Observable<CompanyCreatedStreamObject>;
// hot users observable
users$: Observable<UserCreatedStreamObject>;
} {
const companies$ = this.getCompanies().pipe(
mergeMap(company => this.createCompany(company), concurrent),
// make it hot
share(),
);
const users$ = companies$.pipe(
filter(company => !company.error),
mergeMap(company =>
this.getUserIds(company.source).pipe(
map(user => ({
user,
company,
})),
),
),
mergeMap(
userInCompany => this.createUser(userInCompany.company.source, userInCompany.user, context),
concurrent,
),
// make it hot
share(),
);
return {
companies$,
users$,
};
}
private getUserIds(company: Company, paginable?: Paginable): Observable<CompanyUser> {
const pagination = new Pagination(
paginable?.page_token,
paginable?.limitStr,
paginable?.reversed,
);
return from(gr.services.companies.getUsers({ group_id: company.id }, pagination)).pipe(
mergeMap(companyUsers => {
const items$ = from(companyUsers.getEntities());
const next$ = companyUsers?.nextPage?.page_token
? this.getUserIds(company, companyUsers.nextPage)
: EMPTY;
return concat(items$, next$);
}),
distinct(user => user.user_id),
);
}
private getCompanies(paginable?: Paginable): Observable<Company> {
return from(gr.services.companies.getCompanies(paginable)).pipe(
mergeMap(companiesResult => {
const items$ = from(companiesResult.getEntities());
const next$ = companiesResult?.nextPage?.page_token
? this.getCompanies(companiesResult.nextPage)
: EMPTY;
return concat(items$, next$);
}),
distinct((company: Company) => company.id),
);
}
private async createCompany(company: Company): Promise<CompanyCreatedStreamObject> {
logger.debug("Creating company in the Console %s", company.displayName);
let createdCompany: CreatedConsoleCompany;
let error;
try {
createdCompany = await this.client.createCompany({
code: company.id,
displayName: company.displayName,
avatar: {
type: "url",
value: company.logo,
},
status: "active",
});
if (this.linkExternal) {
await this.createCompanyLink(company, createdCompany, this.consoleId);
}
} catch (err) {
logger.warn("Error while creating company %s", company.displayName);
error = err;
}
return {
source: company,
destination: {
code: createdCompany?.code,
},
error,
};
}
private async createUser(
company: Company,
companyUser: CompanyUser,
context: ExecutionContext,
): Promise<UserCreatedStreamObject> {
logger.debug("Creating user in console %o", companyUser.user_id);
let error: Error;
let result: CreatedConsoleUser;
try {
const user = await gr.services.users.get({ id: companyUser.user_id });
if (!user) {
throw new Error(`User ${companyUser.user_id} not found`);
}
const firstName =
user.first_name && user.first_name.trim().length ? user.first_name : user.email_canonical;
const lastName =
user.last_name && user.last_name.trim().length ? user.last_name : user.email_canonical;
const name = (firstName + " " + lastName).trim();
let role: "admin" | "guest" | "member" =
companyUser.level.valueOf() === 3 ? "admin" : "member";
if (role != "admin") {
if (companyUser.isExterne) {
role = "guest";
}
const workspacesUsers = await gr.services.workspaces.getAllForUser(
{ userId: companyUser.user_id },
{ id: company.id },
);
workspacesUsers.forEach(e => {
if (e.isExternal) {
role = "guest";
}
});
}
result = await this.client.addUserToCompany(
{ code: company.id },
{
email: user.email_canonical,
// console requires that firstname/lastname are defined and at least 1 chat long
firstName,
lastName,
name,
avatar: {
type: "url",
value: user.picture,
},
password: passwordGenerator.generate({
length: 10,
numbers: true,
}),
skipInvite: true,
role,
inviterEmail: "",
},
);
const companyUserRepository = await this.database.getRepository<CompanyUser>(
"group_user",
CompanyUser,
);
companyUser.role = role;
await companyUserRepository.save(companyUser, context);
if (this.linkExternal) {
await this.createUserLink(user, result, this.consoleId);
}
} catch (err) {
logger.warn("Error while creating the user %o", companyUser.user_id);
error = err;
}
return {
source: { user: companyUser, company },
destination: {
id: result?._id,
companyCode: company.id,
},
error,
};
}
private async createUserLink(
localUser: User,
remoteUser: CreatedConsoleUser,
serviceId: string,
): Promise<void> {
logger.debug("Creating user link for user %s", localUser.id);
if (this.dryRun) {
return;
}
await gr.services.externalUser.createExternalUser(
getExternalUserInstance({
service_id: serviceId,
external_id: remoteUser._id,
user_id: localUser.id,
}),
);
}
private async createCompanyLink(
localCompany: Company,
remoteCompany: CreatedConsoleCompany,
serviceId: string,
): Promise<void> {
if (this.dryRun) {
return;
}
await gr.services.externalUser.createExternalGroup(
getExternalGroupInstance({
service_id: serviceId,
company_id: localCompany.id,
external_id: remoteCompany.code,
}),
);
}
private updateUserRole(
user: UserCreatedStreamObject,
role: CompanyUserRole,
): Promise<{
source: UserCreatedStreamObject;
result: UpdateConsoleUserRole & { error?: Error };
}> {
logger.debug(
`Updating user role for user ${user.source.user.id} in company ${user.source.company.id}`,
);
return this.client
.updateUserRole({ code: user.destination.companyCode }, { id: user.destination.id, role })
.then(result => ({ source: user, result }))
.catch((err: Error) => {
return {
source: user,
result: {
id: user.destination.id,
role,
error: err,
},
};
});
}
}
@@ -1,5 +1,4 @@
import { DatabaseServiceAPI } from "../../core/platform/services/database/api";
import { MergeProcess } from "./processing/merge";
import { ConsoleOptions, ConsoleType, MergeProgress } from "./types";
import { ConsoleServiceClient } from "./client-interface";
import { ConsoleClientFactory } from "./client-factory";
@@ -24,7 +23,7 @@ export class ConsoleServiceImpl implements TwakeServiceProvider {
}
async init(): Promise<this> {
this.configuration = new Configuration("console");
this.configuration = new Configuration("general.accounts");
assert(this.configuration, "console configuration is missing");
const type = this.configuration.get("type") as ConsoleType;
assert(type, "console configuration type is not defined");
@@ -33,14 +32,12 @@ export class ConsoleServiceImpl implements TwakeServiceProvider {
this.consoleOptions = {
type: type,
new_console: s.new_console,
username: s.username,
password: s.password,
url: s.url,
hook: {
token: s.hook?.token,
public_key: s.hook?.public_key,
},
authority: s.authority,
client_id: s.client_id,
client_secret: s.client_secret,
audience: s.audience,
issuer: s.issuer,
redirect_uris: s.redirect_uris,
disable_account_creation: s.disable_account_creation,
};
@@ -50,24 +47,6 @@ export class ConsoleServiceImpl implements TwakeServiceProvider {
return this;
}
merge(
baseUrl: string,
concurrent: number = 1,
dryRun: boolean = false,
console: string = "console",
link: boolean = true,
client: string,
secret: string,
_context?: ExecutionContext,
): MergeProgress {
return new MergeProcess(this.services.database, dryRun, console, link, {
type: "remote",
username: client,
password: secret,
url: baseUrl,
} as ConsoleOptions).merge(concurrent);
}
getClient(): ConsoleServiceClient {
return ConsoleClientFactory.create(this);
}
@@ -126,14 +126,12 @@ export type ConsoleType = "remote" | "internal";
export type ConsoleOptions = {
type: ConsoleType;
new_console: boolean;
username: string;
password: string;
url: string;
hook: {
token: string;
public_key?: string;
};
authority: string;
client_id: string;
client_secret: string;
issuer: string;
audience: string;
redirect_uris: string[];
disable_account_creation: boolean;
};
@@ -231,7 +229,7 @@ export interface ConsoleExecutionContext extends ExecutionContext {
export interface AuthRequest {
email?: string;
password?: string;
remote_access_token?: string;
oidc_id_token?: string;
}
export interface AuthResponse {
@@ -31,8 +31,8 @@ export class ConsoleController {
}
async auth(request: FastifyRequest<{ Body: AuthRequest }>): Promise<AuthResponse> {
if (request.body.remote_access_token) {
return { access_token: await this.authByToken(request.body.remote_access_token) };
if (request.body.oidc_id_token) {
return { access_token: await this.authByToken(request.body.oidc_id_token) };
} else if (request.body.email && request.body.password) {
return { access_token: await this.authByPassword(request.body.email, request.body.password) };
} else {
@@ -283,12 +283,12 @@ export class ConsoleController {
});
}
private async authByToken(accessToken: string): Promise<AccessToken> {
private async authByToken(idToken: string): Promise<AccessToken> {
const client = gr.services.console.getClient();
const userDTO = await client.getUserByAccessToken(accessToken);
const userDTO = await client.getUserByAccessToken(idToken);
const user = await client.updateLocalUserFromConsole(userDTO._id);
if (!user) {
throw CrudException.notFound(`User details not found for access token ${accessToken}`);
throw CrudException.notFound(`User details not found for access token ${idToken}`);
}
return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, {
track: user?.preferences?.allow_tracking || false,
@@ -14,27 +14,7 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next)
const accessControl = async (
request: FastifyRequest<{ Body: ConsoleHookBody; Querystring: ConsoleHookQueryString }>,
) => {
if (gr.services.console.consoleType != "remote") {
throw fastify.httpErrors.notImplemented("Hook service is only for the remote console");
}
if (
(request.body.secret_key || request.query.secret_key) !==
gr.services.console.consoleOptions.hook.token
) {
throw fastify.httpErrors.forbidden("Wrong secret");
}
const publicKey = gr.services.console.consoleOptions.hook.public_key;
if (publicKey) {
const input = JSON.stringify({ content: request.body.content, type: request.body.type });
const signatureBase64 = request.body.signature;
const verifier = crypto.createVerify("RSA-SHA512").update(input);
if (!verifier.verify(publicKey, signatureBase64, "base64")) {
throw fastify.httpErrors.forbidden("Signature verification failed");
}
}
throw fastify.httpErrors.notImplemented("Hook service doesn't exist anymore");
};
fastify.route({
@@ -32,7 +32,7 @@ export const authenticationSchema = {
properties: {
email: { type: "string" },
password: { type: "string" },
remote_access_token: { type: "string" },
oidc_id_token: { type: "string" },
},
},
response: {
@@ -16,14 +16,15 @@ export type ServerConfiguration = {
help_url: string | null;
pricing_plan_url: string | null;
app_download_url: string | null;
app_grid: { logo: string; name: string; url: string }[];
mobile: {
mobile_redirect: string;
mobile_appstore: string;
mobile_googleplay: string;
};
accounts: {
type: "console" | "internal";
console: null | {
type: "remote" | "internal";
remote: null | {
authority: string;
client_id: string;
account_management_url: string;
@@ -24,11 +24,12 @@ const routes: FastifyPluginCallback<{ configuration: ServerConfiguration["config
"pricing_plan_url",
"mobile",
"app_download_url",
"app_grid",
),
accounts: {
type: accounts.type,
console: _.pick(
accounts.console,
remote: _.pick(
accounts.remote,
"authority",
"client_id",
"account_management_url",