From c21274c86f56d8cf09deee87a99be19d8d2bbbc1 Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Sun, 28 Dec 2025 21:31:02 +0100 Subject: [PATCH] WIP - record sharing --- backend/src/rbac/rbac.module.ts | 3 +- backend/src/rbac/share.controller.ts | 284 +++++++------ backend/src/rbac/user.controller.ts | 41 ++ frontend/components/RecordShareManager.vue | 373 ++++++++++++++++++ .../[objectName]/[[recordId]]/[[view]].vue | 79 +++- 5 files changed, 647 insertions(+), 133 deletions(-) create mode 100644 backend/src/rbac/user.controller.ts create mode 100644 frontend/components/RecordShareManager.vue diff --git a/backend/src/rbac/rbac.module.ts b/backend/src/rbac/rbac.module.ts index 4aa2334..edea214 100644 --- a/backend/src/rbac/rbac.module.ts +++ b/backend/src/rbac/rbac.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { RbacService } from './rbac.service'; import { ShareController } from './share.controller'; import { RoleController, RoleRuleController } from './role.controller'; +import { UserController } from './user.controller'; import { TenantModule } from '../tenant/tenant.module'; @Module({ imports: [TenantModule], providers: [RbacService], - controllers: [ShareController, RoleController, RoleRuleController], + controllers: [ShareController, RoleController, RoleRuleController, UserController], exports: [RbacService], }) export class RbacModule {} diff --git a/backend/src/rbac/share.controller.ts b/backend/src/rbac/share.controller.ts index 83ff076..374339e 100644 --- a/backend/src/rbac/share.controller.ts +++ b/backend/src/rbac/share.controller.ts @@ -8,34 +8,59 @@ import { Param, Query, UseGuards, - Inject, + ForbiddenException, + NotFoundException, } from '@nestjs/common'; +import { IsString, IsArray, IsOptional, IsDateString } from 'class-validator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { CurrentUser } from '../auth/decorators/auth.decorators'; -import { User } from '../models/user.model'; -import { RecordShare } from '../models/record-share.model'; -import { ObjectDefinition } from '../models/object-definition.model'; -import { Knex } from 'knex'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { TenantId } from '../tenant/tenant.decorator'; +import { TenantDatabaseService } from '../tenant/tenant-database.service'; export class CreateShareDto { - objectDefinitionId: string; + @IsString() + objectApiName: string; + + @IsString() recordId: string; + + @IsString() granteeUserId: string; + + @IsArray() + @IsString({ each: true }) actions: string[]; // ["read"], ["read", "update"], etc. + + @IsOptional() + @IsArray() + @IsString({ each: true }) fields?: string[]; // Optional field scoping - expiresAt?: Date; + + @IsOptional() + @IsDateString() + expiresAt?: string; } export class UpdateShareDto { + @IsOptional() + @IsArray() + @IsString({ each: true }) actions?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) fields?: string[]; - expiresAt?: Date; + + @IsOptional() + @IsDateString() + expiresAt?: string; } -@Controller('shares') +@Controller('rbac/shares') @UseGuards(JwtAuthGuard) export class ShareController { - constructor(@Inject('KnexConnection') private readonly knex: Knex) {} + constructor(private tenantDbService: TenantDatabaseService) {} /** * Create a new share @@ -43,156 +68,175 @@ export class ShareController { */ @Post() async create( - @CurrentUser() user: User, + @TenantId() tenantId: string, + @CurrentUser() currentUser: any, @Body() createDto: CreateShareDto, ) { - // Verify the user owns the record or has permission to share - const objectDef = await ObjectDefinition.query(this.knex) - .findById(createDto.objectDefinitionId); + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get object definition by apiName + const objectDef = await knex('object_definitions') + .where({ apiName: createDto.objectApiName }) + .first(); if (!objectDef) { - throw new Error('Object definition not found'); + throw new NotFoundException('Object definition not found'); } - // TODO: Verify ownership or share permission via CASL - // For now, we'll assume authorized + // Get the table name for the object + const tableName = this.getTableName(createDto.objectApiName); - const share = await RecordShare.query(this.knex).insert({ - objectDefinitionId: createDto.objectDefinitionId, - recordId: createDto.recordId, - granteeUserId: createDto.granteeUserId, - grantedByUserId: user.id, + // Verify the user owns the record + const record = await knex(tableName) + .where({ id: createDto.recordId }) + .first(); + + if (!record) { + throw new NotFoundException('Record not found'); + } + + if (record.ownerId !== currentUser.userId) { + throw new ForbiddenException('Only the record owner can share it'); + } + + // Create the share + const shareId = require('crypto').randomUUID(); + await knex('record_shares').insert({ + id: shareId, + object_definition_id: objectDef.id, + record_id: createDto.recordId, + grantee_user_id: createDto.granteeUserId, + granted_by_user_id: currentUser.userId, actions: JSON.stringify(createDto.actions), fields: createDto.fields ? JSON.stringify(createDto.fields) : null, - expiresAt: createDto.expiresAt, + expires_at: createDto.expiresAt, + created_at: knex.fn.now(), }); - return share; + const share = await knex('record_shares').where({ id: shareId }).first(); + + return { + ...share, + actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions, + fields: share.fields ? (typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields) : null, + }; + } + + private getTableName(objectApiName: string): string { + const snakeCase = objectApiName + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); + + if (snakeCase.endsWith('y')) { + return snakeCase.slice(0, -1) + 'ies'; + } else if (snakeCase.endsWith('s')) { + return snakeCase; + } else { + return snakeCase + 's'; + } } /** * List shares for a specific record * Only owner or users with access can see shares */ - @Get('record/:objectDefinitionId/:recordId') + @Get(':objectApiName/:recordId') async listForRecord( - @CurrentUser() user: User, - @Param('objectDefinitionId') objectDefinitionId: string, + @TenantId() tenantId: string, + @CurrentUser() currentUser: any, + @Param('objectApiName') objectApiName: string, @Param('recordId') recordId: string, ) { - // TODO: Verify user has access to this record + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - const shares = await RecordShare.query(this.knex) + // Get object definition + const objectDef = await knex('object_definitions') + .where({ apiName: objectApiName }) + .first(); + + if (!objectDef) { + throw new NotFoundException('Object definition not found'); + } + + // Get shares for this record + const shares = await knex('record_shares') .where({ - objectDefinitionId, - recordId, + object_definition_id: objectDef.id, + record_id: recordId, }) - .whereNull('revokedAt') - .withGraphFetched('[granteeUser, grantedByUser]'); + .whereNull('revoked_at') + .select('*'); - return shares.map((share) => ({ - ...share, - actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions, - fields: share.fields && typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields, - })); - } + // Fetch user details for each share + const sharesWithUsers = await Promise.all( + shares.map(async (share: any) => { + const granteeUser = await knex('users') + .where({ id: share.grantee_user_id }) + .select('id', 'email', 'firstName', 'lastName', 'name') + .first(); - /** - * List shares granted by current user - */ - @Get('granted') - async listGranted(@CurrentUser() user: User) { - const shares = await RecordShare.query(this.knex) - .where('grantedByUserId', user.id) - .whereNull('revokedAt') - .withGraphFetched('[granteeUser, objectDefinition]'); + const grantedByUser = await knex('users') + .where({ id: share.granted_by_user_id }) + .select('id', 'email', 'firstName', 'lastName', 'name') + .first(); - return shares.map((share) => ({ - ...share, - actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions, - fields: share.fields && typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields, - })); - } - - /** - * List shares received by current user - */ - @Get('received') - async listReceived(@CurrentUser() user: User) { - const shares = await RecordShare.query(this.knex) - .where('granteeUserId', user.id) - .whereNull('revokedAt') - .where(function () { - this.whereNull('expiresAt').orWhere('expiresAt', '>', new Date()); + return { + id: share.id, + recordId: share.record_id, + actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions, + fields: share.fields ? (typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields) : null, + expiresAt: share.expires_at, + createdAt: share.created_at, + granteeUser: { + id: granteeUser.id, + email: granteeUser.email, + name: granteeUser.firstName && granteeUser.lastName + ? `${granteeUser.firstName} ${granteeUser.lastName}` + : granteeUser.name || granteeUser.email, + }, + grantedByUser: { + id: grantedByUser.id, + email: grantedByUser.email, + name: grantedByUser.firstName && grantedByUser.lastName + ? `${grantedByUser.firstName} ${grantedByUser.lastName}` + : grantedByUser.name || grantedByUser.email, + }, + }; }) - .withGraphFetched('[grantedByUser, objectDefinition]'); + ); - return shares.map((share) => ({ - ...share, - actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions, - fields: share.fields && typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields, - })); - } - - /** - * Update a share - */ - @Patch(':id') - async update( - @CurrentUser() user: User, - @Param('id') id: string, - @Body() updateDto: UpdateShareDto, - ) { - const share = await RecordShare.query(this.knex).findById(id); - - if (!share) { - throw new Error('Share not found'); - } - - // Only the grantor can update - if (share.grantedByUserId !== user.id) { - throw new Error('Unauthorized'); - } - - const updates: any = {}; - if (updateDto.actions) { - updates.actions = JSON.stringify(updateDto.actions); - } - if (updateDto.fields !== undefined) { - updates.fields = updateDto.fields ? JSON.stringify(updateDto.fields) : null; - } - if (updateDto.expiresAt !== undefined) { - updates.expiresAt = updateDto.expiresAt; - } - - const updated = await RecordShare.query(this.knex) - .patchAndFetchById(id, updates); - - return { - ...updated, - actions: typeof updated.actions === 'string' ? JSON.parse(updated.actions) : updated.actions, - fields: updated.fields && typeof updated.fields === 'string' ? JSON.parse(updated.fields) : updated.fields, - }; + return sharesWithUsers; } /** * Revoke a share (soft delete) */ @Delete(':id') - async revoke(@CurrentUser() user: User, @Param('id') id: string) { - const share = await RecordShare.query(this.knex).findById(id); + async revoke( + @TenantId() tenantId: string, + @CurrentUser() currentUser: any, + @Param('id') id: string, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + const share = await knex('record_shares').where({ id }).first(); if (!share) { - throw new Error('Share not found'); + throw new NotFoundException('Share not found'); } // Only the grantor can revoke - if (share.grantedByUserId !== user.id) { - throw new Error('Unauthorized'); + if (share.granted_by_user_id !== currentUser.userId) { + throw new ForbiddenException('Unauthorized'); } - await RecordShare.query(this.knex) - .patchAndFetchById(id, { revokedAt: new Date() }); + await knex('record_shares') + .where({ id }) + .update({ revoked_at: knex.fn.now() }); return { success: true }; } diff --git a/backend/src/rbac/user.controller.ts b/backend/src/rbac/user.controller.ts new file mode 100644 index 0000000..d54fed8 --- /dev/null +++ b/backend/src/rbac/user.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { TenantId } from '../tenant/tenant.decorator'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { TenantDatabaseService } from '../tenant/tenant-database.service'; +import { User } from '../models/user.model'; + +@Controller('rbac/users') +@UseGuards(JwtAuthGuard) +export class UserController { + constructor(private tenantDbService: TenantDatabaseService) {} + + @Get() + async getUsers( + @TenantId() tenantId: string, + @CurrentUser() currentUser: any, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get all active users from tenant database (excluding current user) + let query = User.query(knex) + .select('id', 'email', 'firstName', 'lastName') + .where('isActive', true); + + // Exclude current user if we have their ID + if (currentUser?.userId) { + query = query.whereNot('id', currentUser.userId); + } + + const users = await query; + + return users.map((user) => ({ + id: user.id, + email: user.email, + name: user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.email, + })); + } +} diff --git a/frontend/components/RecordShareManager.vue b/frontend/components/RecordShareManager.vue new file mode 100644 index 0000000..f9dceca --- /dev/null +++ b/frontend/components/RecordShareManager.vue @@ -0,0 +1,373 @@ + + + diff --git a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue index ed75d24..c515760 100644 --- a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue +++ b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue @@ -3,14 +3,18 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useApi } from '@/composables/useApi' import { useFields, useViewState } from '@/composables/useFieldViews' +import { useAuth } from '@/composables/useAuth' import ListView from '@/components/views/ListView.vue' import DetailView from '@/components/views/DetailViewEnhanced.vue' import EditView from '@/components/views/EditViewEnhanced.vue' +import RecordShareManager from '@/components/RecordShareManager.vue' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' const route = useRoute() const router = useRouter() const { api } = useApi() const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields() +const { getUser } = useAuth() // Use breadcrumbs composable const { setBreadcrumbs } = useBreadcrumbs() @@ -131,6 +135,38 @@ const canCreate = computed(() => { return result }) +// Check if user can share the record +const canShareRecord = computed(() => { + if (!currentRecord.value) return false + const user = getUser() + if (!user) return false + // User can share if they own the record + return currentRecord.value.ownerId === user.id +}) + +// Get current user's permissions for the record +const currentUserPermissions = computed(() => { + if (!objectDefinition.value || !currentRecord.value) { + return { canRead: false, canUpdate: false, canDelete: false } + } + + const user = getUser() + const isOwner = user ? currentRecord.value.ownerId === user.id : false + const accessModel = objectDefinition.value.access_model || objectDefinition.value.accessModel + const publicRead = objectAccess.value?.publicRead === true || objectAccess.value?.publicRead === 1 + const publicUpdate = objectAccess.value?.publicUpdate === true || objectAccess.value?.publicUpdate === 1 + const publicDelete = objectAccess.value?.publicDelete === true || objectAccess.value?.publicDelete === 1 + + return { + canRead: isOwner || publicRead || accessModel === 'public', + canUpdate: isOwner || publicUpdate, + canDelete: isOwner || publicDelete + } +}) + +// Active tab for detail view with sharing +const activeTab = ref('details') + // Fetch object definition const fetchObjectDefinition = async () => { try { @@ -293,18 +329,37 @@ onMounted(async () => { @delete="handleDelete" /> - - + +
+ + + Details + Sharing + + + + + + + + + + +