RolesList.tsx 11 KB

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