JitDbAccessConfiguration.tsx497 lines · main
| 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 | |
| 35 | import type { JitUserRule, SheetMode } from './JitDbAccess.types' |
| 36 | import { |
| 37 | getAssignableJitRoleOptions, |
| 38 | getJitMemberOptions, |
| 39 | mapJitMembersToUserRules, |
| 40 | } from './JitDbAccess.utils' |
| 41 | import { JitDbAccessDeleteDialog } from './JitDbAccessDeleteDialog' |
| 42 | import { JitDbAccessRuleSheet } from './JitDbAccessRuleSheet' |
| 43 | import { JitDbAccessRulesTable } from './JitDbAccessRulesTable' |
| 44 | import { SupportLink } from '@/components/interfaces/Support/SupportLink' |
| 45 | import AlertError from '@/components/ui/AlertError' |
| 46 | import { DocsButton } from '@/components/ui/DocsButton' |
| 47 | import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' |
| 48 | import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' |
| 49 | import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' |
| 50 | import { useJitDbAccessMembersQuery } from '@/data/jit-db-access/jit-db-access-members-query' |
| 51 | import { useJitDbAccessQuery } from '@/data/jit-db-access/jit-db-access-query' |
| 52 | import { useJitDbAccessRevokeMutation } from '@/data/jit-db-access/jit-db-access-revoke-mutation' |
| 53 | import { useJitDbAccessUpdateMutation } from '@/data/jit-db-access/jit-db-access-update-mutation' |
| 54 | import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query' |
| 55 | import { useProjectMembersQuery } from '@/data/projects/project-members-query' |
| 56 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 57 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 58 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 59 | import { DOCS_URL } from '@/lib/constants' |
| 60 | |
| 61 | export 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 | } |