JitDbAccessRulesTable.tsx213 lines · main
| 1 | import { EllipsisVertical, Pencil, Plus, Trash2 } from 'lucide-react' |
| 2 | import { |
| 3 | Badge, |
| 4 | Button, |
| 5 | Card, |
| 6 | CardContent, |
| 7 | DropdownMenu, |
| 8 | DropdownMenuContent, |
| 9 | DropdownMenuItem, |
| 10 | DropdownMenuSeparator, |
| 11 | DropdownMenuTrigger, |
| 12 | Skeleton, |
| 13 | Table, |
| 14 | TableBody, |
| 15 | TableCell, |
| 16 | TableHead, |
| 17 | TableHeader, |
| 18 | TableRow, |
| 19 | Tooltip, |
| 20 | TooltipContent, |
| 21 | TooltipTrigger, |
| 22 | } from 'ui' |
| 23 | |
| 24 | import type { JitUserRule } from './JitDbAccess.types' |
| 25 | import { getJitStatusDisplay } from './JitDbAccess.utils' |
| 26 | |
| 27 | interface JitDbAccessRulesTableProps { |
| 28 | users: JitUserRule[] |
| 29 | isLoading?: boolean |
| 30 | canUpdate: boolean |
| 31 | disableActions?: boolean |
| 32 | allProjectMembersHaveRules?: boolean |
| 33 | onAddRule: () => void |
| 34 | onEditRule: (user: JitUserRule) => void |
| 35 | onDeleteRule: (user: JitUserRule) => void |
| 36 | } |
| 37 | |
| 38 | export function JitDbAccessRulesTable({ |
| 39 | users, |
| 40 | isLoading = false, |
| 41 | canUpdate, |
| 42 | disableActions = false, |
| 43 | allProjectMembersHaveRules = false, |
| 44 | onAddRule, |
| 45 | onEditRule, |
| 46 | onDeleteRule, |
| 47 | }: JitDbAccessRulesTableProps) { |
| 48 | const addDisabled = disableActions || !canUpdate || allProjectMembersHaveRules |
| 49 | const addRuleTooltip = !canUpdate |
| 50 | ? 'Additional permissions required' |
| 51 | : allProjectMembersHaveRules |
| 52 | ? 'All project members already have temporary access rules' |
| 53 | : undefined |
| 54 | |
| 55 | if (isLoading) { |
| 56 | return ( |
| 57 | <Card> |
| 58 | <CardContent className="space-y-4 p-4"> |
| 59 | <div className="flex items-center justify-between"> |
| 60 | <div className="space-y-2"> |
| 61 | <Skeleton className="h-4 w-32" /> |
| 62 | <Skeleton className="h-4 w-64" /> |
| 63 | </div> |
| 64 | <Skeleton className="h-9 w-24" /> |
| 65 | </div> |
| 66 | <Skeleton className="h-32 w-full" /> |
| 67 | </CardContent> |
| 68 | </Card> |
| 69 | ) |
| 70 | } |
| 71 | |
| 72 | return ( |
| 73 | <Card> |
| 74 | <CardContent className="space-y-4 p-0"> |
| 75 | <div className="flex items-center justify-between px-4 pb-2 pt-6"> |
| 76 | <div> |
| 77 | <h3 className="text-sm text-foreground">Temporary access rules</h3> |
| 78 | <p className="text-sm text-foreground-light"> |
| 79 | Manage member access, allowed roles, and expiry settings. |
| 80 | </p> |
| 81 | </div> |
| 82 | |
| 83 | <Tooltip> |
| 84 | <TooltipTrigger asChild> |
| 85 | <span className="inline-flex"> |
| 86 | <Button type="default" icon={<Plus />} onClick={onAddRule} disabled={addDisabled}> |
| 87 | Add rule |
| 88 | </Button> |
| 89 | </span> |
| 90 | </TooltipTrigger> |
| 91 | {addRuleTooltip && <TooltipContent side="bottom">{addRuleTooltip}</TooltipContent>} |
| 92 | </Tooltip> |
| 93 | </div> |
| 94 | |
| 95 | <Table className="border-t"> |
| 96 | <TableHeader> |
| 97 | <TableRow> |
| 98 | <TableHead>Member</TableHead> |
| 99 | <TableHead>Roles</TableHead> |
| 100 | <TableHead>Status</TableHead> |
| 101 | <TableHead className="w-1"> |
| 102 | <span className="sr-only">Actions</span> |
| 103 | </TableHead> |
| 104 | </TableRow> |
| 105 | </TableHeader> |
| 106 | <TableBody> |
| 107 | {users.length === 0 ? ( |
| 108 | <TableRow className="[&>td]:hover:bg-inherit"> |
| 109 | <TableCell colSpan={4}> |
| 110 | <p className="text-sm text-foreground">No rules yet</p> |
| 111 | <p className="text-sm text-foreground-lighter"> |
| 112 | Add your first temporary access rule above |
| 113 | </p> |
| 114 | </TableCell> |
| 115 | </TableRow> |
| 116 | ) : ( |
| 117 | users.map((user) => { |
| 118 | const statusDisplay = getJitStatusDisplay(user.status) |
| 119 | const enabledGrants = user.grants.filter((grant) => grant.enabled) |
| 120 | const rowIsInteractive = canUpdate && !disableActions |
| 121 | |
| 122 | return ( |
| 123 | <TableRow |
| 124 | key={user.id} |
| 125 | className={rowIsInteractive ? 'relative inset-focus cursor-pointer' : undefined} |
| 126 | onClick={ |
| 127 | rowIsInteractive |
| 128 | ? (event) => { |
| 129 | if ((event.target as HTMLElement).closest('button')) return |
| 130 | onEditRule(user) |
| 131 | } |
| 132 | : undefined |
| 133 | } |
| 134 | onKeyDown={ |
| 135 | rowIsInteractive |
| 136 | ? (event) => { |
| 137 | if ((event.target as HTMLElement).closest('button')) return |
| 138 | if (event.key === 'Enter' || event.key === ' ') { |
| 139 | event.preventDefault() |
| 140 | onEditRule(user) |
| 141 | } |
| 142 | } |
| 143 | : undefined |
| 144 | } |
| 145 | tabIndex={rowIsInteractive ? 0 : undefined} |
| 146 | > |
| 147 | <TableCell className="text-sm"> |
| 148 | {user.name && <p>{user.name}</p>} |
| 149 | <p className="text-foreground-lighter">{user.email}</p> |
| 150 | </TableCell> |
| 151 | <TableCell className="text-sm text-foreground-light"> |
| 152 | {enabledGrants.length} role{enabledGrants.length === 1 ? '' : 's'} |
| 153 | </TableCell> |
| 154 | <TableCell className="text-sm text-foreground-light"> |
| 155 | {statusDisplay.badges.length > 0 ? ( |
| 156 | <span className="flex flex-wrap gap-1.5"> |
| 157 | {statusDisplay.badges.map((badge) => ( |
| 158 | <Badge key={badge.label} variant={badge.variant}> |
| 159 | {badge.label} |
| 160 | </Badge> |
| 161 | ))} |
| 162 | </span> |
| 163 | ) : null} |
| 164 | </TableCell> |
| 165 | <TableCell className="text-right"> |
| 166 | <DropdownMenu> |
| 167 | <DropdownMenuTrigger asChild> |
| 168 | <Button |
| 169 | icon={<EllipsisVertical />} |
| 170 | aria-label="More actions" |
| 171 | type="default" |
| 172 | size="tiny" |
| 173 | className="w-7" |
| 174 | disabled={!canUpdate || disableActions} |
| 175 | /> |
| 176 | </DropdownMenuTrigger> |
| 177 | <DropdownMenuContent align="end" side="bottom" className="w-40"> |
| 178 | <DropdownMenuItem |
| 179 | className="gap-x-2" |
| 180 | onClick={(event) => { |
| 181 | event.stopPropagation() |
| 182 | onEditRule(user) |
| 183 | }} |
| 184 | disabled={!canUpdate || disableActions} |
| 185 | > |
| 186 | <Pencil size={14} className="text-foreground-lighter" /> |
| 187 | Edit |
| 188 | </DropdownMenuItem> |
| 189 | <DropdownMenuSeparator /> |
| 190 | <DropdownMenuItem |
| 191 | className="gap-x-2" |
| 192 | onClick={(event) => { |
| 193 | event.stopPropagation() |
| 194 | onDeleteRule(user) |
| 195 | }} |
| 196 | disabled={!canUpdate || disableActions} |
| 197 | > |
| 198 | <Trash2 size={14} className="text-foreground-lighter" /> |
| 199 | Delete |
| 200 | </DropdownMenuItem> |
| 201 | </DropdownMenuContent> |
| 202 | </DropdownMenu> |
| 203 | </TableCell> |
| 204 | </TableRow> |
| 205 | ) |
| 206 | }) |
| 207 | )} |
| 208 | </TableBody> |
| 209 | </Table> |
| 210 | </CardContent> |
| 211 | </Card> |
| 212 | ) |
| 213 | } |