2 Commits

Author SHA1 Message Date
Francisco Gaona
c21274c86f WIP - record sharing 2025-12-28 21:31:02 +01:00
Francisco Gaona
358a216015 WIP - fix check for default writable 2025-12-28 20:39:44 +01:00
6 changed files with 651 additions and 135 deletions

View File

@@ -380,8 +380,10 @@ export class ObjectService {
}
// Check if field is writable (for authorization)
if (fieldDef.defaultWritable === false) {
this.logger.warn(`Field ${key} is not writable, skipping`);
// Support both snake_case (from DB) and camelCase (if mapped)
const defaultWritable = fieldDef.default_writable ?? fieldDef.defaultWritable;
if (defaultWritable === false || defaultWritable === 0) {
this.logger.warn(`Field ${key} is not writable (default_writable = ${defaultWritable}), skipping`);
continue;
}

View File

@@ -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 {}

View File

@@ -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 };
}

View 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,
}));
}
}

View 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>

View File

@@ -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"
/>
<!-- Detail View -->
<DetailView
v-else-if="view === 'detail' && detailConfig && currentRecord"
:config="detailConfig"
:data="currentRecord"
:loading="dataLoading"
:object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@edit="handleEdit"
@delete="() => handleDelete([currentRecord])"
@back="handleBack"
/>
<!-- 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
:config="detailConfig"
:data="currentRecord"
:loading="dataLoading"
:object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@edit="handleEdit"
@delete="() => handleDelete([currentRecord])"
@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 -->
<div v-else-if="(view === 'edit' || recordId === 'new') && editConfig">