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