RolesList.tsx305 lines · main
| 1 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 2 | import { partition, sortBy } from 'lodash' |
| 3 | import { Plus, Search, X } from 'lucide-react' |
| 4 | import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' |
| 5 | import { useEffect, useRef, useState } from 'react' |
| 6 | import { toast } from 'sonner' |
| 7 | import { Badge, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' |
| 8 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 9 | import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' |
| 10 | |
| 11 | import { CreateRolePanel } from './CreateRolePanel' |
| 12 | import { RoleRow } from './RoleRow' |
| 13 | import { RoleRowSkeleton } from './RoleRowSkeleton' |
| 14 | import { BRIVEN_ROLES } from './Roles.constants' |
| 15 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 16 | import { NoSearchResults } from '@/components/ui/NoSearchResults' |
| 17 | import { Shortcut } from '@/components/ui/Shortcut' |
| 18 | import { SparkBar } from '@/components/ui/SparkBar' |
| 19 | import { useDatabaseRoleDeleteMutation } from '@/data/database-roles/database-role-delete-mutation' |
| 20 | import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' |
| 21 | import { useMaxConnectionsQuery } from '@/data/database/max-connections-query' |
| 22 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 23 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 24 | import { onSearchInputEscape } from '@/lib/keyboard' |
| 25 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 26 | import { useShortcut } from '@/state/shortcuts/useShortcut' |
| 27 | |
| 28 | type BRIVEN_ROLE = (typeof BRIVEN_ROLES)[number] |
| 29 | |
| 30 | export const RolesList = () => { |
| 31 | const { data: project } = useSelectedProjectQuery() |
| 32 | |
| 33 | const [filterString, setFilterString] = useState('') |
| 34 | const [filterType, setFilterType] = useState<'all' | 'active'>('all') |
| 35 | const searchInputRef = useRef<HTMLInputElement>(null) |
| 36 | |
| 37 | const { can: canUpdateRoles } = useAsyncCheckPermissions( |
| 38 | PermissionAction.TENANT_SQL_ADMIN_WRITE, |
| 39 | 'roles' |
| 40 | ) |
| 41 | |
| 42 | useShortcut( |
| 43 | SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH, |
| 44 | () => { |
| 45 | searchInputRef.current?.focus() |
| 46 | searchInputRef.current?.select() |
| 47 | }, |
| 48 | { label: 'Search roles' } |
| 49 | ) |
| 50 | |
| 51 | useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => { |
| 52 | setFilterString('') |
| 53 | setFilterType('all') |
| 54 | }) |
| 55 | |
| 56 | const { data: maxConnData } = useMaxConnectionsQuery({ |
| 57 | projectRef: project?.ref, |
| 58 | connectionString: project?.connectionString, |
| 59 | }) |
| 60 | const maxConnectionLimit = maxConnData?.maxConnections |
| 61 | |
| 62 | const { |
| 63 | data, |
| 64 | isPending: isLoading, |
| 65 | isSuccess, |
| 66 | } = useDatabaseRolesQuery({ |
| 67 | projectRef: project?.ref, |
| 68 | connectionString: project?.connectionString, |
| 69 | }) |
| 70 | |
| 71 | const { |
| 72 | mutate: deleteDatabaseRole, |
| 73 | isPending: isDeleting, |
| 74 | isSuccess: isSuccessDelete, |
| 75 | } = useDatabaseRoleDeleteMutation({ |
| 76 | onSuccess: () => { |
| 77 | toast.success(`Successfully deleted role`) |
| 78 | setSelectedRoleIdToDelete(null) |
| 79 | }, |
| 80 | }) |
| 81 | |
| 82 | const [isCreatingRole, setIsCreatingRole] = useQueryState( |
| 83 | 'new', |
| 84 | parseAsBoolean.withDefault(false) |
| 85 | ) |
| 86 | |
| 87 | const [selectedRoleIdToDelete, setSelectedRoleIdToDelete] = useQueryState('delete', parseAsString) |
| 88 | const roles = sortBy(data ?? [], (r) => r.name.toLocaleLowerCase()) |
| 89 | const roleToDelete = roles?.find((role) => role.id.toString() === selectedRoleIdToDelete) |
| 90 | |
| 91 | const filteredRoles = ( |
| 92 | filterType === 'active' ? roles.filter((role) => role.activeConnections > 0) : roles |
| 93 | ).filter((role) => role.name.includes(filterString)) |
| 94 | const [brivenRoles, otherRoles] = partition(filteredRoles, (role) => |
| 95 | BRIVEN_ROLES.includes(role.name as BRIVEN_ROLE) |
| 96 | ) |
| 97 | |
| 98 | const totalActiveConnections = roles |
| 99 | .map((role) => role.activeConnections) |
| 100 | .reduce((a, b) => a + b, 0) |
| 101 | // order the roles with active connections by number of connections, most connections first |
| 102 | const rolesWithActiveConnections = sortBy( |
| 103 | roles.filter((role) => role.activeConnections > 0), |
| 104 | (r) => -r.activeConnections |
| 105 | ) |
| 106 | |
| 107 | const deleteRole = async () => { |
| 108 | if (!project) return console.error('Project is required') |
| 109 | if (!roleToDelete) return console.error('Failed to delete role: role is missing') |
| 110 | deleteDatabaseRole({ |
| 111 | projectRef: project.ref, |
| 112 | connectionString: project.connectionString, |
| 113 | id: roleToDelete.id, |
| 114 | }) |
| 115 | } |
| 116 | |
| 117 | useEffect(() => { |
| 118 | if (isSuccess && !!selectedRoleIdToDelete && !roleToDelete && !isSuccessDelete) { |
| 119 | toast('Role cannot be found') |
| 120 | setSelectedRoleIdToDelete(null) |
| 121 | } |
| 122 | }, [isSuccess, selectedRoleIdToDelete, roleToDelete, isSuccessDelete, setSelectedRoleIdToDelete]) |
| 123 | |
| 124 | return ( |
| 125 | <> |
| 126 | <div className="mb-4 flex items-center justify-between gap-2 flex-wrap"> |
| 127 | <div className="flex items-center space-x-4"> |
| 128 | <Input |
| 129 | ref={searchInputRef} |
| 130 | size="tiny" |
| 131 | className="w-52" |
| 132 | placeholder="Search for a role" |
| 133 | icon={<Search />} |
| 134 | value={filterString} |
| 135 | onChange={(event) => setFilterString(event.target.value)} |
| 136 | onKeyDown={onSearchInputEscape(filterString, setFilterString)} |
| 137 | actions={ |
| 138 | filterString && ( |
| 139 | <Button |
| 140 | size="tiny" |
| 141 | type="text" |
| 142 | onClick={() => setFilterString('')} |
| 143 | className="px-1 mr-1" |
| 144 | > |
| 145 | <X size={12} strokeWidth={2} /> |
| 146 | </Button> |
| 147 | ) |
| 148 | } |
| 149 | /> |
| 150 | <div className="flex items-center border border-strong rounded-full w-min h-[26px]"> |
| 151 | <button |
| 152 | className={cn( |
| 153 | 'text-xs w-[80px] h-full text-center rounded-l-full flex items-center justify-center transition', |
| 154 | filterType === 'all' |
| 155 | ? 'bg-overlay-hover text-foreground' |
| 156 | : 'hover:bg-surface-200 text-foreground-light' |
| 157 | )} |
| 158 | onClick={() => setFilterType('all')} |
| 159 | > |
| 160 | All roles |
| 161 | </button> |
| 162 | <div className="h-full w-px border-r border-strong"></div> |
| 163 | <button |
| 164 | className={cn( |
| 165 | 'text-xs w-[80px] h-full text-center rounded-r-full flex items-center justify-center transition', |
| 166 | filterType === 'active' |
| 167 | ? 'bg-overlay-hover text-foreground' |
| 168 | : 'hover:bg-surface-200 text-foreground-light' |
| 169 | )} |
| 170 | onClick={() => setFilterType('active')} |
| 171 | > |
| 172 | Active roles |
| 173 | </button> |
| 174 | </div> |
| 175 | </div> |
| 176 | <div className="flex items-center space-x-6"> |
| 177 | <Tooltip> |
| 178 | <TooltipTrigger> |
| 179 | <div className="w-42"> |
| 180 | <SparkBar |
| 181 | type="horizontal" |
| 182 | // if the maxConnectionLimit is undefined, set totalActiveConnections so that |
| 183 | // the width of the bar is set to 100% |
| 184 | max={maxConnectionLimit || totalActiveConnections} |
| 185 | value={totalActiveConnections} |
| 186 | barClass={ |
| 187 | maxConnectionLimit === 0 || maxConnectionLimit === undefined |
| 188 | ? 'bg-foreground' |
| 189 | : totalActiveConnections > 0.9 * maxConnectionLimit |
| 190 | ? 'bg-destructive' |
| 191 | : totalActiveConnections > 0.75 * maxConnectionLimit |
| 192 | ? 'bg-warning' |
| 193 | : undefined |
| 194 | } |
| 195 | labelTop={ |
| 196 | Number.isInteger(maxConnectionLimit) |
| 197 | ? `${totalActiveConnections}/${maxConnectionLimit}` |
| 198 | : `${totalActiveConnections}` |
| 199 | } |
| 200 | labelTopClass="text-xs" |
| 201 | labelBottom="Active connections" |
| 202 | labelBottomClass="text-xs" |
| 203 | /> |
| 204 | </div> |
| 205 | </TooltipTrigger> |
| 206 | <TooltipContent align="start" side="bottom" className="space-y-1"> |
| 207 | <p className="text-foreground-light pr-2">Connections by roles:</p> |
| 208 | {rolesWithActiveConnections.map((role) => ( |
| 209 | <div key={role.id}> |
| 210 | {role.name}: {role.activeConnections} |
| 211 | </div> |
| 212 | ))} |
| 213 | </TooltipContent> |
| 214 | </Tooltip> |
| 215 | {canUpdateRoles ? ( |
| 216 | <Shortcut |
| 217 | id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM} |
| 218 | label="Add new role" |
| 219 | onTrigger={() => setIsCreatingRole(true)} |
| 220 | side="bottom" |
| 221 | > |
| 222 | <Button |
| 223 | type="primary" |
| 224 | icon={<Plus size={12} />} |
| 225 | onClick={() => setIsCreatingRole(true)} |
| 226 | > |
| 227 | Add role |
| 228 | </Button> |
| 229 | </Shortcut> |
| 230 | ) : ( |
| 231 | <ButtonTooltip |
| 232 | type="primary" |
| 233 | disabled |
| 234 | icon={<Plus size={12} />} |
| 235 | tooltip={{ |
| 236 | content: { |
| 237 | side: 'bottom', |
| 238 | text: 'You need additional permissions to add a new role', |
| 239 | }, |
| 240 | }} |
| 241 | > |
| 242 | Add role |
| 243 | </ButtonTooltip> |
| 244 | )} |
| 245 | </div> |
| 246 | </div> |
| 247 | |
| 248 | <div className="space-y-4"> |
| 249 | <div> |
| 250 | <div className="bg-surface-100 border border-default px-card py-3 rounded-t flex items-center space-x-4"> |
| 251 | <p className="text-sm text-foreground-light">Roles managed by Briven</p> |
| 252 | <Badge variant="success">Protected</Badge> |
| 253 | </div> |
| 254 | |
| 255 | {isLoading |
| 256 | ? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />) |
| 257 | : brivenRoles.map((role) => ( |
| 258 | <RoleRow |
| 259 | disabled |
| 260 | key={role.id} |
| 261 | role={role} |
| 262 | onSelectDelete={setSelectedRoleIdToDelete} |
| 263 | /> |
| 264 | ))} |
| 265 | </div> |
| 266 | |
| 267 | <div> |
| 268 | <div className="bg-surface-100 border border-default px-card py-3 rounded-t"> |
| 269 | <p className="text-sm text-foreground-light">Other database roles</p> |
| 270 | </div> |
| 271 | |
| 272 | {isLoading |
| 273 | ? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />) |
| 274 | : otherRoles.map((role) => ( |
| 275 | <RoleRow |
| 276 | key={role.id} |
| 277 | disabled={!canUpdateRoles} |
| 278 | role={role} |
| 279 | onSelectDelete={setSelectedRoleIdToDelete} |
| 280 | /> |
| 281 | ))} |
| 282 | </div> |
| 283 | </div> |
| 284 | |
| 285 | {filterString.length > 0 && filteredRoles.length === 0 && ( |
| 286 | <NoSearchResults searchString={filterString} onResetFilter={() => setFilterString('')} /> |
| 287 | )} |
| 288 | |
| 289 | <CreateRolePanel visible={isCreatingRole} onClose={() => setIsCreatingRole(false)} /> |
| 290 | |
| 291 | <ConfirmationModal |
| 292 | visible={!!roleToDelete} |
| 293 | loading={isDeleting} |
| 294 | onCancel={() => setSelectedRoleIdToDelete(null)} |
| 295 | title={`Confirm to delete role "${roleToDelete?.name}"`} |
| 296 | onConfirm={deleteRole} |
| 297 | > |
| 298 | <p className="text-sm"> |
| 299 | This will automatically revoke any membership of this role in other roles, and this action |
| 300 | cannot be undone. |
| 301 | </p> |
| 302 | </ConfirmationModal> |
| 303 | </> |
| 304 | ) |
| 305 | } |