WIP - manually sharing records

This commit is contained in:
Francisco Gaona
2025-12-30 18:29:20 +01:00
parent 6c29d18696
commit e73126bcb7
9 changed files with 830 additions and 80 deletions

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

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

View File

@@ -2,9 +2,11 @@
import { computed, ref, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
import RelatedList from '@/components/RelatedList.vue'
import RecordSharing from '@/components/RecordSharing.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
@@ -20,11 +22,13 @@ interface Props {
loading?: boolean
objectId?: string // For fetching page layout
baseUrl?: string
showSharing?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
baseUrl: '/runtime/objects',
showSharing: true,
})
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>
<!-- Content with Page Layout -->
<Card v-else-if="usePageLayout">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<PageLayoutRenderer
:fields="config.fields"
:layout="pageLayout"
:model-value="data"
:readonly="true"
/>
</CardContent>
</Card>
<!-- Tabs for Details, Related, and Sharing -->
<Tabs v-else default-value="details" class="space-y-6">
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger v-if="config.relatedLists && config.relatedLists.length > 0" value="related">
Related
</TabsTrigger>
<TabsTrigger v-if="showSharing && data.id" value="sharing">
Sharing
</TabsTrigger>
</TabsList>
<!-- 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"
>
<!-- Details Tab -->
<TabsContent value="details" class="space-y-6">
<!-- Content with Page Layout -->
<Card v-if="usePageLayout">
<CardHeader>
<CollapsibleTrigger class="flex items-center justify-between w-full hover:bg-muted/50 -m-2 p-2 rounded">
<div>
<CardTitle>Details</CardTitle>
</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>
<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>
</CardHeader>
<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>
</template>
</Card>
</div>
</TabsContent>
<template v-else>
<CardHeader v-if="section.title || section.description">
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
<CardDescription v-if="section.description">
{{ section.description }}
</CardDescription>
</CardHeader>
<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>
<!-- Related Lists Tab -->
<TabsContent value="related" class="space-y-6">
<div v-if="config.relatedLists && config.relatedLists.length > 0">
<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>
</TabsContent>
<!-- Sharing Tab -->
<TabsContent value="sharing">
<Card>
<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>
</template>
</Card>
</div>
<!-- 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>
</Card>
</TabsContent>
</Tabs>
</div>
</template>