Add folder permissions UI and WebDAV protocol support
Permissions UI: - FolderPermissionsDialog component with public/private toggle, role/user permission management, and access level badges - Integrated into file manager toolbar (visible for folder admins) - Backend returns accessLevel in folder detail endpoint WebDAV server: - Full WebDAV protocol at /webdav/ with Basic Auth (existing credentials) - PROPFIND, GET, PUT, DELETE, MKCOL, COPY, MOVE, LOCK/UNLOCK support - Permission-checked against existing folder access model - In-memory lock stubs for Windows client compatibility - 22 API integration tests covering all operations Also fixes canAccess to check folder creator (was missing).
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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: '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>
|
||||
)
|
||||
}
|
||||
33
packages/admin/src/components/ui/switch.tsx
Normal file
33
packages/admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -17,9 +17,10 @@ import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { FolderPlus, Upload, Search, Folder, ChevronRight, MoreVertical, Trash2, Download } from 'lucide-react'
|
||||
import { FolderPlus, Upload, Search, Folder, ChevronRight, MoreVertical, Trash2, Download, Shield } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { FolderPermissionsDialog } from '@/components/storage/folder-permissions-dialog'
|
||||
import type { StorageFolder, StorageFile } from '@/types/storage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/files/')({
|
||||
@@ -39,6 +40,7 @@ function FileManagerPage() {
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const [newFolderOpen, setNewFolderOpen] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
const [permissionsOpen, setPermissionsOpen] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { params } = usePagination()
|
||||
|
||||
@@ -193,6 +195,11 @@ function FileManagerPage() {
|
||||
|
||||
{selectedFolderId && (
|
||||
<>
|
||||
{folderDetail?.accessLevel === 'admin' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setPermissionsOpen(true)}>
|
||||
<Shield className="mr-2 h-4 w-4" />Permissions
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="mr-2 h-4 w-4" />Upload
|
||||
</Button>
|
||||
@@ -283,6 +290,16 @@ function FileManagerPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFolderId && folderDetail && (
|
||||
<FolderPermissionsDialog
|
||||
folderId={selectedFolderId}
|
||||
folderName={folderDetail.name ?? ''}
|
||||
isPublic={folderDetail.isPublic ?? true}
|
||||
open={permissionsOpen}
|
||||
onOpenChange={setPermissionsOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface StorageFolder {
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
breadcrumbs?: { id: string; name: string }[]
|
||||
accessLevel?: 'view' | 'edit' | 'admin' | null
|
||||
}
|
||||
|
||||
export interface StorageFolderPermission {
|
||||
|
||||
Reference in New Issue
Block a user