From 86d24964796f860c0787ef41c53586c438a7cc84 Mon Sep 17 00:00:00 2001 From: Pranav C Date: Sun, 9 Apr 2023 15:06:05 +0530 Subject: [PATCH] feat: org users apis Signed-off-by: Pranav C --- packages/nocodb-nest/src/app.module.ts | 3 +- .../org-users/org-users.controller.spec.ts | 20 ++ .../modules/org-users/org-users.controller.ts | 146 ++++++++++ .../src/modules/org-users/org-users.module.ts | 9 + .../org-users/org-users.service.spec.ts | 18 ++ .../modules/org-users/org-users.service.ts | 269 ++++++++++++++++++ 6 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 packages/nocodb-nest/src/modules/org-users/org-users.controller.spec.ts create mode 100644 packages/nocodb-nest/src/modules/org-users/org-users.controller.ts create mode 100644 packages/nocodb-nest/src/modules/org-users/org-users.module.ts create mode 100644 packages/nocodb-nest/src/modules/org-users/org-users.service.spec.ts create mode 100644 packages/nocodb-nest/src/modules/org-users/org-users.service.ts diff --git a/packages/nocodb-nest/src/app.module.ts b/packages/nocodb-nest/src/app.module.ts index e58642c9e1..467aa47c34 100644 --- a/packages/nocodb-nest/src/app.module.ts +++ b/packages/nocodb-nest/src/app.module.ts @@ -32,9 +32,10 @@ import { ApiTokensModule } from './modules/api-tokens/api-tokens.module'; import { AttachmentsModule } from './modules/attachments/attachments.module'; import { OrgLcenseModule } from './modules/org-lcense/org-lcense.module'; import { OrgTokensModule } from './modules/org-tokens/org-tokens.module'; +import { OrgUsersModule } from './modules/org-users/org-users.module'; @Module({ - imports: [AuthModule, UsersModule, UtilsModule, ProjectsModule, TablesModule, ViewsModule, FiltersModule, SortsModule, ColumnsModule, ViewColumnsModule, BasesModule, HooksModule, SharedBasesModule, FormsModule, GridsModule, KanbansModule, GalleriesModule, FormColumnsModule, GridColumnsModule, MapsModule, ProjectUsersModule, ModelVisibilitiesModule, HookFiltersModule, ApiTokensModule, AttachmentsModule, OrgLcenseModule, OrgTokensModule], + imports: [AuthModule, UsersModule, UtilsModule, ProjectsModule, TablesModule, ViewsModule, FiltersModule, SortsModule, ColumnsModule, ViewColumnsModule, BasesModule, HooksModule, SharedBasesModule, FormsModule, GridsModule, KanbansModule, GalleriesModule, FormColumnsModule, GridColumnsModule, MapsModule, ProjectUsersModule, ModelVisibilitiesModule, HookFiltersModule, ApiTokensModule, AttachmentsModule, OrgLcenseModule, OrgTokensModule, OrgUsersModule], controllers: [], providers: [Connection, MetaService, JwtStrategy, ExtractProjectIdMiddleware], exports: [Connection, MetaService], diff --git a/packages/nocodb-nest/src/modules/org-users/org-users.controller.spec.ts b/packages/nocodb-nest/src/modules/org-users/org-users.controller.spec.ts new file mode 100644 index 0000000000..94f5d4e53f --- /dev/null +++ b/packages/nocodb-nest/src/modules/org-users/org-users.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OrgUsersController } from './org-users.controller'; +import { OrgUsersService } from './org-users.service'; + +describe('OrgUsersController', () => { + let controller: OrgUsersController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OrgUsersController], + providers: [OrgUsersService], + }).compile(); + + controller = module.get(OrgUsersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/nocodb-nest/src/modules/org-users/org-users.controller.ts b/packages/nocodb-nest/src/modules/org-users/org-users.controller.ts new file mode 100644 index 0000000000..a3abf00c29 --- /dev/null +++ b/packages/nocodb-nest/src/modules/org-users/org-users.controller.ts @@ -0,0 +1,146 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Request, +} from '@nestjs/common'; +import { OrgUserRoles } from 'nocodb-sdk'; +import { PagedResponseImpl } from '../../helpers/PagedResponse'; +import { Acl } from '../../middlewares/extract-project-id/extract-project-id.middleware'; +import { User } from '../../models'; +import { OrgUsersService } from './org-users.service'; + +@Controller('org-users') +export class OrgUsersController { + constructor(private readonly orgUsersService: OrgUsersService) {} + + @Get('/api/v1/users') + @Acl('userList', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async userList(@Request() req) { + return new PagedResponseImpl( + await this.orgUsersService.userList({ + query: req.query, + }), + { + ...req.query, + // todo: fix - wrong count + count: await User.count(req.query), + }, + ); + } + + @Patch('/api/v1/users/:userId') + @Acl('userUpdate', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async userUpdate(@Body() body, @Param('userId') userId: string) { + return; + await this.orgUsersService.userUpdate({ + user: body, + userId, + }); + } + + @Delete('/api/v1/users/:userId') + @Acl('userDelete', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async userDelete(@Param('userId') userId: string) { + await this.orgUsersService.userDelete({ + userId, + }); + return { msg: 'The user has been deleted successfully' }; + } + + @Post('/api/v1/users') + @Acl('userAdd', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async userAdd( + @Body() body, + @Request() req, + @Param('projectId') projectId: string, + ) { + const result = await this.orgUsersService.userAdd({ + user: req.body, + req, + projectId, + }); + + return result; + } + + @Post('/api/v1/users/settings') + @Acl('userSettings', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async userSettings(@Body() body): Promise { + await this.orgUsersService.userSettings(body); + return {}; + } + + @Post('/api/v1/users/:userId/resend-invite') + @Acl('userInviteResend', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async userInviteResend( + @Request() req, + @Param('userId') userId: string, + ): Promise { + await this.orgUsersService.userInviteResend({ + userId, + req, + }); + + return { msg: 'The invitation has been sent to the user' }; + } + + @Post('/api/v1/users/:userId/generate-reset-url') + @Acl('generateResetUrl', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async generateResetUrl(@Request() req, @Param('userId') userId: string) { + const result = await this.orgUsersService.generateResetUrl({ + siteUrl: req.ncSiteUrl, + userId, + }); + + return result; + } + + @Get('/api/v1/app-settings') + @Acl('appSettingsGet', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async appSettingsGet() { + const settings = await this.orgUsersService.appSettingsGet(); + return settings; + } + + @Post('/api/v1/app-settings') + @Acl('appSettingsSet', { + allowedRoles: [OrgUserRoles.SUPER_ADMIN], + blockApiTokenAccess: true, + }) + async appSettingsSet(req, res) { + await this.orgUsersService.appSettingsSet({ + settings: req.body, + }); + + res.json({ msg: 'The app settings have been saved' }); + } +} diff --git a/packages/nocodb-nest/src/modules/org-users/org-users.module.ts b/packages/nocodb-nest/src/modules/org-users/org-users.module.ts new file mode 100644 index 0000000000..afee64421b --- /dev/null +++ b/packages/nocodb-nest/src/modules/org-users/org-users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { OrgUsersService } from './org-users.service'; +import { OrgUsersController } from './org-users.controller'; + +@Module({ + controllers: [OrgUsersController], + providers: [OrgUsersService] +}) +export class OrgUsersModule {} diff --git a/packages/nocodb-nest/src/modules/org-users/org-users.service.spec.ts b/packages/nocodb-nest/src/modules/org-users/org-users.service.spec.ts new file mode 100644 index 0000000000..35f53132b1 --- /dev/null +++ b/packages/nocodb-nest/src/modules/org-users/org-users.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OrgUsersService } from './org-users.service'; + +describe('OrgUsersService', () => { + let service: OrgUsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OrgUsersService], + }).compile(); + + service = module.get(OrgUsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/nocodb-nest/src/modules/org-users/org-users.service.ts b/packages/nocodb-nest/src/modules/org-users/org-users.service.ts new file mode 100644 index 0000000000..3c4a3e07a1 --- /dev/null +++ b/packages/nocodb-nest/src/modules/org-users/org-users.service.ts @@ -0,0 +1,269 @@ +import { Injectable } from '@nestjs/common'; +import { + AuditOperationSubTypes, + AuditOperationTypes, + OrgUserRoles, + PluginCategory, + UserType, +} from 'nocodb-sdk'; +import { sendInviteEmail } from '../../../../nocodb/src/lib/services/projectUser.svc'; +import { NC_APP_SETTINGS } from '../../constants'; +import { validatePayload } from '../../helpers'; +import { NcError } from '../../helpers/catchError'; +import { extractProps } from '../../helpers/extractProps'; +import { randomTokenString } from '../../helpers/stringHelpers'; +import { Audit, ProjectUser, Store, SyncSource, User } from '../../models'; +import validator from 'validator'; + +import { v4 as uuidv4 } from 'uuid'; +import Noco from '../../Noco'; +import { MetaTable } from '../../utils/globals'; +import { T } from 'nc-help'; + +@Injectable() +export class OrgUsersService { + async userList(param: { + // todo: add better typing + query: Record; + }) { + return await User.list(param.query); + } + + async userUpdate(param: { + // todo: better typing + user: Partial; + userId: string; + }) { + validatePayload('swagger.json#/components/schemas/OrgUserReq', param.user); + + const updateBody = extractProps(param.user, ['roles']); + + const user = await User.get(param.userId); + + if (user.roles.includes(OrgUserRoles.SUPER_ADMIN)) { + NcError.badRequest('Cannot update super admin roles'); + } + + return await User.update(param.userId, { + ...updateBody, + token_version: null, + }); + } + + async userDelete(param: { userId: string }) { + const ncMeta = await Noco.ncMeta.startTransaction(); + try { + const user = await User.get(param.userId, ncMeta); + + if (user.roles.includes(OrgUserRoles.SUPER_ADMIN)) { + NcError.badRequest('Cannot delete super admin'); + } + + // delete project user entry and assign to super admin + const projectUsers = await ProjectUser.getProjectsIdList( + param.userId, + ncMeta, + ); + + // todo: clear cache + + // TODO: assign super admin as project owner + for (const projectUser of projectUsers) { + await ProjectUser.delete( + projectUser.project_id, + projectUser.fk_user_id, + ncMeta, + ); + } + + // delete sync source entry + await SyncSource.deleteByUserId(param.userId, ncMeta); + + // delete user + await User.delete(param.userId, ncMeta); + await ncMeta.commit(); + } catch (e) { + await ncMeta.rollback(e); + throw e; + } + + return true; + } + + async userAdd(param: { + user: UserType; + projectId: string; + // todo: refactor + req: any; + }) { + validatePayload('swagger.json#/components/schemas/OrgUserReq', param.user); + + // allow only viewer or creator role + if ( + param.user.roles && + ![OrgUserRoles.VIEWER, OrgUserRoles.CREATOR].includes( + param.user.roles as OrgUserRoles, + ) + ) { + NcError.badRequest('Invalid role'); + } + + // extract emails from request body + const emails = (param.user.email || '') + .toLowerCase() + .split(/\s*,\s*/) + .map((v) => v.trim()); + + // check for invalid emails + const invalidEmails = emails.filter((v) => !validator.isEmail(v)); + + if (!emails.length) { + return NcError.badRequest('Invalid email address'); + } + if (invalidEmails.length) { + NcError.badRequest('Invalid email address : ' + invalidEmails.join(', ')); + } + + const invite_token = uuidv4(); + const error = []; + + for (const email of emails) { + // add user to project if user already exist + const user = await User.getByEmail(email); + + if (user) { + NcError.badRequest('User already exist'); + } else { + try { + // create new user with invite token + await User.insert({ + invite_token, + invite_token_expires: new Date(Date.now() + 24 * 60 * 60 * 1000), + email, + roles: param.user.roles || OrgUserRoles.VIEWER, + token_version: randomTokenString(), + }); + + const count = await User.count(); + T.emit('evt', { evt_type: 'org:user:invite', count }); + + await Audit.insert({ + op_type: AuditOperationTypes.ORG_USER, + op_sub_type: AuditOperationSubTypes.INVITE, + user: param.req.user.email, + description: `invited ${email} to ${param.projectId} project `, + ip: param.req.clientIp, + }); + // in case of single user check for smtp failure + // and send back token if failed + if ( + emails.length === 1 && + !(await sendInviteEmail(email, invite_token, param.req)) + ) { + return { invite_token, email }; + } else { + sendInviteEmail(email, invite_token, param.req); + } + } catch (e) { + console.log(e); + if (emails.length === 1) { + throw e; + } else { + error.push({ email, error: e.message }); + } + } + } + } + + if (emails.length === 1) { + return { + msg: 'success', + }; + } else { + return { invite_token, emails, error }; + } + } + + async userSettings(_param): Promise { + NcError.notImplemented(); + } + + async userInviteResend(param: { userId: string; req: any }): Promise { + const user = await User.get(param.userId); + + if (!user) { + NcError.badRequest(`User with id '${param.userId}' not found`); + } + + const invite_token = uuidv4(); + + await User.update(user.id, { + invite_token, + invite_token_expires: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + const pluginData = await Noco.ncMeta.metaGet2( + null, + null, + MetaTable.PLUGIN, + { + category: PluginCategory.EMAIL, + active: true, + }, + ); + + if (!pluginData) { + NcError.badRequest( + `No Email Plugin is found. Please go to App Store to configure first or copy the invitation URL to users instead.`, + ); + } + + await sendInviteEmail(user.email, invite_token, param.req); + + await Audit.insert({ + op_type: AuditOperationTypes.ORG_USER, + op_sub_type: AuditOperationSubTypes.RESEND_INVITE, + user: user.email, + description: `resent a invite to ${user.email} `, + ip: param.req.clientIp, + }); + + return true; + } + + async generateResetUrl(param: { userId: string; siteUrl: string }) { + const user = await User.get(param.userId); + + if (!user) { + NcError.badRequest(`User with id '${param.userId}' not found`); + } + const token = uuidv4(); + await User.update(user.id, { + email: user.email, + reset_password_token: token, + reset_password_expires: new Date(Date.now() + 60 * 60 * 1000), + token_version: null, + }); + + return { + reset_password_token: token, + reset_password_url: param.siteUrl + `/auth/password/reset/${token}`, + }; + } + + async appSettingsGet() { + let settings = {}; + try { + settings = JSON.parse((await Store.get(NC_APP_SETTINGS))?.value); + } catch {} + return settings; + } + + async appSettingsSet(param: { settings: any }) { + await Store.saveOrUpdate({ + value: JSON.stringify(param.settings), + key: NC_APP_SETTINGS, + }); + return true; + } +}