960753b3d6
Split note:edit/note:delete and submission:edit/submission:delete into four independent permissions each - edit_self/delete_self (acting on your own note or submission) and edit_other/delete_other (acting on someone else's). Previously "own" access was an unconditional, unrevokable ownership check with no permission behind it, and a prior round had accidentally granted coordinator submission:edit/delete by default (inconsistent with notes, which were correctly own-only) - both are fixed here: self-service now goes through a real, default-granted-to-everyone permission, and acting on someone else's note/submission is an explicit elevated grant that nobody gets by default. The Role Management permission editor now shows "Edit Own / Delete Own / Edit Others' / Delete Others'" as four clear, independently toggleable options instead of one ambiguous "Edit"/"Delete" checkbox. migrate_role_permissions.py renames the existing permission rows in place (rather than leaving orphaned duplicates) and includes a one-time, idempotent correction that revokes the earlier over-grant from coordinator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
231 lines
8.3 KiB
Vue
231 lines
8.3 KiB
Vue
<template>
|
|
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
|
<DialogContent class="sm:max-w-2xl max-h-[85vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>{{ role ? 'Edit Role' : 'Create Role' }}</DialogTitle>
|
|
<DialogDescription>
|
|
{{ role?.is_system
|
|
? 'System role permissions are editable, but the name and description cannot be changed.'
|
|
: 'Define a role and the permissions it grants.' }}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form @submit.prevent="handleSubmit" class="flex-1 min-h-0 flex flex-col gap-4">
|
|
<div class="grid grid-cols-2 gap-4 flex-shrink-0">
|
|
<div class="space-y-2">
|
|
<Label for="role_name">Name</Label>
|
|
<Input
|
|
id="role_name"
|
|
v-model="formData.name"
|
|
placeholder="e.g. Reviewer"
|
|
required
|
|
:disabled="role?.is_system"
|
|
/>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="role_description">Description</Label>
|
|
<Input
|
|
id="role_description"
|
|
v-model="formData.description"
|
|
placeholder="What this role is for"
|
|
:disabled="role?.is_system"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 min-h-0 flex flex-col gap-2">
|
|
<div class="flex items-center justify-between flex-shrink-0">
|
|
<Label>Permissions</Label>
|
|
<span class="text-xs text-muted-foreground">{{ selectedPermissionIds.size }} of {{ permissions.length }} selected</span>
|
|
</div>
|
|
|
|
<div class="flex-1 min-h-0 overflow-y-auto rounded-md border p-3 space-y-4">
|
|
<div v-for="group in permissionGroups" :key="group.title" class="space-y-2">
|
|
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ group.title }}</h4>
|
|
<div class="space-y-2">
|
|
<div v-for="resource in group.resources" :key="resource" class="rounded-md border bg-muted/30 p-2.5">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-2 text-sm font-medium capitalize">
|
|
<component :is="resourceIcon(resource)" class="h-4 w-4 text-muted-foreground" />
|
|
{{ resource }}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
|
@click="toggleAllForResource(resource)"
|
|
>
|
|
{{ allSelectedForResource(resource) ? 'Clear' : 'Select all' }}
|
|
</button>
|
|
</div>
|
|
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
|
<label
|
|
v-for="perm in permissionsForResource(resource)"
|
|
:key="perm.id"
|
|
class="flex items-center gap-1.5 text-sm cursor-pointer"
|
|
:title="perm.description || undefined"
|
|
>
|
|
<Checkbox
|
|
:model-value="selectedPermissionIds.has(perm.id)"
|
|
@update:model-value="(val) => togglePermission(perm.id, !!val)"
|
|
/>
|
|
{{ actionLabel(perm.action) }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="formError" class="text-sm text-destructive flex-shrink-0">{{ formError }}</p>
|
|
|
|
<DialogFooter class="flex-shrink-0">
|
|
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
|
|
<Button type="submit" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import {
|
|
Package, Camera, ListTodo, UserCheck, CheckCircle2, UploadCloud, Paperclip, MessageSquare, Shield
|
|
} from 'lucide-vue-next'
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
|
} 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'
|
|
import type { Role, Permission } from '@/services/role'
|
|
|
|
interface Props {
|
|
open: boolean
|
|
role: Role | null
|
|
permissions: Permission[]
|
|
saving?: boolean
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<{
|
|
'update:open': [value: boolean]
|
|
saved: [data: { name?: string; description?: string; permission_ids: number[] }]
|
|
}>()
|
|
|
|
const formData = ref({ name: '', description: '' })
|
|
const selectedPermissionIds = ref<Set<number>>(new Set())
|
|
const formError = ref('')
|
|
|
|
// Resources grouped into scannable sections, in a fixed, deliberate order
|
|
// (not alphabetical) so related concepts sit together.
|
|
const RESOURCE_GROUPS: { title: string; resources: string[] }[] = [
|
|
{ title: 'Production', resources: ['asset', 'shot', 'task'] },
|
|
{ title: 'Task workflow', resources: ['assignment', 'submission', 'upload'] },
|
|
{ title: 'Review', resources: ['review'] },
|
|
{ title: 'Notes', resources: ['note'] },
|
|
]
|
|
|
|
const RESOURCE_ICONS: Record<string, any> = {
|
|
asset: Package,
|
|
shot: Camera,
|
|
task: ListTodo,
|
|
assignment: UserCheck,
|
|
submission: UploadCloud,
|
|
upload: Paperclip,
|
|
review: CheckCircle2,
|
|
note: MessageSquare,
|
|
}
|
|
|
|
const ACTION_LABELS: Record<string, string> = {
|
|
create: 'Create',
|
|
edit: 'Edit',
|
|
delete: 'Delete',
|
|
publish: 'Approve',
|
|
retake: 'Request Retake',
|
|
view_internal: 'View Internal',
|
|
view_client: 'View Client',
|
|
change_status: 'Change Status',
|
|
edit_self: 'Edit Own',
|
|
delete_self: 'Delete Own',
|
|
edit_other: "Edit Others'",
|
|
delete_other: "Delete Others'",
|
|
}
|
|
|
|
function resourceIcon(resource: string) {
|
|
return RESOURCE_ICONS[resource] ?? Shield
|
|
}
|
|
|
|
function actionLabel(action: string): string {
|
|
return ACTION_LABELS[action] ?? action
|
|
}
|
|
|
|
// Only show groups/resources that actually have permissions in the catalog,
|
|
// and fall back to a catch-all group for any future resource not yet
|
|
// assigned to a section above (so nothing silently disappears from the UI).
|
|
const permissionGroups = computed(() => {
|
|
const knownResources = new Set(RESOURCE_GROUPS.flatMap(g => g.resources))
|
|
const groups = RESOURCE_GROUPS
|
|
.map(g => ({ title: g.title, resources: g.resources.filter(r => permissionsForResource(r).length > 0) }))
|
|
.filter(g => g.resources.length > 0)
|
|
|
|
const otherResources = [...new Set(props.permissions.map(p => p.resource))].filter(r => !knownResources.has(r))
|
|
if (otherResources.length > 0) {
|
|
groups.push({ title: 'Other', resources: otherResources })
|
|
}
|
|
return groups
|
|
})
|
|
|
|
function permissionsForResource(resource: string): Permission[] {
|
|
return props.permissions.filter(p => p.resource === resource)
|
|
}
|
|
|
|
function allSelectedForResource(resource: string): boolean {
|
|
const perms = permissionsForResource(resource)
|
|
return perms.length > 0 && perms.every(p => selectedPermissionIds.value.has(p.id))
|
|
}
|
|
|
|
function toggleAllForResource(resource: string) {
|
|
const perms = permissionsForResource(resource)
|
|
const shouldSelect = !allSelectedForResource(resource)
|
|
for (const perm of perms) {
|
|
if (shouldSelect) selectedPermissionIds.value.add(perm.id)
|
|
else selectedPermissionIds.value.delete(perm.id)
|
|
}
|
|
}
|
|
|
|
function togglePermission(id: number, checked: boolean) {
|
|
if (checked) selectedPermissionIds.value.add(id)
|
|
else selectedPermissionIds.value.delete(id)
|
|
}
|
|
|
|
watch(() => props.open, (isOpen) => {
|
|
if (!isOpen) return
|
|
formError.value = ''
|
|
formData.value = {
|
|
name: props.role?.name ?? '',
|
|
description: props.role?.description ?? ''
|
|
}
|
|
selectedPermissionIds.value = new Set(props.role?.permissions.map(p => p.id) ?? [])
|
|
})
|
|
|
|
function handleSubmit() {
|
|
formError.value = ''
|
|
if (!props.role && !formData.value.name.trim()) {
|
|
formError.value = 'Name is required'
|
|
return
|
|
}
|
|
|
|
const payload = props.role?.is_system
|
|
? { permission_ids: [...selectedPermissionIds.value] }
|
|
: {
|
|
name: formData.value.name,
|
|
description: formData.value.description || undefined,
|
|
permission_ids: [...selectedPermissionIds.value]
|
|
}
|
|
emit('saved', payload)
|
|
}
|
|
</script>
|