WIP - permissions
This commit is contained in:
262
frontend/components/ObjectAccessSettings.vue
Normal file
262
frontend/components/ObjectAccessSettings.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div v-if="loading" class="text-center py-8">Loading access settings...</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Global Access Model -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Global Access Model</CardTitle>
|
||||
<CardDescription>
|
||||
Define the default access control model for this object
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Access Model</Label>
|
||||
<Select v-model="accessModel">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select access model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public">Public - Anyone can access</SelectItem>
|
||||
<SelectItem value="owner">Owner Only - Only record owner can access</SelectItem>
|
||||
<SelectItem value="mixed">Mixed - Owner plus role/share-based access</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span v-if="accessModel === 'public'">
|
||||
All users can access records by default
|
||||
</span>
|
||||
<span v-else-if="accessModel === 'owner'">
|
||||
Only the record owner can access records
|
||||
</span>
|
||||
<span v-else-if="accessModel === 'mixed'">
|
||||
Record owner has access, plus role-based and sharing rules apply
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Owner Field</Label>
|
||||
<Input v-model="ownerField" placeholder="ownerId" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
The field name that stores the record owner's ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label>Public Permissions</Label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="public-read"
|
||||
v-model:checked="publicRead"
|
||||
/>
|
||||
<Label for="public-read" class="cursor-pointer font-normal">Public Read</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="public-create"
|
||||
v-model:checked="publicCreate"
|
||||
/>
|
||||
<Label for="public-create" class="cursor-pointer font-normal">Public Create</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="public-update"
|
||||
v-model:checked="publicUpdate"
|
||||
/>
|
||||
<Label for="public-update" class="cursor-pointer font-normal">Public Update</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="public-delete"
|
||||
v-model:checked="publicDelete"
|
||||
/>
|
||||
<Label for="public-delete" class="cursor-pointer font-normal">Public Delete</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Field-Level Permissions -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Field-Level Permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Set default read/write permissions for individual fields
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.apiName"
|
||||
class="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ field.label }}</div>
|
||||
<div class="text-sm text-muted-foreground">{{ field.apiName }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:id="`${field.apiName}-read`"
|
||||
:checked="getFieldPermission(field.apiName, 'read')"
|
||||
@update:checked="(val) => setFieldPermission(field.apiName, 'read', val)"
|
||||
/>
|
||||
<Label :for="`${field.apiName}-read`" class="cursor-pointer">Read</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:id="`${field.apiName}-write`"
|
||||
:checked="getFieldPermission(field.apiName, 'write')"
|
||||
@update:checked="(val) => setFieldPermission(field.apiName, 'write', val)"
|
||||
/>
|
||||
<Label :for="`${field.apiName}-write`" class="cursor-pointer">Write</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<Button @click="saveChanges" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Changes' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
interface Props {
|
||||
objectApiName: string
|
||||
fields: any[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['updated'])
|
||||
|
||||
const { api } = useApi()
|
||||
const { toast } = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const accessModel = ref<string>('owner')
|
||||
const publicRead = ref<boolean>(false)
|
||||
const publicCreate = ref<boolean>(false)
|
||||
const publicUpdate = ref<boolean>(false)
|
||||
const publicDelete = ref<boolean>(false)
|
||||
const ownerField = ref<string>('ownerId')
|
||||
|
||||
const fieldPermissions = ref<Record<string, { defaultReadable: boolean; defaultWritable: boolean }>>({})
|
||||
|
||||
const fetchAccessConfig = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await api.get(`/setup/objects/${props.objectApiName}/access`)
|
||||
|
||||
accessModel.value = data.accessModel || 'owner'
|
||||
publicRead.value = Boolean(data.publicRead)
|
||||
publicCreate.value = Boolean(data.publicCreate)
|
||||
publicUpdate.value = Boolean(data.publicUpdate)
|
||||
publicDelete.value = Boolean(data.publicDelete)
|
||||
ownerField.value = data.ownerField || 'ownerId'
|
||||
|
||||
// Initialize field permissions from field definitions
|
||||
fieldPermissions.value = {}
|
||||
if (data.fields && data.fields.length > 0) {
|
||||
data.fields.forEach((field: any) => {
|
||||
fieldPermissions.value[field.apiName] = {
|
||||
defaultReadable: Boolean(field.defaultReadable ?? true),
|
||||
defaultWritable: Boolean(field.defaultWritable ?? true),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Initialize all fields with default permissions
|
||||
props.fields.forEach((field) => {
|
||||
fieldPermissions.value[field.apiName] = {
|
||||
defaultReadable: true,
|
||||
defaultWritable: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Error fetching access config:', e)
|
||||
toast.error('Failed to load access settings')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getFieldPermission = (fieldKey: string, type: 'read' | 'write'): boolean => {
|
||||
const perms = fieldPermissions.value[fieldKey]
|
||||
if (!perms) return true
|
||||
const value = type === 'read' ? perms.defaultReadable : perms.defaultWritable
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
const setFieldPermission = (fieldKey: string, type: 'read' | 'write', value: boolean) => {
|
||||
if (!fieldPermissions.value[fieldKey]) {
|
||||
fieldPermissions.value[fieldKey] = { defaultReadable: true, defaultWritable: true }
|
||||
}
|
||||
if (type === 'read') {
|
||||
fieldPermissions.value[fieldKey].defaultReadable = Boolean(value)
|
||||
} else {
|
||||
fieldPermissions.value[fieldKey].defaultWritable = Boolean(value)
|
||||
}
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
// Ensure all values are proper booleans
|
||||
const payload = {
|
||||
accessModel: accessModel.value,
|
||||
publicRead: Boolean(publicRead.value),
|
||||
publicCreate: Boolean(publicCreate.value),
|
||||
publicUpdate: Boolean(publicUpdate.value),
|
||||
publicDelete: Boolean(publicDelete.value),
|
||||
ownerField: ownerField.value,
|
||||
}
|
||||
|
||||
// Update global access config
|
||||
await api.put(`/setup/objects/${props.objectApiName}/access`, payload)
|
||||
|
||||
// Update field permissions
|
||||
const fieldPermsArray = Object.entries(fieldPermissions.value).map(([fieldKey, perms]) => ({
|
||||
fieldKey,
|
||||
defaultReadable: perms.defaultReadable,
|
||||
defaultWritable: perms.defaultWritable,
|
||||
}))
|
||||
|
||||
await api.put(`/setup/objects/${props.objectApiName}/field-permissions`, fieldPermsArray)
|
||||
|
||||
toast.success('Access settings saved successfully')
|
||||
emit('updated')
|
||||
} catch (e: any) {
|
||||
console.error('Error saving access config:', e)
|
||||
toast.error('Failed to save access settings')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAccessConfig()
|
||||
})
|
||||
</script>
|
||||
284
frontend/components/RecordShareDialog.vue
Normal file
284
frontend/components/RecordShareDialog.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="handleClose">
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share Record</DialogTitle>
|
||||
<DialogDescription>
|
||||
Grant access to this record to other users
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-6 py-4">
|
||||
<!-- Existing Shares -->
|
||||
<div v-if="shares.length > 0" class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">Current Shares</h3>
|
||||
<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">{{ share.granteeUser?.email || 'Unknown User' }}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Permissions: {{ share.actions.join(', ') }}
|
||||
<span v-if="share.fields">(Limited fields)</span>
|
||||
</div>
|
||||
<div v-if="share.expiresAt" class="text-xs text-muted-foreground">
|
||||
Expires: {{ formatDate(share.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleRevokeShare(share.id)"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Share Form -->
|
||||
<div class="space-y-4 border-t pt-4">
|
||||
<h3 class="text-sm font-semibold">Add New Share</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>User Email</Label>
|
||||
<Input
|
||||
v-model="newShare.userEmail"
|
||||
placeholder="user@example.com"
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Permissions</Label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="perm-read"
|
||||
:checked="newShare.permissions.read"
|
||||
@update:checked="(val) => newShare.permissions.read = val"
|
||||
/>
|
||||
<Label for="perm-read" class="cursor-pointer">Read</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="perm-update"
|
||||
:checked="newShare.permissions.update"
|
||||
@update:checked="(val) => newShare.permissions.update = val"
|
||||
/>
|
||||
<Label for="perm-update" class="cursor-pointer">Update</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="field-scoped"
|
||||
:checked="newShare.fieldScoped"
|
||||
@update:checked="(val) => newShare.fieldScoped = val"
|
||||
/>
|
||||
<Label for="field-scoped" class="cursor-pointer">Limit to specific fields</Label>
|
||||
</div>
|
||||
|
||||
<div v-if="newShare.fieldScoped" class="ml-6 space-y-2 border-l-2 pl-4">
|
||||
<Label class="text-sm">Select Fields</Label>
|
||||
<div class="space-y-1 max-h-48 overflow-y-auto">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.apiName"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
:id="`field-${field.apiName}`"
|
||||
:checked="newShare.selectedFields.includes(field.apiName)"
|
||||
@update:checked="(val) => handleFieldToggle(field.apiName, val)"
|
||||
/>
|
||||
<Label :for="`field-${field.apiName}`" class="cursor-pointer text-sm">
|
||||
{{ field.label }}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="has-expiry"
|
||||
:checked="newShare.hasExpiry"
|
||||
@update:checked="(val) => newShare.hasExpiry = val"
|
||||
/>
|
||||
<Label for="has-expiry" class="cursor-pointer">Set expiration date</Label>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-if="newShare.hasExpiry"
|
||||
v-model="newShare.expiryDate"
|
||||
type="datetime-local"
|
||||
class="ml-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="handleClose">Cancel</Button>
|
||||
<Button @click="handleAddShare" :disabled="!canAddShare || saving">
|
||||
{{ saving ? 'Sharing...' : 'Share' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
objectDefinitionId: string
|
||||
recordId: string
|
||||
fields?: any[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fields: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'shared'])
|
||||
|
||||
const { api } = useApi()
|
||||
const { toast } = useToast()
|
||||
|
||||
const shares = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const newShare = ref({
|
||||
userEmail: '',
|
||||
permissions: {
|
||||
read: true,
|
||||
update: false,
|
||||
},
|
||||
fieldScoped: false,
|
||||
selectedFields: [] as string[],
|
||||
hasExpiry: false,
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const canAddShare = computed(() => {
|
||||
return newShare.value.userEmail && (newShare.value.permissions.read || newShare.value.permissions.update)
|
||||
})
|
||||
|
||||
const fetchShares = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
shares.value = await api.get(`/shares/record/${props.objectDefinitionId}/${props.recordId}`)
|
||||
} catch (e: any) {
|
||||
console.error('Error fetching shares:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFieldToggle = (fieldKey: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!newShare.value.selectedFields.includes(fieldKey)) {
|
||||
newShare.value.selectedFields.push(fieldKey)
|
||||
}
|
||||
} else {
|
||||
newShare.value.selectedFields = newShare.value.selectedFields.filter(f => f !== fieldKey)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddShare = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
// First, find user by email (you'll need an endpoint for this)
|
||||
// For now, we'll assume the email is actually a user ID
|
||||
const actions = []
|
||||
if (newShare.value.permissions.read) actions.push('read')
|
||||
if (newShare.value.permissions.update) actions.push('update')
|
||||
|
||||
const payload: any = {
|
||||
objectDefinitionId: props.objectDefinitionId,
|
||||
recordId: props.recordId,
|
||||
granteeUserId: newShare.value.userEmail, // Should be user ID, not email
|
||||
actions,
|
||||
}
|
||||
|
||||
if (newShare.value.fieldScoped && newShare.value.selectedFields.length > 0) {
|
||||
payload.fields = newShare.value.selectedFields
|
||||
}
|
||||
|
||||
if (newShare.value.hasExpiry && newShare.value.expiryDate) {
|
||||
payload.expiresAt = new Date(newShare.value.expiryDate).toISOString()
|
||||
}
|
||||
|
||||
await api.post('/shares', payload)
|
||||
|
||||
toast.success('Record shared successfully')
|
||||
await fetchShares()
|
||||
|
||||
// Reset form
|
||||
newShare.value = {
|
||||
userEmail: '',
|
||||
permissions: { read: true, update: false },
|
||||
fieldScoped: false,
|
||||
selectedFields: [],
|
||||
hasExpiry: false,
|
||||
expiryDate: '',
|
||||
}
|
||||
|
||||
emit('shared')
|
||||
} catch (e: any) {
|
||||
console.error('Error creating share:', e)
|
||||
toast.error('Failed to share record')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevokeShare = async (shareId: string) => {
|
||||
if (!confirm('Are you sure you want to revoke this share?')) return
|
||||
|
||||
try {
|
||||
await api.delete(`/shares/${shareId}`)
|
||||
toast.success('Share revoked successfully')
|
||||
await fetchShares()
|
||||
emit('shared')
|
||||
} catch (e: any) {
|
||||
console.error('Error revoking share:', e)
|
||||
toast.error('Failed to revoke share')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
fetchShares()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
265
frontend/components/RolePermissionsEditor.vue
Normal file
265
frontend/components/RolePermissionsEditor.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div v-if="loading" class="text-center py-8">Loading...</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Object Permissions -->
|
||||
<div
|
||||
v-for="obj in objects"
|
||||
:key="obj.id"
|
||||
class="border rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">{{ obj.label }}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="toggleObjectExpanded(obj.id)"
|
||||
>
|
||||
{{ expandedObjects[obj.id] ? 'Collapse' : 'Expand' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedObjects[obj.id]" class="space-y-4">
|
||||
<!-- CRUD Permissions -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:id="`${obj.id}-read`"
|
||||
:checked="hasPermission(obj.apiName, 'read')"
|
||||
@update:checked="(val) => setPermission(obj.apiName, 'read', val)"
|
||||
/>
|
||||
<Label :for="`${obj.id}-read`" class="cursor-pointer">Read</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:id="`${obj.id}-create`"
|
||||
:checked="hasPermission(obj.apiName, 'create')"
|
||||
@update:checked="(val) => setPermission(obj.apiName, 'create', val)"
|
||||
/>
|
||||
<Label :for="`${obj.id}-create`" class="cursor-pointer">Create</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:id="`${obj.id}-update`"
|
||||
:checked="hasPermission(obj.apiName, 'update')"
|
||||
@update:checked="(val) => setPermission(obj.apiName, 'update', val)"
|
||||
/>
|
||||
<Label :for="`${obj.id}-update`" class="cursor-pointer">Update</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:id="`${obj.id}-delete`"
|
||||
:checked="hasPermission(obj.apiName, 'delete')"
|
||||
@update:checked="(val) => setPermission(obj.apiName, 'delete', val)"
|
||||
/>
|
||||
<Label :for="`${obj.id}-delete`" class="cursor-pointer">Delete</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced: Condition-based permissions -->
|
||||
<div class="border-t pt-3">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<Checkbox
|
||||
:id="`${obj.id}-conditions`"
|
||||
:checked="hasConditions(obj.apiName)"
|
||||
@update:checked="(val) => toggleConditions(obj.apiName, val)"
|
||||
/>
|
||||
<Label :for="`${obj.id}-conditions`" class="cursor-pointer text-sm">
|
||||
Apply conditions (e.g., own records only)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div v-if="hasConditions(obj.apiName)" class="ml-6 space-y-2">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Only allow access to records where:
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="getConditions(obj.apiName).field"
|
||||
placeholder="Field name (e.g., ownerId)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Input
|
||||
v-model="getConditions(obj.apiName).value"
|
||||
placeholder="Value (e.g., $userId)"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
|
||||
<Button @click="savePermissions" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Permissions' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
|
||||
interface Props {
|
||||
role: any
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['saved', 'cancel'])
|
||||
|
||||
const { api } = useApi()
|
||||
const { toast } = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const objects = ref<any[]>([])
|
||||
const expandedObjects = ref<Record<string, boolean>>({})
|
||||
|
||||
// Store permissions as CASL-like rules
|
||||
const permissions = ref<Record<string, {
|
||||
actions: string[]
|
||||
conditions?: any
|
||||
}>>({})
|
||||
|
||||
const fetchObjects = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
objects.value = await api.get('/setup/objects')
|
||||
|
||||
// Expand all objects by default
|
||||
objects.value.forEach(obj => {
|
||||
expandedObjects.value[obj.id] = true
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('Error fetching objects:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRolePermissions = async () => {
|
||||
try {
|
||||
const rules = await api.get(`/role-rules/role/${props.role.id}`)
|
||||
// Parse existing rules into our format
|
||||
if (rules && rules.length > 0 && rules[0].rulesJson) {
|
||||
const rulesJson = rules[0].rulesJson
|
||||
rulesJson.forEach((rule: any) => {
|
||||
if (!permissions.value[rule.subject]) {
|
||||
permissions.value[rule.subject] = { actions: [] }
|
||||
}
|
||||
if (Array.isArray(rule.action)) {
|
||||
permissions.value[rule.subject].actions.push(...rule.action)
|
||||
} else {
|
||||
permissions.value[rule.subject].actions.push(rule.action)
|
||||
}
|
||||
if (rule.conditions) {
|
||||
permissions.value[rule.subject].conditions = rule.conditions
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Error fetching role permissions:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleObjectExpanded = (objectId: string) => {
|
||||
expandedObjects.value[objectId] = !expandedObjects.value[objectId]
|
||||
}
|
||||
|
||||
const hasPermission = (subject: string, action: string): boolean => {
|
||||
return permissions.value[subject]?.actions.includes(action) || false
|
||||
}
|
||||
|
||||
const setPermission = (subject: string, action: string, value: boolean) => {
|
||||
if (!permissions.value[subject]) {
|
||||
permissions.value[subject] = { actions: [] }
|
||||
}
|
||||
|
||||
if (value) {
|
||||
if (!permissions.value[subject].actions.includes(action)) {
|
||||
permissions.value[subject].actions.push(action)
|
||||
}
|
||||
} else {
|
||||
permissions.value[subject].actions = permissions.value[subject].actions.filter(a => a !== action)
|
||||
}
|
||||
}
|
||||
|
||||
const hasConditions = (subject: string): boolean => {
|
||||
return !!permissions.value[subject]?.conditions
|
||||
}
|
||||
|
||||
const toggleConditions = (subject: string, value: boolean) => {
|
||||
if (!permissions.value[subject]) {
|
||||
permissions.value[subject] = { actions: [] }
|
||||
}
|
||||
|
||||
if (value) {
|
||||
permissions.value[subject].conditions = { field: 'ownerId', value: '$userId' }
|
||||
} else {
|
||||
delete permissions.value[subject].conditions
|
||||
}
|
||||
}
|
||||
|
||||
const getConditions = (subject: string) => {
|
||||
if (!permissions.value[subject]?.conditions) {
|
||||
return { field: '', value: '' }
|
||||
}
|
||||
const cond = permissions.value[subject].conditions
|
||||
// Convert CASL condition format to simple field/value
|
||||
const field = Object.keys(cond)[0] || ''
|
||||
const value = cond[field] || ''
|
||||
return { field, value }
|
||||
}
|
||||
|
||||
const savePermissions = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
// Convert our permission structure to CASL rules format
|
||||
const rules: any[] = []
|
||||
|
||||
Object.entries(permissions.value).forEach(([subject, perm]) => {
|
||||
if (perm.actions.length > 0) {
|
||||
const rule: any = {
|
||||
action: perm.actions,
|
||||
subject,
|
||||
}
|
||||
|
||||
if (perm.conditions) {
|
||||
const cond = getConditions(subject)
|
||||
if (cond.field && cond.value) {
|
||||
rule.conditions = { [cond.field]: cond.value }
|
||||
}
|
||||
}
|
||||
|
||||
rules.push(rule)
|
||||
}
|
||||
})
|
||||
|
||||
await api.post('/role-rules', {
|
||||
roleId: props.role.id,
|
||||
rulesJson: rules,
|
||||
})
|
||||
|
||||
emit('saved')
|
||||
} catch (e: any) {
|
||||
console.error('Error saving permissions:', e)
|
||||
toast.error('Failed to save permissions')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchObjects()
|
||||
await fetchRolePermissions()
|
||||
})
|
||||
</script>
|
||||
@@ -1,30 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
interface Props {
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
name?: string
|
||||
value?: string
|
||||
id?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
required: false,
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const emit = defineEmits<{
|
||||
'update:checked': [value: boolean]
|
||||
}>()
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:checked', target.checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('grid place-content-center 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="grid place-content-center text-current">
|
||||
<slot>
|
||||
<Check class="h-4 w-4" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
<div class="relative inline-flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="props.id"
|
||||
:checked="props.checked"
|
||||
:disabled="props.disabled"
|
||||
:required="props.required"
|
||||
:name="props.name"
|
||||
:value="props.value"
|
||||
@change="handleChange"
|
||||
: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 cursor-pointer',
|
||||
'appearance-none bg-background',
|
||||
'checked:bg-primary checked:border-primary',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Check
|
||||
v-if="props.checked"
|
||||
class="absolute h-4 w-4 text-primary-foreground pointer-events-none"
|
||||
:class="{ 'opacity-50': props.disabled }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
<!-- Tabs -->
|
||||
<div class="mb-8">
|
||||
<Tabs v-model="activeTab" default-value="fields" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2 max-w-md">
|
||||
<TabsList class="grid w-full grid-cols-3 max-w-2xl">
|
||||
<TabsTrigger value="fields">Fields</TabsTrigger>
|
||||
<TabsTrigger value="layouts">Page Layouts</TabsTrigger>
|
||||
<TabsTrigger value="access">Access & Permissions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Fields Tab -->
|
||||
@@ -125,6 +126,15 @@
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Access & Permissions Tab -->
|
||||
<TabsContent value="access" class="mt-6">
|
||||
<ObjectAccessSettings
|
||||
:object-api-name="object.apiName"
|
||||
:fields="object.fields"
|
||||
@updated="fetchObject"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,6 +148,7 @@ import { Plus, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
|
||||
import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue'
|
||||
import type { PageLayout, FieldLayoutItem } from '~/types/page-layout'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
185
frontend/pages/setup/roles.vue
Normal file
185
frontend/pages/setup/roles.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<NuxtLayout name="default">
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Roles & Permissions</h1>
|
||||
<p class="text-muted-foreground">Manage user roles and their permissions across objects</p>
|
||||
</div>
|
||||
<Button @click="showCreateDialog = true">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
New Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">Loading roles...</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<Card
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
class="cursor-pointer hover:border-primary transition-colors"
|
||||
@click="handleSelectRole(role)"
|
||||
>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{{ role.name }}</CardTitle>
|
||||
<CardDescription v-if="role.description">
|
||||
{{ role.description }}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click.stop="handleDeleteRole(role.id)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div v-if="roles.length === 0" class="text-center py-12 text-muted-foreground">
|
||||
No roles yet. Create one to get started.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Role Dialog -->
|
||||
<Dialog v-model:open="showCreateDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Role</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define a new role for your organization
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Role Name</Label>
|
||||
<Input v-model="newRole.name" placeholder="e.g., Account Manager" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Input v-model="newRole.description" placeholder="Optional description" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showCreateDialog = false">Cancel</Button>
|
||||
<Button @click="handleCreateRole" :disabled="!newRole.name || creating">
|
||||
{{ creating ? 'Creating...' : 'Create' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Role Permissions Editor Dialog -->
|
||||
<Dialog v-model:open="showPermissionsDialog">
|
||||
<DialogContent class="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Permissions: {{ selectedRole?.name }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure what this role can do with each object
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RolePermissionsEditor
|
||||
v-if="selectedRole"
|
||||
:role="selectedRole"
|
||||
@saved="handlePermissionsSaved"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import RolePermissionsEditor from '@/components/RolePermissionsEditor.vue'
|
||||
|
||||
const { api } = useApi()
|
||||
const { toast } = useToast()
|
||||
|
||||
const roles = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const creating = ref(false)
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const showPermissionsDialog = ref(false)
|
||||
const selectedRole = ref<any>(null)
|
||||
|
||||
const newRole = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
roles.value = await api.get('/roles')
|
||||
} catch (e: any) {
|
||||
console.error('Error fetching roles:', e)
|
||||
toast.error('Failed to load roles')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateRole = async () => {
|
||||
try {
|
||||
creating.value = true
|
||||
const created = await api.post('/roles', newRole.value)
|
||||
roles.value.push(created)
|
||||
toast.success('Role created successfully')
|
||||
showCreateDialog.value = false
|
||||
newRole.value = { name: '', description: '' }
|
||||
} catch (e: any) {
|
||||
console.error('Error creating role:', e)
|
||||
toast.error('Failed to create role')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectRole = (role: any) => {
|
||||
selectedRole.value = role
|
||||
showPermissionsDialog.value = true
|
||||
}
|
||||
|
||||
const handleDeleteRole = async (roleId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this role?')) return
|
||||
|
||||
try {
|
||||
await api.delete(`/roles/${roleId}`)
|
||||
roles.value = roles.value.filter(r => r.id !== roleId)
|
||||
toast.success('Role deleted successfully')
|
||||
} catch (e: any) {
|
||||
console.error('Error deleting role:', e)
|
||||
toast.error('Failed to delete role')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermissionsSaved = () => {
|
||||
showPermissionsDialog.value = false
|
||||
toast.success('Permissions saved successfully')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user