import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { Loader2 } from 'lucide-react' import Link from 'next/link' import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, AlertDialogBody, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Button, Card, CardContent, Switch, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' import { PageSection, PageSectionContent, PageSectionMeta, PageSectionSummary, PageSectionTitle, } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' import type { JitUserRule, SheetMode } from './JitDbAccess.types' import { getAssignableJitRoleOptions, getJitMemberOptions, mapJitMembersToUserRules, } from './JitDbAccess.utils' import { JitDbAccessDeleteDialog } from './JitDbAccessDeleteDialog' import { JitDbAccessRuleSheet } from './JitDbAccessRuleSheet' import { JitDbAccessRulesTable } from './JitDbAccessRulesTable' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import AlertError from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' import { useJitDbAccessMembersQuery } from '@/data/jit-db-access/jit-db-access-members-query' import { useJitDbAccessQuery } from '@/data/jit-db-access/jit-db-access-query' import { useJitDbAccessRevokeMutation } from '@/data/jit-db-access/jit-db-access-revoke-mutation' import { useJitDbAccessUpdateMutation } from '@/data/jit-db-access/jit-db-access-update-mutation' import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query' import { useProjectMembersQuery } from '@/data/projects/project-members-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' export const JitDbAccessConfiguration = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() const { data: organization } = useSelectedOrganizationQuery() const parentProjectRef = project?.parent_project_ref const [enabled, setEnabled] = useState(false) const [, setShowCreateRuleSheet] = useQueryState('jit_new', parseAsBoolean.withDefault(false)) const [ruleIdToEdit, setRuleIdToEdit] = useQueryState('jit_edit', parseAsString) const [showEnableJitDialog, setShowEnableJitDialog] = useState(false) const [enableJitError, setEnableJitError] = useState(null) const [selectedUserToDelete, setSelectedUserToDelete] = useState(null) const [deleteRuleError, setDeleteRuleError] = useState(null) const { data: jitDbAccessConfiguration, error: jitDbAccessConfigurationError, isError: isErrorJitDbAccessConfiguration, isLoading: isLoadingConfiguration, isSuccess: isSuccessConfiguration, } = useJitDbAccessQuery({ projectRef: ref }) const { data: jitMembers, error: jitMembersError, isError: isErrorJitMembers, isLoading: isLoadingJitMembers, } = useJitDbAccessMembersQuery({ projectRef: ref }) const { data: projectMembers, isLoading: isLoadingProjectMembers } = useProjectMembersQuery({ projectRef: ref, }) const { data: organizationMembers, isLoading: isLoadingOrganizationMembers } = useOrganizationMembersQuery({ slug: organization?.slug }) const { data: databaseRoles, isLoading: isLoadingDatabaseRoles } = useDatabaseRolesQuery({ projectRef: ref, connectionString: project?.connectionString, }) const { can: canUpdateJitDbAccess } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { resource: { project_id: project?.id } } ) const { mutateAsync: updateJitDbAccess, isPending: isUpdatingJitDbAccess } = useJitDbAccessUpdateMutation({ onSuccess: (_, variables) => { const nextEnabled = variables.requestedConfig.state === 'enabled' if (nextEnabled) { toast.success('Temporary access enabled') } else { toast.success( activeRuleCount > 0 ? `Temporary access disabled. ${activeRuleCount} configured member${activeRuleCount === 1 ? '' : 's'} can no longer request temporary database access.` : 'Temporary access disabled' ) } }, onError: () => {}, }) const { mutateAsync: revokeUserAccess, isPending: isRevokingAccess } = useJitDbAccessRevokeMutation({ onSuccess: (_, variables) => { toast.success('Successfully revoked user access') setSelectedUserToDelete(null) if (ruleIdToEdit === variables.userId) resetSheetState() }, onError: () => {}, }) const isMutating = isUpdatingJitDbAccess || isRevokingAccess const disableRuleActions = isMutating || isLoadingDatabaseRoles || isLoadingOrganizationMembers const isRulesLoading = isLoadingJitMembers || isLoadingProjectMembers const initialIsEnabled = jitDbAccessConfiguration?.state === 'enabled' ? jitDbAccessConfiguration?.appliedSuccessfully : false const isJitDbAccessUnavailable = jitDbAccessConfiguration?.state === 'unavailable' const unavailableReason = isJitDbAccessUnavailable ? jitDbAccessConfiguration.unavailableReason : undefined const roleOptions = useMemo(() => getAssignableJitRoleOptions(databaseRoles), [databaseRoles]) const users = useMemo( () => mapJitMembersToUserRules(jitMembers, projectMembers, roleOptions), [jitMembers, projectMembers, roleOptions] ) const allMembers = useMemo( () => getJitMemberOptions(organizationMembers, projectMembers), [organizationMembers, projectMembers] ) const editingUser = useMemo( () => users.find((user) => user.id === ruleIdToEdit) ?? null, [users, ruleIdToEdit] ) const sheetMode: SheetMode = ruleIdToEdit ? 'edit' : 'add' const membersWithRules = useMemo(() => new Set(users.map((user) => user.memberId)), [users]) const availableMembersForAdd = useMemo( () => allMembers.filter((member) => !membersWithRules.has(member.id)), [allMembers, membersWithRules] ) const memberOptionsForSheet = useMemo(() => { if (sheetMode !== 'edit') return availableMembersForAdd if (!editingUser) return allMembers if (allMembers.some((member) => member.id === editingUser.memberId)) return allMembers return [ { id: editingUser.memberId, email: editingUser.email, name: editingUser.name, }, ...allMembers, ] }, [sheetMode, availableMembersForAdd, allMembers, editingUser]) const activeRuleCount = useMemo( () => users.filter((user) => user.status.active > 0).length, [users] ) const resetSheetState = () => { setShowCreateRuleSheet(false) setRuleIdToEdit(null) } const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : 'An unknown error occurred' const submitJitToggle = async (nextEnabled: boolean) => { if (!ref) throw new Error('Project ref is required') setEnabled(nextEnabled) try { await updateJitDbAccess({ projectRef: ref, requestedConfig: { state: nextEnabled ? 'enabled' : 'disabled' }, }) } catch (error) { setEnabled(initialIsEnabled ?? false) throw error } } const handleJitToggleChange = (checked: boolean) => { if (isJitDbAccessUnavailable || !canUpdateJitDbAccess) return if (checked && !enabled) { if (activeRuleCount > 0) { setEnableJitError(null) return setShowEnableJitDialog(true) } return void submitJitToggle(true).catch((error) => { toast.error(`Failed to update temporary access: ${getErrorMessage(error)}`) }) } if (!checked && enabled) { void submitJitToggle(false).catch((error) => { toast.error(`Failed to update temporary access: ${getErrorMessage(error)}`) }) } } const handleConfirmEnableJit = async () => { setEnableJitError(null) try { await submitJitToggle(true) } catch (error) { setEnableJitError(getErrorMessage(error)) throw error } } const openAddRuleSheet = () => { if (!canUpdateJitDbAccess) return setRuleIdToEdit(null) setShowCreateRuleSheet(true) } const openEditRuleSheet = (user: JitUserRule) => { if (!canUpdateJitDbAccess) return setShowCreateRuleSheet(false) setRuleIdToEdit(user.id) } const openDeleteDialog = (user: JitUserRule) => { if (!canUpdateJitDbAccess) return setDeleteRuleError(null) setSelectedUserToDelete(user) } const handleConfirmDelete = async () => { setDeleteRuleError(null) try { if (!ref) throw new Error('Project ref is required') if (!selectedUserToDelete) throw new Error('User is required') await revokeUserAccess({ projectRef: ref, userId: selectedUserToDelete.memberId }) } catch (error) { setDeleteRuleError(getErrorMessage(error)) throw error } } const switchDisabled = isLoadingConfiguration || isUpdatingJitDbAccess || !canUpdateJitDbAccess const switchTooltipText = !canUpdateJitDbAccess ? 'Additional permissions required' : undefined const showToggleFailedWarning = isSuccessConfiguration && jitDbAccessConfiguration?.state !== 'unavailable' && !jitDbAccessConfiguration.appliedSuccessfully const projectReference = ref ? ( <> This project {ref} ) : ( 'This project' ) const unavailableTitle = unavailableReason === 'postgres_upgrade_required' ? 'Postgres upgrade required' : unavailableReason === 'manual_migration_required' ? 'Migration required' : 'Temporary access unavailable' const unavailableDescription = unavailableReason === 'postgres_upgrade_required' ? 'must be upgraded to Postgres 17 or later before temporary access can be enabled.' : unavailableReason === 'manual_migration_required' ? 'must be migrated before temporary access can be enabled. Contact support to migrate this project.' : 'This feature is currently unavailable for this project. Contact support if you need help enabling it.' useEffect(() => { if (!isLoadingConfiguration && jitDbAccessConfiguration) { setEnabled(initialIsEnabled ?? false) } }, [initialIsEnabled, isLoadingConfiguration, jitDbAccessConfiguration]) return ( <> Temporary access {parentProjectRef && ( Temporary access rules are configured in the main branch and apply across all preview branches. Return to the{' '} main branch {' '} to manage your access rules. } /> )} {!parentProjectRef && isErrorJitDbAccessConfiguration && ( )} {!parentProjectRef && !isErrorJitDbAccessConfiguration && isJitDbAccessUnavailable && ( {projectReference} {unavailableDescription} ) } actions={ unavailableReason === 'postgres_upgrade_required' && ref ? ( ) : ( ) } /> )} {!parentProjectRef && !isErrorJitDbAccessConfiguration && !isJitDbAccessUnavailable && (
{(isLoadingConfiguration || isUpdatingJitDbAccess) && ( )} {/* [Joshen] Added div as tooltip is messing with data state property of toggle */}
{switchTooltipText && ( {switchTooltipText} )}
{showToggleFailedWarning && ( The change didn’t apply. Try enabling or disabling temporary access again, or{' '} contact support {' '} if the issue persists. } className="mb-0 rounded-none border-0" /> )}
)} {!parentProjectRef && enabled && !isJitDbAccessUnavailable && !isUpdatingJitDbAccess && ( <> {isErrorJitMembers && ( )} )}
{ setDeleteRuleError(null) setSelectedUserToDelete(null) }} onConfirm={handleConfirmDelete} /> This will activate existing rules

Enabling temporary access will allow {activeRuleCount} pre-configured member {activeRuleCount === 1 ? '' : 's'} to request temporary database access immediately.

{enableJitError && ( )} Cancel Enable temporary access
) }