WIP - record sharing
This commit is contained in:
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { RbacService } from './rbac.service';
|
import { RbacService } from './rbac.service';
|
||||||
import { ShareController } from './share.controller';
|
import { ShareController } from './share.controller';
|
||||||
import { RoleController, RoleRuleController } from './role.controller';
|
import { RoleController, RoleRuleController } from './role.controller';
|
||||||
|
import { UserController } from './user.controller';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenantModule],
|
imports: [TenantModule],
|
||||||
providers: [RbacService],
|
providers: [RbacService],
|
||||||
controllers: [ShareController, RoleController, RoleRuleController],
|
controllers: [ShareController, RoleController, RoleRuleController, UserController],
|
||||||
exports: [RbacService],
|
exports: [RbacService],
|
||||||
})
|
})
|
||||||
export class RbacModule {}
|
export class RbacModule {}
|
||||||
|
|||||||
@@ -8,34 +8,59 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Inject,
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { IsString, IsArray, IsOptional, IsDateString } from 'class-validator';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/decorators/auth.decorators';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { User } from '../models/user.model';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
import { RecordShare } from '../models/record-share.model';
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
import { ObjectDefinition } from '../models/object-definition.model';
|
|
||||||
import { Knex } from 'knex';
|
|
||||||
|
|
||||||
export class CreateShareDto {
|
export class CreateShareDto {
|
||||||
objectDefinitionId: string;
|
@IsString()
|
||||||
|
objectApiName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
recordId: string;
|
recordId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
granteeUserId: string;
|
granteeUserId: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
actions: string[]; // ["read"], ["read", "update"], etc.
|
actions: string[]; // ["read"], ["read", "update"], etc.
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
fields?: string[]; // Optional field scoping
|
fields?: string[]; // Optional field scoping
|
||||||
expiresAt?: Date;
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiresAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateShareDto {
|
export class UpdateShareDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
actions?: string[];
|
actions?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
fields?: string[];
|
fields?: string[];
|
||||||
expiresAt?: Date;
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiresAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('shares')
|
@Controller('rbac/shares')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class ShareController {
|
export class ShareController {
|
||||||
constructor(@Inject('KnexConnection') private readonly knex: Knex) {}
|
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new share
|
* Create a new share
|
||||||
@@ -43,156 +68,175 @@ export class ShareController {
|
|||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: User,
|
@TenantId() tenantId: string,
|
||||||
|
@CurrentUser() currentUser: any,
|
||||||
@Body() createDto: CreateShareDto,
|
@Body() createDto: CreateShareDto,
|
||||||
) {
|
) {
|
||||||
// Verify the user owns the record or has permission to share
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const objectDef = await ObjectDefinition.query(this.knex)
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
.findById(createDto.objectDefinitionId);
|
|
||||||
|
// Get object definition by apiName
|
||||||
|
const objectDef = await knex('object_definitions')
|
||||||
|
.where({ apiName: createDto.objectApiName })
|
||||||
|
.first();
|
||||||
|
|
||||||
if (!objectDef) {
|
if (!objectDef) {
|
||||||
throw new Error('Object definition not found');
|
throw new NotFoundException('Object definition not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Verify ownership or share permission via CASL
|
// Get the table name for the object
|
||||||
// For now, we'll assume authorized
|
const tableName = this.getTableName(createDto.objectApiName);
|
||||||
|
|
||||||
const share = await RecordShare.query(this.knex).insert({
|
// Verify the user owns the record
|
||||||
objectDefinitionId: createDto.objectDefinitionId,
|
const record = await knex(tableName)
|
||||||
recordId: createDto.recordId,
|
.where({ id: createDto.recordId })
|
||||||
granteeUserId: createDto.granteeUserId,
|
.first();
|
||||||
grantedByUserId: user.id,
|
|
||||||
|
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),
|
actions: JSON.stringify(createDto.actions),
|
||||||
fields: createDto.fields ? JSON.stringify(createDto.fields) : null,
|
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
|
* List shares for a specific record
|
||||||
* Only owner or users with access can see shares
|
* Only owner or users with access can see shares
|
||||||
*/
|
*/
|
||||||
@Get('record/:objectDefinitionId/:recordId')
|
@Get(':objectApiName/:recordId')
|
||||||
async listForRecord(
|
async listForRecord(
|
||||||
@CurrentUser() user: User,
|
@TenantId() tenantId: string,
|
||||||
@Param('objectDefinitionId') objectDefinitionId: string,
|
@CurrentUser() currentUser: any,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
@Param('recordId') recordId: 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({
|
.where({
|
||||||
objectDefinitionId,
|
object_definition_id: objectDef.id,
|
||||||
recordId,
|
record_id: recordId,
|
||||||
})
|
})
|
||||||
.whereNull('revokedAt')
|
.whereNull('revoked_at')
|
||||||
.withGraphFetched('[granteeUser, grantedByUser]');
|
.select('*');
|
||||||
|
|
||||||
return shares.map((share) => ({
|
// Fetch user details for each share
|
||||||
...share,
|
const sharesWithUsers = await Promise.all(
|
||||||
actions: typeof share.actions === 'string' ? JSON.parse(share.actions) : share.actions,
|
shares.map(async (share: any) => {
|
||||||
fields: share.fields && typeof share.fields === 'string' ? JSON.parse(share.fields) : share.fields,
|
const granteeUser = await knex('users')
|
||||||
}));
|
.where({ id: share.grantee_user_id })
|
||||||
}
|
.select('id', 'email', 'firstName', 'lastName', 'name')
|
||||||
|
.first();
|
||||||
|
|
||||||
/**
|
const grantedByUser = await knex('users')
|
||||||
* List shares granted by current user
|
.where({ id: share.granted_by_user_id })
|
||||||
*/
|
.select('id', 'email', 'firstName', 'lastName', 'name')
|
||||||
@Get('granted')
|
.first();
|
||||||
async listGranted(@CurrentUser() user: User) {
|
|
||||||
const shares = await RecordShare.query(this.knex)
|
|
||||||
.where('grantedByUserId', user.id)
|
|
||||||
.whereNull('revokedAt')
|
|
||||||
.withGraphFetched('[granteeUser, 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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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());
|
|
||||||
})
|
|
||||||
.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 {
|
return {
|
||||||
...updated,
|
id: share.id,
|
||||||
actions: typeof updated.actions === 'string' ? JSON.parse(updated.actions) : updated.actions,
|
recordId: share.record_id,
|
||||||
fields: updated.fields && typeof updated.fields === 'string' ? JSON.parse(updated.fields) : updated.fields,
|
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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return sharesWithUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke a share (soft delete)
|
* Revoke a share (soft delete)
|
||||||
*/
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
async revoke(@CurrentUser() user: User, @Param('id') id: string) {
|
async revoke(
|
||||||
const share = await RecordShare.query(this.knex).findById(id);
|
@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) {
|
if (!share) {
|
||||||
throw new Error('Share not found');
|
throw new NotFoundException('Share not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the grantor can revoke
|
// Only the grantor can revoke
|
||||||
if (share.grantedByUserId !== user.id) {
|
if (share.granted_by_user_id !== currentUser.userId) {
|
||||||
throw new Error('Unauthorized');
|
throw new ForbiddenException('Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
await RecordShare.query(this.knex)
|
await knex('record_shares')
|
||||||
.patchAndFetchById(id, { revokedAt: new Date() });
|
.where({ id })
|
||||||
|
.update({ revoked_at: knex.fn.now() });
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
41
backend/src/rbac/user.controller.ts
Normal file
41
backend/src/rbac/user.controller.ts
Normal file
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
373
frontend/components/RecordShareManager.vue
Normal file
373
frontend/components/RecordShareManager.vue
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Existing Shares List -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Current Shares</CardTitle>
|
||||||
|
<CardDescription>Users who have access to this record</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div v-if="loading" class="flex justify-center py-8">
|
||||||
|
<div class="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="shares.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
No shares yet. Click "Add Share" to share this record.
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="share in shares"
|
||||||
|
:key="share.id"
|
||||||
|
class="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium">{{ getUserName(share.granteeUser) }}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
Access: {{ formatActions(share.actions) }}
|
||||||
|
<span v-if="share.fields && share.fields.length > 0">
|
||||||
|
• Fields: {{ share.fields.join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="share.expiresAt">
|
||||||
|
• Expires: {{ formatDate(share.expiresAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
|
Granted by {{ getUserName(share.grantedByUser) }}
|
||||||
|
on {{ formatDate(share.createdAt) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="revokeShare(share.id)"
|
||||||
|
:disabled="revoking === share.id"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Add New Share -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Share</CardTitle>
|
||||||
|
<CardDescription>Grant access to another user</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<!-- User Selection -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="user">User</Label>
|
||||||
|
<select
|
||||||
|
id="user"
|
||||||
|
v-model="newShare.userId"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
>
|
||||||
|
<option value="">Select a user</option>
|
||||||
|
<option
|
||||||
|
v-for="user in availableUsers"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Access Level -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Access Level</Label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="share-read"
|
||||||
|
v-model="newShare.canRead"
|
||||||
|
:disabled="!canGrantRead"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
for="share-read"
|
||||||
|
class="font-normal cursor-pointer"
|
||||||
|
:class="{ 'text-muted-foreground': !canGrantRead }"
|
||||||
|
>
|
||||||
|
Read
|
||||||
|
<span v-if="!canGrantRead" class="text-xs">(You don't have read permission)</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="share-update"
|
||||||
|
v-model="newShare.canUpdate"
|
||||||
|
:disabled="!canGrantUpdate"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
for="share-update"
|
||||||
|
class="font-normal cursor-pointer"
|
||||||
|
:class="{ 'text-muted-foreground': !canGrantUpdate }"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
<span v-if="!canGrantUpdate" class="text-xs">(You don't have update permission)</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="share-delete"
|
||||||
|
v-model="newShare.canDelete"
|
||||||
|
:disabled="!canGrantDelete"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
for="share-delete"
|
||||||
|
class="font-normal cursor-pointer"
|
||||||
|
:class="{ 'text-muted-foreground': !canGrantDelete }"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
<span v-if="!canGrantDelete" class="text-xs">(You don't have delete permission)</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Field-Level Access (Optional) -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="limit-fields"
|
||||||
|
v-model="newShare.limitFields"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label for="limit-fields" class="font-normal cursor-pointer">
|
||||||
|
Limit access to specific fields
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="newShare.limitFields" class="ml-6 space-y-2 mt-2">
|
||||||
|
<Label>Select Fields</Label>
|
||||||
|
<div class="space-y-1 max-h-48 overflow-y-auto border rounded p-2">
|
||||||
|
<div
|
||||||
|
v-for="field in availableFields"
|
||||||
|
:key="field.apiName"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:id="`field-${field.apiName}`"
|
||||||
|
:value="field.apiName"
|
||||||
|
v-model="newShare.selectedFields"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label :for="`field-${field.apiName}`" class="font-normal cursor-pointer">
|
||||||
|
{{ field.label }}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiration (Optional) -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="set-expiration"
|
||||||
|
v-model="newShare.hasExpiration"
|
||||||
|
class="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label for="set-expiration" class="font-normal cursor-pointer">
|
||||||
|
Set expiration date
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="newShare.hasExpiration" class="ml-6">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
v-model="newShare.expiresAt"
|
||||||
|
class="w-full px-3 py-2 border rounded-md bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="createShare"
|
||||||
|
:disabled="!canCreateShare || creating"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<Share2 class="h-4 w-4 mr-2" />
|
||||||
|
{{ creating ? 'Creating...' : 'Add Share' }}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Share2, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objectApiName: string
|
||||||
|
recordId: string
|
||||||
|
currentUserPermissions: {
|
||||||
|
canRead: boolean
|
||||||
|
canUpdate: boolean
|
||||||
|
canDelete: boolean
|
||||||
|
}
|
||||||
|
fields: Array<{ apiName: string; label: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { api } = useApi()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
|
const shares = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const revoking = ref<string | null>(null)
|
||||||
|
const creating = ref(false)
|
||||||
|
const availableUsers = ref<any[]>([])
|
||||||
|
|
||||||
|
const newShare = ref({
|
||||||
|
userId: '',
|
||||||
|
canRead: true,
|
||||||
|
canUpdate: false,
|
||||||
|
canDelete: false,
|
||||||
|
limitFields: false,
|
||||||
|
selectedFields: [] as string[],
|
||||||
|
hasExpiration: false,
|
||||||
|
expiresAt: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const canGrantRead = computed(() => props.currentUserPermissions.canRead)
|
||||||
|
const canGrantUpdate = computed(() => props.currentUserPermissions.canUpdate)
|
||||||
|
const canGrantDelete = computed(() => props.currentUserPermissions.canDelete)
|
||||||
|
|
||||||
|
const availableFields = computed(() => {
|
||||||
|
return props.fields.filter(f => !['id', 'created_at', 'updated_at', 'ownerId'].includes(f.apiName))
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCreateShare = computed(() => {
|
||||||
|
return newShare.value.userId &&
|
||||||
|
(newShare.value.canRead || newShare.value.canUpdate || newShare.value.canDelete)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getUserName = (user: any) => {
|
||||||
|
if (!user) return 'Unknown'
|
||||||
|
return user.name || user.email || 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatActions = (actions: string[]) => {
|
||||||
|
return actions.map(a => a.charAt(0).toUpperCase() + a.slice(1)).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchShares = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/rbac/shares/${props.objectApiName}/${props.recordId}`)
|
||||||
|
shares.value = response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch shares:', error)
|
||||||
|
showToast('Failed to load shares', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAvailableUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/rbac/users')
|
||||||
|
availableUsers.value = response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createShare = async () => {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const actions: string[] = []
|
||||||
|
if (newShare.value.canRead) actions.push('read')
|
||||||
|
if (newShare.value.canUpdate) actions.push('update')
|
||||||
|
if (newShare.value.canDelete) actions.push('delete')
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
objectApiName: props.objectApiName,
|
||||||
|
recordId: props.recordId,
|
||||||
|
granteeUserId: newShare.value.userId,
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newShare.value.limitFields && newShare.value.selectedFields.length > 0) {
|
||||||
|
payload.fields = newShare.value.selectedFields
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newShare.value.hasExpiration && newShare.value.expiresAt) {
|
||||||
|
payload.expiresAt = new Date(newShare.value.expiresAt).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.post('/rbac/shares', payload)
|
||||||
|
|
||||||
|
showToast('Share created successfully', 'success')
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
newShare.value = {
|
||||||
|
userId: '',
|
||||||
|
canRead: true,
|
||||||
|
canUpdate: false,
|
||||||
|
canDelete: false,
|
||||||
|
limitFields: false,
|
||||||
|
selectedFields: [],
|
||||||
|
hasExpiration: false,
|
||||||
|
expiresAt: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchShares()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to create share:', error)
|
||||||
|
showToast(error.message || 'Failed to create share', 'error')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeShare = async (shareId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to revoke this share?')) return
|
||||||
|
|
||||||
|
revoking.value = shareId
|
||||||
|
try {
|
||||||
|
await api.delete(`/rbac/shares/${shareId}`)
|
||||||
|
|
||||||
|
showToast('Share revoked successfully', 'success')
|
||||||
|
|
||||||
|
await fetchShares()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to revoke share:', error)
|
||||||
|
showToast(error.message || 'Failed to revoke share', 'error')
|
||||||
|
} finally {
|
||||||
|
revoking.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchShares()
|
||||||
|
fetchAvailableUsers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -3,14 +3,18 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useFields, useViewState } from '@/composables/useFieldViews'
|
import { useFields, useViewState } from '@/composables/useFieldViews'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import ListView from '@/components/views/ListView.vue'
|
import ListView from '@/components/views/ListView.vue'
|
||||||
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
import DetailView from '@/components/views/DetailViewEnhanced.vue'
|
||||||
import EditView from '@/components/views/EditViewEnhanced.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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
|
||||||
|
const { getUser } = useAuth()
|
||||||
|
|
||||||
// Use breadcrumbs composable
|
// Use breadcrumbs composable
|
||||||
const { setBreadcrumbs } = useBreadcrumbs()
|
const { setBreadcrumbs } = useBreadcrumbs()
|
||||||
@@ -131,6 +135,38 @@ const canCreate = computed(() => {
|
|||||||
return result
|
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
|
// Fetch object definition
|
||||||
const fetchObjectDefinition = async () => {
|
const fetchObjectDefinition = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -293,9 +329,16 @@ onMounted(async () => {
|
|||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Detail View -->
|
<!-- Detail View with Tabs -->
|
||||||
|
<div v-else-if="view === 'detail' && detailConfig && currentRecord">
|
||||||
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
|
<TabsList class="grid w-full mb-6" :class="canShareRecord ? 'grid-cols-2' : 'grid-cols-1'">
|
||||||
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
|
<TabsTrigger v-if="canShareRecord" value="sharing">Sharing</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="details">
|
||||||
<DetailView
|
<DetailView
|
||||||
v-else-if="view === 'detail' && detailConfig && currentRecord"
|
|
||||||
:config="detailConfig"
|
:config="detailConfig"
|
||||||
:data="currentRecord"
|
:data="currentRecord"
|
||||||
:loading="dataLoading"
|
:loading="dataLoading"
|
||||||
@@ -305,6 +348,18 @@ onMounted(async () => {
|
|||||||
@delete="() => handleDelete([currentRecord])"
|
@delete="() => handleDelete([currentRecord])"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
/>
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent v-if="canShareRecord" value="sharing">
|
||||||
|
<RecordShareManager
|
||||||
|
:object-api-name="objectApiName"
|
||||||
|
:record-id="recordId!"
|
||||||
|
:current-user-permissions="currentUserPermissions"
|
||||||
|
:fields="objectDefinition?.fields || []"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Edit View -->
|
<!-- Edit View -->
|
||||||
<div v-else-if="(view === 'edit' || recordId === 'new') && editConfig">
|
<div v-else-if="(view === 'edit' || recordId === 'new') && editConfig">
|
||||||
|
|||||||
Reference in New Issue
Block a user