JitDbAccessConfiguration.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants'
  2. import { LOCAL_STORAGE_KEYS, useParams } from 'common'
  3. import { Loader2 } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
  6. import { useEffect, useMemo, useState } from 'react'
  7. import { toast } from 'sonner'
  8. import {
  9. AlertDialog,
  10. AlertDialogAction,
  11. AlertDialogCancel,
  12. AlertDialogContent,
  13. AlertDialogDescription,
  14. AlertDialogFooter,
  15. AlertDialogHeader,
  16. AlertDialogTitle,
  17. Button,
  18. Card,
  19. CardContent,
  20. Switch,
  21. Tooltip,
  22. TooltipContent,
  23. TooltipTrigger,
  24. } from 'ui'
  25. import {
  26. PageSection,
  27. PageSectionContent,
  28. PageSectionMeta,
  29. PageSectionSummary,
  30. PageSectionTitle,
  31. } from 'ui-patterns'
  32. import { Admonition } from 'ui-patterns/admonition'
  33. import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
  34. import type { JitUserRule, SheetMode } from './JitDbAccess.types'
  35. import {
  36. getAssignableJitRoleOptions,
  37. getJitMemberOptions,
  38. mapJitMembersToUserRules,
  39. } from './JitDbAccess.utils'
  40. import { JitDbAccessDeleteDialog } from './JitDbAccessDeleteDialog'
  41. import { JitDbAccessRuleSheet } from './JitDbAccessRuleSheet'
  42. import { JitDbAccessRulesTable } from './JitDbAccessRulesTable'
  43. import { SupportLink } from '@/components/interfaces/Support/SupportLink'
  44. import AlertError from '@/components/ui/AlertError'
  45. import { DocsButton } from '@/components/ui/DocsButton'
  46. import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
  47. import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
  48. import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
  49. import { useJitDbAccessMembersQuery } from '@/data/jit-db-access/jit-db-access-members-query'
  50. import { useJitDbAccessQuery } from '@/data/jit-db-access/jit-db-access-query'
  51. import { useJitDbAccessRevokeMutation } from '@/data/jit-db-access/jit-db-access-revoke-mutation'
  52. import { useJitDbAccessUpdateMutation } from '@/data/jit-db-access/jit-db-access-update-mutation'
  53. import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
  54. import { useProjectMembersQuery } from '@/data/projects/project-members-query'
  55. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  56. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  57. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  58. import { DOCS_URL } from '@/lib/constants'
  59. export const JitDbAccessConfiguration = () => {
  60. const { ref } = useParams()
  61. const { data: project } = useSelectedProjectQuery()
  62. const { data: organization } = useSelectedOrganizationQuery()
  63. const parentProjectRef = project?.parent_project_ref
  64. const [enabled, setEnabled] = useState(false)
  65. const [, setShowCreateRuleSheet] = useQueryState('jit_new', parseAsBoolean.withDefault(false))
  66. const [ruleIdToEdit, setRuleIdToEdit] = useQueryState('jit_edit', parseAsString)
  67. const [showEnableJitDialog, setShowEnableJitDialog] = useState(false)
  68. const [selectedUserToDelete, setSelectedUserToDelete] = useState<JitUserRule | null>(null)
  69. const {
  70. data: jitDbAccessConfiguration,
  71. error: jitDbAccessConfigurationError,
  72. isError: isErrorJitDbAccessConfiguration,
  73. isLoading: isLoadingConfiguration,
  74. isSuccess: isSuccessConfiguration,
  75. } = useJitDbAccessQuery({ projectRef: ref })
  76. const {
  77. data: jitMembers,
  78. error: jitMembersError,
  79. isError: isErrorJitMembers,
  80. isLoading: isLoadingJitMembers,
  81. } = useJitDbAccessMembersQuery({ projectRef: ref })
  82. const { data: projectMembers, isLoading: isLoadingProjectMembers } = useProjectMembersQuery({
  83. projectRef: ref,
  84. })
  85. const { data: organizationMembers, isLoading: isLoadingOrganizationMembers } =
  86. useOrganizationMembersQuery({ slug: organization?.slug })
  87. const { data: databaseRoles, isLoading: isLoadingDatabaseRoles } = useDatabaseRolesQuery({
  88. projectRef: ref,
  89. connectionString: project?.connectionString,
  90. })
  91. const { can: canUpdateJitDbAccess } = useAsyncCheckPermissions(
  92. PermissionAction.UPDATE,
  93. 'projects',
  94. { resource: { project_id: project?.id } }
  95. )
  96. const { mutate: updateJitDbAccess, isPending: isUpdatingJitDbAccess } =
  97. useJitDbAccessUpdateMutation({
  98. onSuccess: (_, variables) => {
  99. const nextEnabled = variables.requestedConfig.state === 'enabled'
  100. if (nextEnabled) {
  101. toast.success('Temporary access enabled')
  102. } else {
  103. toast.success(
  104. activeRuleCount > 0
  105. ? `Temporary access disabled. ${activeRuleCount} configured member${activeRuleCount === 1 ? '' : 's'} can no longer request temporary database access.`
  106. : 'Temporary access disabled'
  107. )
  108. }
  109. },
  110. onError: (error) => {
  111. setEnabled(initialIsEnabled ?? false)
  112. toast.error(`Failed to update temporary access: ${error.message}`)
  113. },
  114. })
  115. const { mutate: revokeUserAccess, isPending: isRevokingAccess } = useJitDbAccessRevokeMutation({
  116. onSuccess: (_, variables) => {
  117. toast.success('Successfully revoked user access')
  118. setSelectedUserToDelete(null)
  119. if (ruleIdToEdit === variables.userId) resetSheetState()
  120. },
  121. onError: (error) => {
  122. toast.error(`Failed to revoke user access: ${error.message}`)
  123. },
  124. })
  125. const isMutating = isUpdatingJitDbAccess || isRevokingAccess
  126. const disableRuleActions = isMutating || isLoadingDatabaseRoles || isLoadingOrganizationMembers
  127. const isRulesLoading = isLoadingJitMembers || isLoadingProjectMembers
  128. const initialIsEnabled =
  129. jitDbAccessConfiguration?.state === 'enabled'
  130. ? jitDbAccessConfiguration?.appliedSuccessfully
  131. : false
  132. const isJitDbAccessUnavailable = jitDbAccessConfiguration?.state === 'unavailable'
  133. const unavailableReason = isJitDbAccessUnavailable
  134. ? jitDbAccessConfiguration.unavailableReason
  135. : undefined
  136. const roleOptions = useMemo(() => getAssignableJitRoleOptions(databaseRoles), [databaseRoles])
  137. const users = useMemo(
  138. () => mapJitMembersToUserRules(jitMembers, projectMembers, roleOptions),
  139. [jitMembers, projectMembers, roleOptions]
  140. )
  141. const allMembers = useMemo(
  142. () => getJitMemberOptions(organizationMembers, projectMembers),
  143. [organizationMembers, projectMembers]
  144. )
  145. const editingUser = useMemo(
  146. () => users.find((user) => user.id === ruleIdToEdit) ?? null,
  147. [users, ruleIdToEdit]
  148. )
  149. const sheetMode: SheetMode = ruleIdToEdit ? 'edit' : 'add'
  150. const membersWithRules = useMemo(() => new Set(users.map((user) => user.memberId)), [users])
  151. const availableMembersForAdd = useMemo(
  152. () => allMembers.filter((member) => !membersWithRules.has(member.id)),
  153. [allMembers, membersWithRules]
  154. )
  155. const memberOptionsForSheet = useMemo(() => {
  156. if (sheetMode !== 'edit') return availableMembersForAdd
  157. if (!editingUser) return allMembers
  158. if (allMembers.some((member) => member.id === editingUser.memberId)) return allMembers
  159. return [
  160. {
  161. id: editingUser.memberId,
  162. email: editingUser.email,
  163. name: editingUser.name,
  164. },
  165. ...allMembers,
  166. ]
  167. }, [sheetMode, availableMembersForAdd, allMembers, editingUser])
  168. const activeRuleCount = useMemo(
  169. () => users.filter((user) => user.status.active > 0).length,
  170. [users]
  171. )
  172. const resetSheetState = () => {
  173. setShowCreateRuleSheet(false)
  174. setRuleIdToEdit(null)
  175. }
  176. const submitJitToggle = (nextEnabled: boolean) => {
  177. if (!ref) return console.error('Project ref is required')
  178. setEnabled(nextEnabled)
  179. updateJitDbAccess({
  180. projectRef: ref,
  181. requestedConfig: { state: nextEnabled ? 'enabled' : 'disabled' },
  182. })
  183. }
  184. const handleJitToggleChange = (checked: boolean) => {
  185. if (isJitDbAccessUnavailable || !canUpdateJitDbAccess) return
  186. if (checked && !enabled) {
  187. if (activeRuleCount > 0) {
  188. return setShowEnableJitDialog(true)
  189. }
  190. return submitJitToggle(true)
  191. }
  192. if (!checked && enabled) {
  193. submitJitToggle(false)
  194. }
  195. }
  196. const handleConfirmEnableJit = () => {
  197. setShowEnableJitDialog(false)
  198. submitJitToggle(true)
  199. }
  200. const openAddRuleSheet = () => {
  201. if (!canUpdateJitDbAccess) return
  202. setRuleIdToEdit(null)
  203. setShowCreateRuleSheet(true)
  204. }
  205. const openEditRuleSheet = (user: JitUserRule) => {
  206. if (!canUpdateJitDbAccess) return
  207. setShowCreateRuleSheet(false)
  208. setRuleIdToEdit(user.id)
  209. }
  210. const openDeleteDialog = (user: JitUserRule) => {
  211. if (!canUpdateJitDbAccess) return
  212. setSelectedUserToDelete(user)
  213. }
  214. const handleConfirmDelete = () => {
  215. if (!ref) return console.error('Project ref is required')
  216. if (!selectedUserToDelete) return toast.error('User is required')
  217. revokeUserAccess({ projectRef: ref, userId: selectedUserToDelete.memberId })
  218. }
  219. const switchDisabled = isLoadingConfiguration || isUpdatingJitDbAccess || !canUpdateJitDbAccess
  220. const switchTooltipText = !canUpdateJitDbAccess ? 'Additional permissions required' : undefined
  221. const showToggleFailedWarning =
  222. isSuccessConfiguration &&
  223. jitDbAccessConfiguration?.state !== 'unavailable' &&
  224. !jitDbAccessConfiguration.appliedSuccessfully
  225. const projectReference = ref ? (
  226. <>
  227. This project <code className="text-code-inline">{ref}</code>
  228. </>
  229. ) : (
  230. 'This project'
  231. )
  232. const unavailableTitle =
  233. unavailableReason === 'postgres_upgrade_required'
  234. ? 'Postgres upgrade required'
  235. : unavailableReason === 'manual_migration_required'
  236. ? 'Migration required'
  237. : 'Temporary access unavailable'
  238. const unavailableDescription =
  239. unavailableReason === 'postgres_upgrade_required'
  240. ? 'must be upgraded to Postgres 17 or later before temporary access can be enabled.'
  241. : unavailableReason === 'manual_migration_required'
  242. ? 'must be migrated before temporary access can be enabled. Contact support to migrate this project.'
  243. : 'This feature is currently unavailable for this project. Contact support if you need help enabling it.'
  244. useEffect(() => {
  245. if (!isLoadingConfiguration && jitDbAccessConfiguration) {
  246. setEnabled(initialIsEnabled ?? false)
  247. }
  248. }, [initialIsEnabled, isLoadingConfiguration, jitDbAccessConfiguration])
  249. return (
  250. <>
  251. <PageSection id="jit-db-access-configuration">
  252. <PageSectionMeta>
  253. <PageSectionSummary>
  254. <PageSectionTitle>
  255. <span className="flex items-center gap-x-4">
  256. Temporary access
  257. <FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS} />
  258. </span>
  259. </PageSectionTitle>
  260. </PageSectionSummary>
  261. <DocsButton href={`${DOCS_URL}/guides/platform/temporary-access`} />
  262. </PageSectionMeta>
  263. <PageSectionContent className="space-y-4">
  264. {parentProjectRef && (
  265. <Admonition
  266. type="note"
  267. title="Managed in the main branch"
  268. description={
  269. <>
  270. Temporary access rules are configured in the main branch and apply across all
  271. preview branches. Return to the{' '}
  272. <InlineLink href={`/project/${parentProjectRef}/settings/database`}>
  273. main branch
  274. </InlineLink>{' '}
  275. to manage your access rules.
  276. </>
  277. }
  278. />
  279. )}
  280. {!parentProjectRef && isErrorJitDbAccessConfiguration && (
  281. <AlertError
  282. projectRef={ref}
  283. subject="Failed to load temporary access"
  284. error={jitDbAccessConfigurationError as { message: string } | null}
  285. showInstructions={false}
  286. />
  287. )}
  288. {!parentProjectRef && !isErrorJitDbAccessConfiguration && isJitDbAccessUnavailable && (
  289. <Admonition
  290. type="note"
  291. layout="responsive"
  292. title={unavailableTitle}
  293. description={
  294. unavailableReason === 'temporarily_unavailable' ? (
  295. unavailableDescription
  296. ) : (
  297. <>
  298. {projectReference} {unavailableDescription}
  299. </>
  300. )
  301. }
  302. actions={
  303. unavailableReason === 'postgres_upgrade_required' && ref ? (
  304. <Button type="default" asChild>
  305. <Link href={`/project/${ref}/settings/infrastructure`}>Upgrade Postgres</Link>
  306. </Button>
  307. ) : (
  308. <Button type="default" asChild>
  309. <SupportLink
  310. queryParams={{
  311. category: SupportCategories.PROBLEM,
  312. projectRef: ref,
  313. subject: unavailableTitle,
  314. }}
  315. >
  316. Contact support
  317. </SupportLink>
  318. </Button>
  319. )
  320. }
  321. />
  322. )}
  323. {!parentProjectRef && !isErrorJitDbAccessConfiguration && !isJitDbAccessUnavailable && (
  324. <Card>
  325. <CardContent className="space-y-4">
  326. <FormLayout
  327. layout="flex-row-reverse"
  328. label="Allow temporary access"
  329. description="Let project members request temporary database access."
  330. >
  331. <div className="flex w-fit shrink-0 items-center justify-end gap-2">
  332. {(isLoadingConfiguration || isUpdatingJitDbAccess) && (
  333. <Loader2
  334. className="animate-spin text-foreground-muted/50"
  335. strokeWidth={2}
  336. size={16}
  337. />
  338. )}
  339. <Tooltip>
  340. <TooltipTrigger asChild>
  341. {/* [Joshen] Added div as tooltip is messing with data state property of toggle */}
  342. <div>
  343. <Switch
  344. size="large"
  345. checked={enabled}
  346. onCheckedChange={handleJitToggleChange}
  347. disabled={switchDisabled}
  348. />
  349. </div>
  350. </TooltipTrigger>
  351. {switchTooltipText && (
  352. <TooltipContent side="bottom">{switchTooltipText}</TooltipContent>
  353. )}
  354. </Tooltip>
  355. </div>
  356. </FormLayout>
  357. </CardContent>
  358. {showToggleFailedWarning && (
  359. <Admonition
  360. type="warning"
  361. layout="horizontal"
  362. title="Temporary access update didn’t apply"
  363. description={
  364. <>
  365. The change didn’t apply. Try enabling or disabling temporary access again, or{' '}
  366. <SupportLink
  367. queryParams={{
  368. category: SupportCategories.DASHBOARD_BUG,
  369. subject: 'Temporary access was not updated successfully',
  370. }}
  371. className={InlineLinkClassName}
  372. >
  373. contact support
  374. </SupportLink>{' '}
  375. if the issue persists.
  376. </>
  377. }
  378. className="mb-0 rounded-none border-0"
  379. />
  380. )}
  381. </Card>
  382. )}
  383. {!parentProjectRef && enabled && !isJitDbAccessUnavailable && !isUpdatingJitDbAccess && (
  384. <>
  385. {isErrorJitMembers && (
  386. <AlertError
  387. projectRef={ref}
  388. subject="Failed to load temporary access rules"
  389. error={jitMembersError as { message: string } | null}
  390. showInstructions={false}
  391. />
  392. )}
  393. <JitDbAccessRulesTable
  394. users={users}
  395. isLoading={isRulesLoading}
  396. canUpdate={!!canUpdateJitDbAccess}
  397. disableActions={disableRuleActions}
  398. allProjectMembersHaveRules={availableMembersForAdd.length === 0}
  399. onAddRule={openAddRuleSheet}
  400. onEditRule={openEditRuleSheet}
  401. onDeleteRule={openDeleteDialog}
  402. />
  403. </>
  404. )}
  405. </PageSectionContent>
  406. </PageSection>
  407. <JitDbAccessRuleSheet
  408. memberOptions={memberOptionsForSheet}
  409. membersWithRules={membersWithRules}
  410. availableMembersForAddCount={availableMembersForAdd.length}
  411. />
  412. <JitDbAccessDeleteDialog
  413. user={selectedUserToDelete}
  414. isDeleting={isRevokingAccess}
  415. onClose={() => setSelectedUserToDelete(null)}
  416. onConfirm={handleConfirmDelete}
  417. />
  418. <AlertDialog open={showEnableJitDialog} onOpenChange={setShowEnableJitDialog}>
  419. <AlertDialogContent size="small">
  420. <AlertDialogHeader>
  421. <AlertDialogTitle>This will activate existing rules</AlertDialogTitle>
  422. <AlertDialogDescription asChild>
  423. <div className="text-sm">
  424. <p>
  425. Enabling temporary access will allow {activeRuleCount} pre-configured member
  426. {activeRuleCount === 1 ? '' : 's'} to request temporary database access
  427. immediately.
  428. </p>
  429. </div>
  430. </AlertDialogDescription>
  431. </AlertDialogHeader>
  432. <AlertDialogFooter>
  433. <AlertDialogCancel disabled={isUpdatingJitDbAccess}>Cancel</AlertDialogCancel>
  434. <AlertDialogAction
  435. variant="warning"
  436. disabled={isUpdatingJitDbAccess}
  437. onClick={handleConfirmEnableJit}
  438. >
  439. Enable temporary access
  440. </AlertDialogAction>
  441. </AlertDialogFooter>
  442. </AlertDialogContent>
  443. </AlertDialog>
  444. </>
  445. )
  446. }