349 lines
11 KiB
Vue
349 lines
11 KiB
Vue
<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>
|
|
<div class="flex gap-2">
|
|
<DatePicker
|
|
v-model="expiresDate"
|
|
placeholder="Select date"
|
|
class="flex-1"
|
|
/>
|
|
</div>
|
|
</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 DatePicker from '~/components/ui/date-picker/DatePicker.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: '',
|
|
});
|
|
|
|
const expiresDate = ref<Date | null>(null);
|
|
const expiresTime = ref('');
|
|
|
|
// Computed property to combine date and time into ISO string
|
|
const combinedExpiresAt = computed(() => {
|
|
if (!expiresDate.value) return '';
|
|
|
|
const date = new Date(expiresDate.value);
|
|
if (expiresTime.value) {
|
|
const [hours, minutes] = expiresTime.value.split(':');
|
|
date.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
|
} else {
|
|
date.setHours(23, 59, 59, 999); // Default to end of day
|
|
}
|
|
|
|
return date.toISOString();
|
|
});
|
|
|
|
// 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 expiresAtValue = combinedExpiresAt.value;
|
|
console.log('Creating share, expiresAt value:', expiresAtValue);
|
|
|
|
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 (expiresAtValue) {
|
|
payload.expiresAt = expiresAtValue;
|
|
console.log('Including expiresAt in payload:', payload.expiresAt);
|
|
} else {
|
|
console.log('Skipping expiresAt - no date selected');
|
|
}
|
|
|
|
console.log('Final payload:', payload);
|
|
|
|
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: '',
|
|
};
|
|
expiresDate.value = null;
|
|
expiresTime.value = '';
|
|
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>
|