Files
lunarfront-app/packages/admin/src/components/storage/folder-permissions-dialog.tsx
Ryan Moon f998b16a3f Add traverse access level for folder navigation without file access
When a permission is set on a nested folder, traverse is automatically
granted on all ancestor folders so users can navigate to it. Traverse
only shows subfolders in listings — files are hidden. This prevents
orphaned permissions where a user has access to a nested folder but
can't reach it.

Hierarchy: traverse < view < edit < admin
2026-03-29 18:04:24 -05:00

237 lines
9.7 KiB
TypeScript

import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
storageFolderPermissionsOptions, storageFolderMutations, storageFolderKeys,
} from '@/api/storage'
import { roleListOptions } from '@/api/rbac'
import { userListOptions } from '@/api/users'
import type { UserRecord } from '@/api/users'
import type { Role } from '@/types/rbac'
import type { StorageFolderPermission } from '@/types/storage'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Trash2, Shield, Users, User } from 'lucide-react'
import { toast } from 'sonner'
interface FolderPermissionsDialogProps {
folderId: string
folderName: string
isPublic: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
const ACCESS_LEVELS = [
{ value: 'traverse', label: 'Traverse', variant: 'outline' as const },
{ value: 'view', label: 'View', variant: 'secondary' as const },
{ value: 'edit', label: 'Edit', variant: 'default' as const },
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
]
export function FolderPermissionsDialog({ folderId, folderName, isPublic, open, onOpenChange }: FolderPermissionsDialogProps) {
const queryClient = useQueryClient()
const [assigneeType, setAssigneeType] = useState<'role' | 'user'>('role')
const [assigneeId, setAssigneeId] = useState('')
const [accessLevel, setAccessLevel] = useState('view')
const { data: permissionsData, isLoading: permsLoading } = useQuery({
...storageFolderPermissionsOptions(folderId),
enabled: open && !!folderId,
})
const { data: rolesData } = useQuery({ ...roleListOptions(), enabled: open })
const { data: usersData } = useQuery({
...userListOptions({ page: 1, limit: 100, order: 'asc' }),
enabled: open && assigneeType === 'user',
})
const permissions = permissionsData?.data ?? []
const roles = rolesData?.data ?? []
const users = usersData?.data ?? []
const togglePublicMutation = useMutation({
mutationFn: (newIsPublic: boolean) => storageFolderMutations.update(folderId, { isPublic: newIsPublic }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.all })
queryClient.invalidateQueries({ queryKey: storageFolderKeys.detail(folderId) })
toast.success(isPublic ? 'Folder set to private' : 'Folder set to public')
},
onError: (err) => toast.error(err.message),
})
const addPermissionMutation = useMutation({
mutationFn: () => storageFolderMutations.addPermission(folderId, {
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
accessLevel,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
setAssigneeId('')
setAccessLevel('view')
toast.success('Permission added')
},
onError: (err) => toast.error(err.message),
})
const removePermissionMutation = useMutation({
mutationFn: (permId: string) => storageFolderMutations.removePermission(permId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
toast.success('Permission removed')
},
onError: (err) => toast.error(err.message),
})
function getPermissionLabel(perm: StorageFolderPermission): { icon: typeof Shield; name: string } {
if (perm.roleId) {
const role = roles.find((r: Role) => r.id === perm.roleId)
return { icon: Users, name: role?.name ?? 'Unknown role' }
}
const user = users.find((u: UserRecord) => u.id === perm.userId)
return { icon: User, name: user ? `${user.firstName} ${user.lastName}` : 'Unknown user' }
}
function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!assigneeId) return
addPermissionMutation.mutate()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Permissions {folderName}
</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* Public toggle */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Public folder</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Public folders are viewable by all users with file access
</p>
</div>
<Switch
checked={isPublic}
onCheckedChange={(checked) => togglePublicMutation.mutate(checked)}
disabled={togglePublicMutation.isPending}
/>
</div>
{/* Current permissions */}
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Permissions
</Label>
<div className="mt-2 space-y-1.5">
{permsLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : permissions.length === 0 ? (
<p className="text-sm text-muted-foreground">No specific permissions assigned</p>
) : (
permissions.map((perm) => {
const { icon: Icon, name } = getPermissionLabel(perm)
const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel)
return (
<div key={perm.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={level?.variant ?? 'secondary'} className="text-xs">
{level?.label ?? perm.accessLevel}
</Badge>
<button
type="button"
onClick={() => removePermissionMutation.mutate(perm.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={removePermissionMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})
)}
</div>
</div>
{/* Add permission form */}
<form onSubmit={handleAdd} className="space-y-3">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Add Permission
</Label>
{/* Role / User toggle */}
<div className="flex gap-1 rounded-md border p-0.5">
<button
type="button"
onClick={() => { setAssigneeType('role'); setAssigneeId('') }}
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'role' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
>
Role
</button>
<button
type="button"
onClick={() => { setAssigneeType('user'); setAssigneeId('') }}
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'user' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
>
User
</button>
</div>
<div className="flex gap-2">
{/* Assignee select */}
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={assigneeType === 'role' ? 'Select role...' : 'Select user...'} />
</SelectTrigger>
<SelectContent>
{assigneeType === 'role'
? roles.map((role: Role) => (
<SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>
))
: users.map((user: UserRecord) => (
<SelectItem key={user.id} value={user.id}>
{user.firstName} {user.lastName}
</SelectItem>
))
}
</SelectContent>
</Select>
{/* Access level select */}
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESS_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>{level.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={!assigneeId || addPermissionMutation.isPending} className="w-full">
{addPermissionMutation.isPending ? 'Adding...' : 'Add Permission'}
</Button>
</form>
</div>
</DialogContent>
</Dialog>
)
}