diff --git a/packages/admin/src/components/vault/category-permissions-dialog.tsx b/packages/admin/src/components/vault/category-permissions-dialog.tsx new file mode 100644 index 0000000..c43cb4a --- /dev/null +++ b/packages/admin/src/components/vault/category-permissions-dialog.tsx @@ -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 ( + + + + + + Permissions — {categoryName} + + + +
+
+
+ +

Public categories are viewable by all users with vault access

+
+ togglePublicMutation.mutate(checked)} disabled={togglePublicMutation.isPending} /> +
+ +
+ +
+ {permsLoading ? ( +

Loading...

+ ) : permissions.length === 0 ? ( +

No specific permissions assigned

+ ) : ( + permissions.map((perm) => { + const { icon: Icon, name } = getPermissionLabel(perm) + const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel) + return ( +
+
+ + {name} +
+
+ {level?.label ?? perm.accessLevel} + +
+
+ ) + }) + )} +
+
+ +
{ e.preventDefault(); if (assigneeId) addPermissionMutation.mutate() }} className="space-y-3"> + +
+ + +
+
+ + +
+ +
+
+
+
+ ) +} diff --git a/packages/admin/src/routes/_authenticated/vault/index.tsx b/packages/admin/src/routes/_authenticated/vault/index.tsx index 964735c..1fd5c03 100644 --- a/packages/admin/src/routes/_authenticated/vault/index.tsx +++ b/packages/admin/src/routes/_authenticated/vault/index.tsx @@ -18,9 +18,10 @@ import { } from '@/components/ui/dropdown-menu' import { KeyRound, Lock, Unlock, Plus, MoreVertical, Trash2, Eye, EyeOff, Copy, - FolderKey, ShieldAlert, User2, Globe, StickyNote, + FolderKey, Shield, ShieldAlert, User2, Globe, StickyNote, } from 'lucide-react' import { toast } from 'sonner' +import { CategoryPermissionsDialog } from '@/components/vault/category-permissions-dialog' import type { VaultCategory, VaultEntry } from '@/types/vault' export const Route = createFileRoute('/_authenticated/vault/')({ @@ -155,6 +156,7 @@ function VaultMain() { const [newEntryOpen, setNewEntryOpen] = useState(false) const [entryForm, setEntryForm] = useState({ name: '', username: '', url: '', notes: '', secret: '' }) const [editEntryId, setEditEntryId] = useState(null) + const [permissionsOpen, setPermissionsOpen] = useState(false) const { data: catData, isLoading: catsLoading } = useQuery(vaultCategoryListOptions()) const { data: catDetail } = useQuery(vaultCategoryDetailOptions(selectedCategoryId ?? '')) @@ -319,9 +321,14 @@ function VaultMain() { {selectedCategoryId && ( <> {catDetail?.accessLevel === 'admin' && ( - + <> + + + )} {hasPermission('vault.edit') && (