Files
LinkDesk/frontend/src/components/role/RoleFormDialog.vue
T
indigo 960753b3d6 Make note/submission edit-own and edit-others' permissions explicit
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>
2026-07-18 21:17:47 +08:00

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>