Compare commits
2 Commits
6c29d18696
...
c50098a55c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c50098a55c | ||
|
|
e73126bcb7 |
@@ -68,6 +68,7 @@ exports.up = function (knex) {
|
|||||||
table.timestamp('expiresAt').nullable();
|
table.timestamp('expiresAt').nullable();
|
||||||
table.timestamp('revokedAt').nullable();
|
table.timestamp('revokedAt').nullable();
|
||||||
table.timestamp('createdAt').defaultTo(knex.fn.now());
|
table.timestamp('createdAt').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updatedAt').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
table
|
table
|
||||||
.foreign('objectDefinitionId')
|
.foreign('objectDefinitionId')
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.table('record_shares', (table) => {
|
||||||
|
table.timestamp('updatedAt').defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.table('record_shares', (table) => {
|
||||||
|
table.dropColumn('updatedAt');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -9,6 +9,27 @@ export interface RecordShareAccessLevel {
|
|||||||
export class RecordShare extends BaseModel {
|
export class RecordShare extends BaseModel {
|
||||||
static tableName = 'record_shares';
|
static tableName = 'record_shares';
|
||||||
|
|
||||||
|
// Don't use snake_case mapping since DB columns are already camelCase
|
||||||
|
static get columnNameMappers() {
|
||||||
|
return {
|
||||||
|
parse(obj: any) {
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
format(obj: any) {
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override BaseModel hooks to prevent automatic timestamp handling
|
||||||
|
$beforeInsert(queryContext: any) {
|
||||||
|
// Don't set timestamps - let database defaults handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
$beforeUpdate(opt: any, queryContext: any) {
|
||||||
|
// Don't set timestamps - let database defaults handle it
|
||||||
|
}
|
||||||
|
|
||||||
id!: string;
|
id!: string;
|
||||||
objectDefinitionId!: string;
|
objectDefinitionId!: string;
|
||||||
recordId!: string;
|
recordId!: string;
|
||||||
@@ -18,6 +39,7 @@ export class RecordShare extends BaseModel {
|
|||||||
expiresAt?: Date;
|
expiresAt?: Date;
|
||||||
revokedAt?: Date;
|
revokedAt?: Date;
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
static get jsonSchema() {
|
static get jsonSchema() {
|
||||||
return {
|
return {
|
||||||
@@ -37,8 +59,22 @@ export class RecordShare extends BaseModel {
|
|||||||
canDelete: { type: 'boolean' },
|
canDelete: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expiresAt: { type: 'string', format: 'date-time' },
|
expiresAt: {
|
||||||
revokedAt: { type: 'string', format: 'date-time' },
|
anyOf: [
|
||||||
|
{ type: 'string', format: 'date-time' },
|
||||||
|
{ type: 'null' },
|
||||||
|
{ type: 'object' } // Allow Date objects
|
||||||
|
]
|
||||||
|
},
|
||||||
|
revokedAt: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: 'string', format: 'date-time' },
|
||||||
|
{ type: 'null' },
|
||||||
|
{ type: 'object' } // Allow Date objects
|
||||||
|
]
|
||||||
|
},
|
||||||
|
createdAt: { type: ['string', 'object'], format: 'date-time' },
|
||||||
|
updatedAt: { type: ['string', 'object'], format: 'date-time' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
19
backend/src/rbac/dto/create-record-share.dto.ts
Normal file
19
backend/src/rbac/dto/create-record-share.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, IsBoolean, IsOptional, IsDateString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateRecordShareDto {
|
||||||
|
@IsString()
|
||||||
|
granteeUserId: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
canRead: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
canEdit: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
canDelete: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import { AbilityFactory } from './ability.factory';
|
|||||||
import { AuthorizationService } from './authorization.service';
|
import { AuthorizationService } from './authorization.service';
|
||||||
import { SetupRolesController } from './setup-roles.controller';
|
import { SetupRolesController } from './setup-roles.controller';
|
||||||
import { SetupUsersController } from './setup-users.controller';
|
import { SetupUsersController } from './setup-users.controller';
|
||||||
|
import { RecordSharingController } from './record-sharing.controller';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TenantModule],
|
imports: [TenantModule],
|
||||||
controllers: [SetupRolesController, SetupUsersController],
|
controllers: [SetupRolesController, SetupUsersController, RecordSharingController],
|
||||||
providers: [RbacService, AbilityFactory, AuthorizationService],
|
providers: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
exports: [RbacService, AbilityFactory, AuthorizationService],
|
exports: [RbacService, AbilityFactory, AuthorizationService],
|
||||||
})
|
})
|
||||||
|
|||||||
322
backend/src/rbac/record-sharing.controller.ts
Normal file
322
backend/src/rbac/record-sharing.controller.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
ForbiddenException,
|
||||||
|
} 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 { RecordShare } from '../models/record-share.model';
|
||||||
|
import { ObjectDefinition } from '../models/object-definition.model';
|
||||||
|
import { User } from '../models/user.model';
|
||||||
|
import { AuthorizationService } from './authorization.service';
|
||||||
|
import { CreateRecordShareDto } from './dto/create-record-share.dto';
|
||||||
|
|
||||||
|
@Controller('runtime/objects/:objectApiName/records/:recordId/shares')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class RecordSharingController {
|
||||||
|
constructor(
|
||||||
|
private tenantDbService: TenantDatabaseService,
|
||||||
|
private authService: AuthorizationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getRecordShares(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@Param('recordId') recordId: string,
|
||||||
|
@CurrentUser() currentUser: any,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Get object definition
|
||||||
|
const objectDef = await ObjectDefinition.query(knex)
|
||||||
|
.findOne({ apiName: objectApiName });
|
||||||
|
|
||||||
|
if (!objectDef) {
|
||||||
|
throw new Error('Object not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the record to check ownership
|
||||||
|
const tableName = this.getTableName(objectDef.apiName);
|
||||||
|
const record = await knex(tableName)
|
||||||
|
.where({ id: recordId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('Record not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owner can view shares
|
||||||
|
if (record.ownerId !== currentUser.userId) {
|
||||||
|
// Check if user has modify all permission
|
||||||
|
const user: any = await User.query(knex)
|
||||||
|
.findById(currentUser.userId)
|
||||||
|
.withGraphFetched('roles.objectPermissions');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasModifyAll = user.roles?.some(role =>
|
||||||
|
role.objectPermissions?.some(
|
||||||
|
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasModifyAll) {
|
||||||
|
throw new ForbiddenException('Only the record owner or users with Modify All permission can view shares');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active shares for this record
|
||||||
|
const shares = await RecordShare.query(knex)
|
||||||
|
.where({ objectDefinitionId: objectDef.id, recordId })
|
||||||
|
.whereNull('revokedAt')
|
||||||
|
.where(builder => {
|
||||||
|
builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date());
|
||||||
|
})
|
||||||
|
.withGraphFetched('[granteeUser]')
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createRecordShare(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@Param('recordId') recordId: string,
|
||||||
|
@CurrentUser() currentUser: any,
|
||||||
|
@Body() data: CreateRecordShareDto,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Get object definition
|
||||||
|
const objectDef = await ObjectDefinition.query(knex)
|
||||||
|
.findOne({ apiName: objectApiName });
|
||||||
|
|
||||||
|
if (!objectDef) {
|
||||||
|
throw new Error('Object not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the record to check ownership
|
||||||
|
const tableName = this.getTableName(objectDef.apiName);
|
||||||
|
const record = await knex(tableName)
|
||||||
|
.where({ id: recordId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('Record not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can share - either owner or has modify permissions
|
||||||
|
const canShare = await this.canUserShareRecord(
|
||||||
|
currentUser.userId,
|
||||||
|
record,
|
||||||
|
objectDef,
|
||||||
|
knex,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canShare) {
|
||||||
|
throw new ForbiddenException('You do not have permission to share this record');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot share with self
|
||||||
|
if (data.granteeUserId === currentUser.userId) {
|
||||||
|
throw new Error('Cannot share record with yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if share already exists
|
||||||
|
const existingShare = await RecordShare.query(knex)
|
||||||
|
.where({
|
||||||
|
objectDefinitionId: objectDef.id,
|
||||||
|
recordId,
|
||||||
|
granteeUserId: data.granteeUserId,
|
||||||
|
})
|
||||||
|
.whereNull('revokedAt')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingShare) {
|
||||||
|
// Update existing share
|
||||||
|
await knex('record_shares')
|
||||||
|
.where({ id: existingShare.id })
|
||||||
|
.update({
|
||||||
|
accessLevel: JSON.stringify({
|
||||||
|
canRead: data.canRead,
|
||||||
|
canEdit: data.canEdit,
|
||||||
|
canDelete: data.canDelete,
|
||||||
|
}),
|
||||||
|
expiresAt: data.expiresAt ? data.expiresAt : null,
|
||||||
|
updatedAt: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return RecordShare.query(knex)
|
||||||
|
.findById(existingShare.id)
|
||||||
|
.withGraphFetched('[granteeUser]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new share
|
||||||
|
const [shareId] = await knex('record_shares').insert({
|
||||||
|
objectDefinitionId: objectDef.id,
|
||||||
|
recordId,
|
||||||
|
granteeUserId: data.granteeUserId,
|
||||||
|
grantedByUserId: currentUser.userId,
|
||||||
|
accessLevel: JSON.stringify({
|
||||||
|
canRead: data.canRead,
|
||||||
|
canEdit: data.canEdit,
|
||||||
|
canDelete: data.canDelete,
|
||||||
|
}),
|
||||||
|
expiresAt: data.expiresAt ? data.expiresAt : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return RecordShare.query(knex)
|
||||||
|
.findById(shareId)
|
||||||
|
.withGraphFetched('[granteeUser]');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':shareId')
|
||||||
|
async deleteRecordShare(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Param('objectApiName') objectApiName: string,
|
||||||
|
@Param('recordId') recordId: string,
|
||||||
|
@Param('shareId') shareId: string,
|
||||||
|
@CurrentUser() currentUser: any,
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
// Get object definition
|
||||||
|
const objectDef = await ObjectDefinition.query(knex)
|
||||||
|
.findOne({ apiName: objectApiName });
|
||||||
|
|
||||||
|
if (!objectDef) {
|
||||||
|
throw new Error('Object not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the record to check ownership
|
||||||
|
const tableName = this.getTableName(objectDef.apiName);
|
||||||
|
const record = await knex(tableName)
|
||||||
|
.where({ id: recordId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('Record not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owner can revoke shares
|
||||||
|
if (record.ownerId !== currentUser.userId) {
|
||||||
|
// Check if user has modify all permission
|
||||||
|
const user: any = await User.query(knex)
|
||||||
|
.findById(currentUser.userId)
|
||||||
|
.withGraphFetched('roles.objectPermissions');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasModifyAll = user.roles?.some(role =>
|
||||||
|
role.objectPermissions?.some(
|
||||||
|
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasModifyAll) {
|
||||||
|
throw new ForbiddenException('Only the record owner or users with Modify All permission can revoke shares');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the share (soft delete)
|
||||||
|
await knex('record_shares')
|
||||||
|
.where({ id: shareId })
|
||||||
|
.update({
|
||||||
|
revokedAt: knex.fn.now(),
|
||||||
|
updatedAt: knex.fn.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canUserShareRecord(
|
||||||
|
userId: string,
|
||||||
|
record: any,
|
||||||
|
objectDef: ObjectDefinition,
|
||||||
|
knex: any,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Owner can always share
|
||||||
|
if (record.ownerId === userId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has modify all or edit permissions
|
||||||
|
const user: any = await User.query(knex)
|
||||||
|
.findById(userId)
|
||||||
|
.withGraphFetched('roles.objectPermissions');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for canModifyAll permission
|
||||||
|
const hasModifyAll = user.roles?.some(role =>
|
||||||
|
role.objectPermissions?.some(
|
||||||
|
perm => perm.objectDefinitionId === objectDef.id && perm.canModifyAll
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasModifyAll) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for canEdit permission (user needs edit to share)
|
||||||
|
const hasEdit = user.roles?.some(role =>
|
||||||
|
role.objectPermissions?.some(
|
||||||
|
perm => perm.objectDefinitionId === objectDef.id && perm.canEdit
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// If user has edit permission, check if they can actually edit this record
|
||||||
|
// by using the authorization service
|
||||||
|
if (hasEdit) {
|
||||||
|
try {
|
||||||
|
await this.authService.assertCanPerformAction(
|
||||||
|
'update',
|
||||||
|
objectDef,
|
||||||
|
record,
|
||||||
|
user,
|
||||||
|
knex,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTableName(apiName: string): string {
|
||||||
|
// Convert CamelCase to snake_case and pluralize
|
||||||
|
const snakeCase = apiName
|
||||||
|
.replace(/([A-Z])/g, '_$1')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^_/, '');
|
||||||
|
|
||||||
|
// Simple pluralization
|
||||||
|
if (snakeCase.endsWith('y')) {
|
||||||
|
return snakeCase.slice(0, -1) + 'ies';
|
||||||
|
} else if (snakeCase.endsWith('s')) {
|
||||||
|
return snakeCase + 'es';
|
||||||
|
} else {
|
||||||
|
return snakeCase + 's';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
317
frontend/components/RecordSharing.vue
Normal file
317
frontend/components/RecordSharing.vue
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<template>
|
||||||
|
<div class="record-sharing space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">Sharing</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Grant access to specific users for this record
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button @click="showShareDialog = true" size="sm">
|
||||||
|
<UserPlus class="h-4 w-4 mr-2" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="text-sm text-destructive">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shares List -->
|
||||||
|
<div v-else-if="shares.length > 0" class="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Access</TableHead>
|
||||||
|
<TableHead>Shared</TableHead>
|
||||||
|
<TableHead class="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="share in shares" :key="share.id">
|
||||||
|
<TableCell class="font-medium">
|
||||||
|
{{ getUserName(share.granteeUser) }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ share.granteeUser.email }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Badge v-if="share.accessLevel.canRead" variant="secondary">Read</Badge>
|
||||||
|
<Badge v-if="share.accessLevel.canEdit" variant="secondary">Edit</Badge>
|
||||||
|
<Badge v-if="share.accessLevel.canDelete" variant="secondary">Delete</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ formatDate(share.createdAt) }}</TableCell>
|
||||||
|
<TableCell class="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
@click="removeShare(share.id)"
|
||||||
|
:disabled="removing === share.id"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="text-center py-8 text-muted-foreground border rounded-lg">
|
||||||
|
<Users class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>This record is not shared with anyone</p>
|
||||||
|
<p class="text-sm">Click "Share" to grant access to other users</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Share Dialog -->
|
||||||
|
<Dialog v-model:open="showShareDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share Record</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Grant access to this record to specific users
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="user">User</Label>
|
||||||
|
<Select v-model="newShare.userId" @update:model-value="(value) => newShare.userId = value">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select user" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="user in availableUsers"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
>
|
||||||
|
{{ getUserName(user) }} ({{ user.email }})
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Label>Permissions</Label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="canRead"
|
||||||
|
v-model:checked="newShare.canRead"
|
||||||
|
@update:checked="(value) => newShare.canRead = value"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="canRead"
|
||||||
|
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
Can Read
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="canEdit"
|
||||||
|
v-model:checked="newShare.canEdit"
|
||||||
|
@update:checked="(value) => newShare.canEdit = value"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="canEdit"
|
||||||
|
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
Can Edit
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="canDelete"
|
||||||
|
v-model:checked="newShare.canDelete"
|
||||||
|
@update:checked="(value) => newShare.canDelete = value"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="canDelete"
|
||||||
|
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
Can Delete
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="expiresAt">Expires At (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="expiresAt"
|
||||||
|
v-model="newShare.expiresAt"
|
||||||
|
type="datetime-local"
|
||||||
|
placeholder="Never"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showShareDialog = false">Cancel</Button>
|
||||||
|
<Button
|
||||||
|
@click="createShare"
|
||||||
|
:disabled="!newShare.userId || (!newShare.canRead && !newShare.canEdit && !newShare.canDelete) || sharing"
|
||||||
|
>
|
||||||
|
{{ sharing ? 'Sharing...' : 'Share' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/ui/table';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
|
||||||
|
import { Input } from '~/components/ui/input';
|
||||||
|
import { Label } from '~/components/ui/label';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import Checkbox from '~/components/ui/checkbox.vue';
|
||||||
|
import { UserPlus, Trash2, Users } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
objectApiName: string;
|
||||||
|
recordId: string;
|
||||||
|
ownerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const { api } = useApi();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const sharing = ref(false);
|
||||||
|
const removing = ref<string | null>(null);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const shares = ref<any[]>([]);
|
||||||
|
const allUsers = ref<any[]>([]);
|
||||||
|
const showShareDialog = ref(false);
|
||||||
|
const newShare = ref({
|
||||||
|
userId: '',
|
||||||
|
canRead: true,
|
||||||
|
canEdit: false,
|
||||||
|
canDelete: false,
|
||||||
|
expiresAt: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out users who already have shares
|
||||||
|
const availableUsers = computed(() => {
|
||||||
|
const sharedUserIds = new Set(shares.value.map(s => s.granteeUserId));
|
||||||
|
return allUsers.value.filter(u => !sharedUserIds.has(u.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadShares = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
const response = await api.get(
|
||||||
|
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`
|
||||||
|
);
|
||||||
|
shares.value = response || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to load shares:', e);
|
||||||
|
error.value = e.message || 'Failed to load shares';
|
||||||
|
// If user is not owner, they can't see shares
|
||||||
|
if (e.message?.includes('owner')) {
|
||||||
|
error.value = 'Only the record owner can manage sharing';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/setup/users');
|
||||||
|
allUsers.value = response || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to load users:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createShare = async () => {
|
||||||
|
try {
|
||||||
|
sharing.value = true;
|
||||||
|
const payload: any = {
|
||||||
|
granteeUserId: newShare.value.userId,
|
||||||
|
canRead: newShare.value.canRead,
|
||||||
|
canEdit: newShare.value.canEdit,
|
||||||
|
canDelete: newShare.value.canDelete,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include expiresAt if it has a value
|
||||||
|
if (newShare.value.expiresAt && newShare.value.expiresAt.trim()) {
|
||||||
|
payload.expiresAt = newShare.value.expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.post(
|
||||||
|
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
toast.success('Record shared successfully');
|
||||||
|
showShareDialog.value = false;
|
||||||
|
newShare.value = {
|
||||||
|
userId: '',
|
||||||
|
canRead: true,
|
||||||
|
canEdit: false,
|
||||||
|
canDelete: false,
|
||||||
|
expiresAt: '',
|
||||||
|
};
|
||||||
|
await loadShares();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to share record:', e);
|
||||||
|
toast.error(e.message || 'Failed to share record');
|
||||||
|
} finally {
|
||||||
|
sharing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeShare = async (shareId: string) => {
|
||||||
|
try {
|
||||||
|
removing.value = shareId;
|
||||||
|
await api.delete(
|
||||||
|
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares/${shareId}`
|
||||||
|
);
|
||||||
|
toast.success('Share removed successfully');
|
||||||
|
await loadShares();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to remove share:', e);
|
||||||
|
toast.error(e.message || 'Failed to remove share');
|
||||||
|
} finally {
|
||||||
|
removing.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserName = (user: any) => {
|
||||||
|
if (!user) return 'Unknown';
|
||||||
|
if (user.firstName || user.lastName) {
|
||||||
|
return [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return user.email;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Date(date).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadShares(), loadUsers()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
33
frontend/components/ui/checkbox.vue
Normal file
33
frontend/components/ui/checkbox.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check } from 'lucide-vue-next'
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, type CheckboxRootEmits, type CheckboxRootProps, useForwardPropsEmits } from 'radix-vue'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
||||||
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
|
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
|
||||||
import RelatedList from '@/components/RelatedList.vue'
|
import RelatedList from '@/components/RelatedList.vue'
|
||||||
|
import RecordSharing from '@/components/RecordSharing.vue'
|
||||||
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
|
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
|
||||||
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
@@ -20,11 +22,13 @@ interface Props {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
objectId?: string // For fetching page layout
|
objectId?: string // For fetching page layout
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
|
showSharing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false,
|
loading: false,
|
||||||
baseUrl: '/runtime/objects',
|
baseUrl: '/runtime/objects',
|
||||||
|
showSharing: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -130,91 +134,123 @@ const usePageLayout = computed(() => {
|
|||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content with Page Layout -->
|
<!-- Tabs for Details, Related, and Sharing -->
|
||||||
<Card v-else-if="usePageLayout">
|
<Tabs v-else default-value="details" class="space-y-6">
|
||||||
<CardHeader>
|
<TabsList>
|
||||||
<CardTitle>Details</CardTitle>
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
</CardHeader>
|
<TabsTrigger v-if="config.relatedLists && config.relatedLists.length > 0" value="related">
|
||||||
<CardContent>
|
Related
|
||||||
<PageLayoutRenderer
|
</TabsTrigger>
|
||||||
:fields="config.fields"
|
<TabsTrigger v-if="showSharing && data.id" value="sharing">
|
||||||
:layout="pageLayout"
|
Sharing
|
||||||
:model-value="data"
|
</TabsTrigger>
|
||||||
:readonly="true"
|
</TabsList>
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Traditional Section-based Layout -->
|
<!-- Details Tab -->
|
||||||
<div v-else class="space-y-6">
|
<TabsContent value="details" class="space-y-6">
|
||||||
<Card v-for="(section, idx) in sections" :key="idx">
|
<!-- Content with Page Layout -->
|
||||||
<Collapsible
|
<Card v-if="usePageLayout">
|
||||||
v-if="section.collapsible"
|
|
||||||
:default-open="!section.defaultCollapsed"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CollapsibleTrigger class="flex items-center justify-between w-full hover:bg-muted/50 -m-2 p-2 rounded">
|
<CardTitle>Details</CardTitle>
|
||||||
<div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PageLayoutRenderer
|
||||||
|
:fields="config.fields"
|
||||||
|
:layout="pageLayout"
|
||||||
|
:model-value="data"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Traditional Section-based Layout -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<Card v-for="(section, idx) in sections" :key="idx">
|
||||||
|
<Collapsible
|
||||||
|
v-if="section.collapsible"
|
||||||
|
:default-open="!section.defaultCollapsed"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CollapsibleTrigger class="flex items-center justify-between w-full hover:bg-muted/50 -m-2 p-2 rounded">
|
||||||
|
<div>
|
||||||
|
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
|
||||||
|
<CardDescription v-if="section.description">
|
||||||
|
{{ section.description }}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</CardHeader>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent>
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<FieldRenderer
|
||||||
|
v-for="field in getFieldsBySection(section)"
|
||||||
|
:key="field.id"
|
||||||
|
:field="field"
|
||||||
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
|
:mode="ViewMode.DETAIL"
|
||||||
|
:base-url="baseUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<CardHeader v-if="section.title || section.description">
|
||||||
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
|
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
|
||||||
<CardDescription v-if="section.description">
|
<CardDescription v-if="section.description">
|
||||||
{{ section.description }}
|
{{ section.description }}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
</CollapsibleTrigger>
|
<CardContent>
|
||||||
</CardHeader>
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<CollapsibleContent>
|
<FieldRenderer
|
||||||
<CardContent>
|
v-for="field in getFieldsBySection(section)"
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
:key="field?.id"
|
||||||
<FieldRenderer
|
:field="field"
|
||||||
v-for="field in getFieldsBySection(section)"
|
:model-value="data[field.apiName]"
|
||||||
:key="field.id"
|
:record-data="data"
|
||||||
:field="field"
|
:mode="ViewMode.DETAIL"
|
||||||
:model-value="data[field.apiName]"
|
:base-url="baseUrl"
|
||||||
:record-data="data"
|
/>
|
||||||
:mode="ViewMode.DETAIL"
|
</div>
|
||||||
:base-url="baseUrl"
|
</CardContent>
|
||||||
/>
|
</template>
|
||||||
</div>
|
</Card>
|
||||||
</CardContent>
|
</div>
|
||||||
</CollapsibleContent>
|
</TabsContent>
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<template v-else>
|
<!-- Related Lists Tab -->
|
||||||
<CardHeader v-if="section.title || section.description">
|
<TabsContent value="related" class="space-y-6">
|
||||||
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
|
<div v-if="config.relatedLists && config.relatedLists.length > 0">
|
||||||
<CardDescription v-if="section.description">
|
<RelatedList
|
||||||
{{ section.description }}
|
v-for="relatedList in config.relatedLists"
|
||||||
</CardDescription>
|
:key="relatedList.relationName"
|
||||||
</CardHeader>
|
:config="relatedList"
|
||||||
<CardContent>
|
:parent-id="data.id"
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
:related-records="data[relatedList.relationName]"
|
||||||
<FieldRenderer
|
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
|
||||||
v-for="field in getFieldsBySection(section)"
|
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
|
||||||
:key="field?.id"
|
/>
|
||||||
:field="field"
|
</div>
|
||||||
:model-value="data[field.apiName]"
|
</TabsContent>
|
||||||
:record-data="data"
|
|
||||||
:mode="ViewMode.DETAIL"
|
<!-- Sharing Tab -->
|
||||||
:base-url="baseUrl"
|
<TabsContent value="sharing">
|
||||||
/>
|
<Card>
|
||||||
</div>
|
<CardContent class="pt-6">
|
||||||
|
<RecordSharing
|
||||||
|
v-if="data.id && config.objectApiName"
|
||||||
|
:object-api-name="config.objectApiName"
|
||||||
|
:record-id="data.id"
|
||||||
|
:owner-id="data.ownerId"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</template>
|
</Card>
|
||||||
</Card>
|
</TabsContent>
|
||||||
</div>
|
</Tabs>
|
||||||
|
|
||||||
<!-- Related Lists -->
|
|
||||||
<div v-if="config.relatedLists && config.relatedLists.length > 0" class="space-y-6">
|
|
||||||
<RelatedList
|
|
||||||
v-for="relatedList in config.relatedLists"
|
|
||||||
:key="relatedList.relationName"
|
|
||||||
:config="relatedList"
|
|
||||||
:parent-id="data.id"
|
|
||||||
:related-records="data[relatedList.relationName]"
|
|
||||||
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
|
|
||||||
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user