Skip to content

Commit

Permalink
feat: organization permission (#1208)
Browse files Browse the repository at this point in the history
* feat: organzaition memeber selectors

* feat: update collaborator migration

* fix: update mirgration

* fix: member selector

* refactor: search and pagination in collaborator endpoint

* feat: remove collaborator foreign key user

* feat: add collaborator endpoint

* fix: user cell convert

* fix: member selector users list

* feat: comment and user editor support adapting to new interface

* refactor: department list remove unnecessary merge tree node

* fix: share e2e

* fix: sheet view coll query type

* fix: some typescript check

* fix: typescript error

* feat: search compatible sqlite

* fix: comment reaction

* feat: collaborator include department

* fix: collabortors resource

* fix: department collabortors update and delete

* feat: add collaborator entity validation before assistance

* feat: add support for encoded URI paths as object keys

* fix: comment full image url

* fix: create space collaborator

* fix: invitation service unit test

* feat: custom member selector dialog header

* chore: remove useless code

* fix: import date timezone

* test: wating event apply

* fix: permission service unit test

* fix: departments collaborators permisison

* chore: remove useless import code

* feat: the first registered user will be the admin

* feat: update organization panel icons

* chore: update empty state text for department selector
  • Loading branch information
boris-w authored Jan 10, 2025
1 parent aa6ccf8 commit 3a58f8f
Show file tree
Hide file tree
Showing 132 changed files with 4,039 additions and 1,028 deletions.
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/db-provider/db.provider.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,6 @@ export interface IDbProvider {
lookupOptionsQuery(optionsKey: keyof ILookupOptionsVo, value: string): string;

optionsQuery(type: FieldType, optionsKey: string, value: string): string;

searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder;
}
1 change: 0 additions & 1 deletion apps/nestjs-backend/src/db-provider/db.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export const DbProvider: Provider = {
provide: DB_PROVIDER_SYMBOL,
useFactory: (knex: Knex) => {
const driverClient = getDriverName(knex);

switch (driverClient) {
case DriverClient.Sqlite:
return new SqliteProvider(knex);
Expand Down
8 changes: 8 additions & 0 deletions apps/nestjs-backend/src/db-provider/postgres.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,4 +460,12 @@ export class PostgresProvider implements IDbProvider {
.where('type', type)
.toQuery();
}

searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder {
return qb.where((builder) => {
search.forEach(([field, value]) => {
builder.orWhere(field, 'ilike', `%${value}%`);
});
});
}
}
8 changes: 8 additions & 0 deletions apps/nestjs-backend/src/db-provider/sqlite.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,12 @@ export class SqliteProvider implements IDbProvider {
.whereRaw(`json_extract(options, '$."${optionsKey}"') = ?`, [value])
.toQuery();
}

searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder {
return qb.where((builder) => {
search.forEach(([field, value]) => {
builder.orWhereRaw('LOWER(??) LIKE LOWER(?)', [field, `%${value}%`]);
});
});
}
}
10 changes: 8 additions & 2 deletions apps/nestjs-backend/src/features/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import type { IUserMeVo } from '@teable/openapi';
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { AUTH_SESSION_COOKIE_NAME } from '../../const';
import type { IClsStore } from '../../types/cls';
import { AuthService } from './auth.service';
import { TokenAccess } from './decorators/token.decorator';
import { SessionService } from './session/session.service';
Expand All @@ -10,7 +12,8 @@ import { SessionService } from './session/session.service';
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly sessionService: SessionService
private readonly sessionService: SessionService,
private readonly cls: ClsService<IClsStore>
) {}

@Post('signout')
Expand All @@ -22,7 +25,10 @@ export class AuthController {

@Get('/user/me')
async me(@Req() request: Express.Request) {
return request.user;
return {
...request.user,
organization: this.cls.get('organization'),
};
}

@Get('/user')
Expand Down
13 changes: 6 additions & 7 deletions apps/nestjs-backend/src/features/auth/permission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,32 +43,31 @@ describe('PermissionService', () => {
it('should return a SpaceRole', async () => {
const spaceId = 'space-id';
const roleName = 'space-role';
prismaServiceMock.collaborator.findFirst.mockResolvedValue({ roleName } as any);
prismaServiceMock.collaborator.findMany.mockResolvedValue([{ roleName } as any]);
const result = await service['getRoleBySpaceId'](spaceId);
expect(result).toBe(roleName);
});

it('should throw a ForbiddenException if collaborator is not found', async () => {
const spaceId = 'space-id';
prismaServiceMock.collaborator.findFirst.mockResolvedValue(null);
await expect(service['getRoleBySpaceId'](spaceId)).rejects.toThrowError(
new ForbiddenException(`you have no permission to access this space`)
);
prismaServiceMock.collaborator.findMany.mockResolvedValue([]);
const res = await service['getRoleBySpaceId'](spaceId);
expect(res).toBeNull();
});
});

describe('getRoleByBaseId', () => {
it('should return a BaseRole', async () => {
const baseId = 'base-id';
const roleName = 'base-role';
prismaServiceMock.collaborator.findFirst.mockResolvedValue({ roleName } as any);
prismaServiceMock.collaborator.findMany.mockResolvedValue([{ roleName } as any]);
const result = await service['getRoleByBaseId'](baseId);
expect(result).toBe(roleName);
});

it('should return null if collaborator is not found', async () => {
const baseId = 'base-id';
prismaServiceMock.collaborator.findFirst.mockResolvedValue(null);
prismaServiceMock.collaborator.findMany.mockResolvedValue([]);
const result = await service['getRoleByBaseId'](baseId);
expect(result).toBeNull();
});
Expand Down
54 changes: 35 additions & 19 deletions apps/nestjs-backend/src/features/auth/permission.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ForbiddenException, NotFoundException, Injectable } from '@nestjs/common';
import type { IBaseRole, Action, IRole } from '@teable/core';
import type { IBaseRole, Action } from '@teable/core';
import { IdPrefix, getPermissions } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { CollaboratorType } from '@teable/openapi';
import { intersection } from 'lodash';
import { intersection, union } from 'lodash';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../types/cls';
import { getMaxLevelRole } from '../../utils/get-max-level-role';

@Injectable()
export class PermissionService {
Expand All @@ -14,51 +15,58 @@ export class PermissionService {
private readonly cls: ClsService<IClsStore>
) {}

private getDepartmentIds() {
const departments = this.cls.get('organization.departments');
return departments?.map((department) => department.id) || [];
}

async getRoleBySpaceId(spaceId: string) {
const userId = this.cls.get('user.id');

const collaborator = await this.prismaService.collaborator.findFirst({
const departmentIds = this.getDepartmentIds();
const collaborators = await this.prismaService.collaborator.findMany({
where: {
userId,
principalId: { in: [...departmentIds, userId] },
resourceId: spaceId,
resourceType: CollaboratorType.Space,
},
select: { roleName: true },
});
if (!collaborator) {
throw new ForbiddenException(`you have no permission to access this space`);
if (!collaborators.length) {
return null;
}
return collaborator.roleName as IRole;
return getMaxLevelRole(collaborators);
}

async getRoleByBaseId(baseId: string) {
const departmentIds = this.getDepartmentIds();
const userId = this.cls.get('user.id');

const collaborator = await this.prismaService.collaborator.findFirst({
const collaborators = await this.prismaService.collaborator.findMany({
where: {
userId,
principalId: { in: [...departmentIds, userId] },
resourceId: baseId,
resourceType: CollaboratorType.Base,
},
select: { roleName: true },
});
if (!collaborator) {
if (!collaborators.length) {
return null;
}
return collaborator.roleName as IBaseRole;
return getMaxLevelRole(collaborators) as IBaseRole;
}

async getOAuthAccessBy(userId: string) {
const collaborator = await this.prismaService.txClient().collaborator.findMany({
const departmentIds = this.getDepartmentIds();
const collaborators = await this.prismaService.txClient().collaborator.findMany({
where: {
userId,
principalId: { in: [...departmentIds, userId] },
},
select: { roleName: true, resourceId: true, resourceType: true },
});

const spaceIds: string[] = [];
const baseIds: string[] = [];
collaborator.forEach(({ resourceId, resourceType }) => {
collaborators.forEach(({ resourceId, resourceType }) => {
if (resourceType === CollaboratorType.Base) {
baseIds.push(resourceId);
} else if (resourceType === CollaboratorType.Space) {
Expand Down Expand Up @@ -205,17 +213,25 @@ export class PermissionService {

private async getPermissionBySpaceId(spaceId: string) {
const role = await this.getRoleBySpaceId(spaceId);
if (!role) {
throw new ForbiddenException(`you have no permission to access this space`);
}
return getPermissions(role);
}

private async getPermissionByBaseId(baseId: string, includeInactiveResource?: boolean) {
const role = await this.getRoleByBaseId(baseId);
if (role) {
return getPermissions(role);
}
return this.getPermissionBySpaceId(
const spaceRole = await this.getRoleBySpaceId(
(await this.getUpperIdByBaseId(baseId, includeInactiveResource)).spaceId
);
if (!role && !spaceRole) {
throw new ForbiddenException(`you have no permission to access this base`);
}
const basePermissions = role ? getPermissions(role) : [];
const spacePermissions = spaceRole ? getPermissions(spaceRole) : [];
// In the presence of an organization, a user can have concurrent permissions at both space and base levels,
// requiring a merge operation to determine the highest applicable permission level
return union(basePermissions, spacePermissions);
}

private async getPermissionByTableId(tableId: string, includeInactiveResource?: boolean) {
Expand Down
26 changes: 21 additions & 5 deletions apps/nestjs-backend/src/features/base/base.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
CollaboratorType,
listBaseCollaboratorRoSchema,
ListBaseCollaboratorRo,
deleteBaseCollaboratorRoSchema,
DeleteBaseCollaboratorRo,
addBaseCollaboratorRoSchema,
AddBaseCollaboratorRo,
} from '@teable/openapi';
import type {
CreateBaseInvitationLinkVo,
Expand Down Expand Up @@ -163,7 +167,10 @@ export class BaseController {
@Param('baseId') baseId: string,
@Query(new ZodValidationPipe(listBaseCollaboratorRoSchema)) options: ListBaseCollaboratorRo
): Promise<ListBaseCollaboratorVo> {
return await this.collaboratorService.getListByBase(baseId, options);
return {
collaborators: await this.collaboratorService.getListByBase(baseId, options),
total: await this.collaboratorService.getTotalBase(baseId, options),
};
}

@Permissions('base|read')
Expand Down Expand Up @@ -259,25 +266,34 @@ export class BaseController {
await this.collaboratorService.updateCollaborator({
resourceId: baseId,
resourceType: CollaboratorType.Base,
userId: updateBaseCollaborateRo.userId,
role: updateBaseCollaborateRo.role,
...updateBaseCollaborateRo,
});
}

@Delete(':baseId/collaborators')
async deleteCollaborator(
@Param('baseId') baseId: string,
@Query('userId') userId: string
@Query(new ZodValidationPipe(deleteBaseCollaboratorRoSchema))
deleteBaseCollaboratorRo: DeleteBaseCollaboratorRo
): Promise<void> {
await this.collaboratorService.deleteCollaborator({
resourceId: baseId,
resourceType: CollaboratorType.Base,
userId,
...deleteBaseCollaboratorRo,
});
}

@Delete(':baseId/permanent')
async permanentDeleteBase(@Param('baseId') baseId: string) {
return await this.baseService.permanentDeleteBase(baseId);
}

@Post(':baseId/collaborator')
async addCollaborators(
@Param('baseId') baseId: string,
@Body(new ZodValidationPipe(addBaseCollaboratorRoSchema))
addBaseCollaboratorRo: AddBaseCollaboratorRo
) {
return await this.collaboratorService.addBaseCollaborators(baseId, addBaseCollaboratorRo);
}
}
33 changes: 16 additions & 17 deletions apps/nestjs-backend/src/features/base/base.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { IRole } from '@teable/core';
import { ActionPrefix, actionPrefixMap, generateBaseId, isUnrestrictedRole } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { CollaboratorType, ResourceType } from '@teable/openapi';
Expand All @@ -16,6 +15,7 @@ import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.confi
import { InjectDbProvider } from '../../db-provider/db.provider';
import { IDbProvider } from '../../db-provider/db.provider.interface';
import type { IClsStore } from '../../types/cls';
import { getMaxLevelRole } from '../../utils/get-max-level-role';
import { updateOrder } from '../../utils/update-order';
import { PermissionService } from '../auth/permission.service';
import { CollaboratorService } from '../collaborator/collaborator.service';
Expand All @@ -39,7 +39,7 @@ export class BaseService {

async getBaseById(baseId: string) {
const userId = this.cls.get('user.id');

const departmentIds = this.cls.get('organization.departments')?.map((d) => d.id);
const base = await this.prismaService.base
.findFirstOrThrow({
select: {
Expand All @@ -56,30 +56,29 @@ export class BaseService {
.catch(() => {
throw new NotFoundException('Base not found');
});
const collaborator = await this.prismaService.collaborator
.findFirstOrThrow({
where: {
resourceId: { in: [baseId, base.spaceId] },
userId,
},
})
.catch(() => {
throw new ForbiddenException('cannot access base');
});
const collaborators = await this.prismaService.collaborator.findMany({
where: {
resourceId: { in: [baseId, base.spaceId] },
principalId: { in: [userId, ...(departmentIds || [])] },
},
});

const role = collaborator.roleName as IRole;
if (!collaborators.length) {
throw new ForbiddenException('cannot access base');
}
const role = getMaxLevelRole(collaborators);
const collaborator = collaborators.find((c) => c.roleName === role);
return {
...base,
role: role,
collaboratorType: collaborator.resourceType as CollaboratorType,
collaboratorType: collaborator?.resourceType as CollaboratorType,
isUnrestricted: isUnrestrictedRole(role),
};
}

async getAllBaseList() {
const userId = this.cls.get('user.id');
const { spaceIds, baseIds, roleMap } =
await this.collaboratorService.getCollaboratorsBaseAndSpaceArray(userId);
await this.collaboratorService.getCurrentUserCollaboratorsBaseAndSpaceArray();
const baseList = await this.prismaService.base.findMany({
select: {
id: true,
Expand Down Expand Up @@ -112,7 +111,7 @@ export class BaseService {
const userId = this.cls.get('user.id');
const accessTokenId = this.cls.get('accessTokenId');
const { spaceIds, baseIds } =
await this.collaboratorService.getCollaboratorsBaseAndSpaceArray(userId);
await this.collaboratorService.getCurrentUserCollaboratorsBaseAndSpaceArray();

if (accessTokenId) {
const access = await this.prismaService.accessToken.findFirst({
Expand Down
Loading

0 comments on commit 3a58f8f

Please sign in to comment.