Add vault category permissions dialog with role/user management
This commit is contained in:
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { vaultCategoryPermissionsOptions, vaultCategoryMutations, vaultKeys } from '@/api/vault'
|
||||||
|
import { roleListOptions } from '@/api/rbac'
|
||||||
|
import { userListOptions } from '@/api/users'
|
||||||
|
import type { UserRecord } from '@/api/users'
|
||||||
|
import type { Role } from '@/types/rbac'
|
||||||
|
import type { VaultCategoryPermission } from '@/types/vault'
|
||||||
|
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 Props {
|
||||||
|
categoryId: string
|
||||||
|
categoryName: 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 CategoryPermissionsDialog({ categoryId, categoryName, isPublic, open, onOpenChange }: Props) {
|
||||||
|
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({
|
||||||
|
...vaultCategoryPermissionsOptions(categoryId),
|
||||||
|
enabled: open && !!categoryId,
|
||||||
|
})
|
||||||
|
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) => vaultCategoryMutations.update(categoryId, { isPublic: newIsPublic }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: vaultKeys.categories })
|
||||||
|
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryDetail(categoryId) })
|
||||||
|
toast.success(isPublic ? 'Category set to private' : 'Category set to public')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addPermissionMutation = useMutation({
|
||||||
|
mutationFn: () => vaultCategoryMutations.addPermission(categoryId, {
|
||||||
|
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
|
||||||
|
accessLevel,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryPermissions(categoryId) })
|
||||||
|
setAssigneeId('')
|
||||||
|
setAccessLevel('view')
|
||||||
|
toast.success('Permission added')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removePermissionMutation = useMutation({
|
||||||
|
mutationFn: (permId: string) => vaultCategoryMutations.removePermission(permId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryPermissions(categoryId) })
|
||||||
|
toast.success('Permission removed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
function getPermissionLabel(perm: VaultCategoryPermission) {
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 — {categoryName}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium">Public category</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Public categories are viewable by all users with vault access</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={isPublic} onCheckedChange={(checked) => togglePublicMutation.mutate(checked)} disabled={togglePublicMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); if (assigneeId) addPermissionMutation.mutate() }} className="space-y-3">
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Add Permission</Label>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
KeyRound, Lock, Unlock, Plus, MoreVertical, Trash2, Eye, EyeOff, Copy,
|
KeyRound, Lock, Unlock, Plus, MoreVertical, Trash2, Eye, EyeOff, Copy,
|
||||||
FolderKey, ShieldAlert, User2, Globe, StickyNote,
|
FolderKey, Shield, ShieldAlert, User2, Globe, StickyNote,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { CategoryPermissionsDialog } from '@/components/vault/category-permissions-dialog'
|
||||||
import type { VaultCategory, VaultEntry } from '@/types/vault'
|
import type { VaultCategory, VaultEntry } from '@/types/vault'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/vault/')({
|
export const Route = createFileRoute('/_authenticated/vault/')({
|
||||||
@@ -155,6 +156,7 @@ function VaultMain() {
|
|||||||
const [newEntryOpen, setNewEntryOpen] = useState(false)
|
const [newEntryOpen, setNewEntryOpen] = useState(false)
|
||||||
const [entryForm, setEntryForm] = useState({ name: '', username: '', url: '', notes: '', secret: '' })
|
const [entryForm, setEntryForm] = useState({ name: '', username: '', url: '', notes: '', secret: '' })
|
||||||
const [editEntryId, setEditEntryId] = useState<string | null>(null)
|
const [editEntryId, setEditEntryId] = useState<string | null>(null)
|
||||||
|
const [permissionsOpen, setPermissionsOpen] = useState(false)
|
||||||
|
|
||||||
const { data: catData, isLoading: catsLoading } = useQuery(vaultCategoryListOptions())
|
const { data: catData, isLoading: catsLoading } = useQuery(vaultCategoryListOptions())
|
||||||
const { data: catDetail } = useQuery(vaultCategoryDetailOptions(selectedCategoryId ?? ''))
|
const { data: catDetail } = useQuery(vaultCategoryDetailOptions(selectedCategoryId ?? ''))
|
||||||
@@ -319,9 +321,14 @@ function VaultMain() {
|
|||||||
{selectedCategoryId && (
|
{selectedCategoryId && (
|
||||||
<>
|
<>
|
||||||
{catDetail?.accessLevel === 'admin' && (
|
{catDetail?.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={() => deleteCatMutation.mutate(selectedCategoryId)}>
|
<Button variant="outline" size="sm" onClick={() => deleteCatMutation.mutate(selectedCategoryId)}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />Delete Category
|
<Trash2 className="mr-2 h-4 w-4" />Delete Category
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{hasPermission('vault.edit') && (
|
{hasPermission('vault.edit') && (
|
||||||
<Button variant="outline" size="sm" onClick={() => {
|
<Button variant="outline" size="sm" onClick={() => {
|
||||||
@@ -360,6 +367,17 @@ function VaultMain() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions Dialog */}
|
||||||
|
{selectedCategoryId && catDetail && (
|
||||||
|
<CategoryPermissionsDialog
|
||||||
|
categoryId={selectedCategoryId}
|
||||||
|
categoryName={catDetail.name ?? ''}
|
||||||
|
isPublic={catDetail.isPublic ?? false}
|
||||||
|
open={permissionsOpen}
|
||||||
|
onOpenChange={setPermissionsOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Entry Dialog */}
|
{/* Entry Dialog */}
|
||||||
<Dialog open={newEntryOpen} onOpenChange={(open) => { setNewEntryOpen(open); if (!open) setEditEntryId(null) }}>
|
<Dialog open={newEntryOpen} onOpenChange={(open) => { setNewEntryOpen(open); if (!open) setEditEntryId(null) }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user