migrate jit-prototype work to sec-462 changes

This commit is contained in:
Danny White committed 2026-03-02 11:34:16 +11:00
1 parent 7a17174672
commit cfa8a90b86
13 files changed
+1535 -928

No files matched your search

@@ -1,326 +0,0 @@
import { useParams } from 'common'
import { CalendarIcon, X } from 'lucide-react'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Modal } from 'ui'
import { Form_Shadcn_ } from 'ui'
import {
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Select_Shadcn_,
} from 'ui'
import { DatePicker } from 'components/ui/DatePicker'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { format } from 'date-fns'
import dayjs from 'dayjs'
const FormSchema = z.object({
userId: z.string().min(1, 'Please select a user'),
roles: z
.array(
z.object({
role: z.string(),
expires_at: z.date().optional(),
})
)
.min(1, 'Please select at least one role'),
})
interface AddUserAccessModalProps {
visible: boolean
onClose: () => void
onSubmit: (values: {
userId: string
userName: string
userEmail: string
roles: Array<{ role: string; expires_at?: Date }>
}) => void
isSubmitting?: boolean
editMode?: {
userId: string
userName: string
userEmail: string
currentRoles: Array<{ role: string; expires_at?: Date }>
}
usersWithAccess?: string[] // Array of user IDs who already have access
}
const AddUserAccessModal = ({
visible,
onClose,
onSubmit,
isSubmitting = false,
editMode,
usersWithAccess = [],
}: AddUserAccessModalProps) => {
const formId = 'add-user-access-form'
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
userId: '',
roles: [],
},
})
// Update form when editMode changes or modal opens
useEffect(() => {
if (visible) {
if (editMode) {
form.reset({
userId: editMode.userId,
roles: editMode.currentRoles,
})
} else {
form.reset({
userId: '',
roles: [],
})
}
}
}, [editMode, visible, form])
const { data: members, isLoading: isLoadingMembers } = useOrganizationMembersQuery({
slug: organization?.slug,
})
// Filter out users who already have access (only in add mode)
const availableMembers = !editMode
? members?.filter((m) => !usersWithAccess.includes(m.gotrue_id ?? m.primary_email))
: members
const { data: databaseRoles, isLoading: isLoadingRoles } = useDatabaseRolesQuery({
projectRef: ref,
connectionString: project?.connectionString,
})
// Filter out system roles and roles that shouldn't be assigned
// and roles that can not login to the database
const assignableRoles =
databaseRoles?.filter(
(role) =>
role.canLogin &&
!role.is_superuser &&
!role.name.startsWith('pg_') &&
(!role.name.startsWith('supabase_') || role.name == 'supabase_read_only_user') &&
!['pgbouncer', 'authenticator'].find((x) => x == role.name)
) ?? []
const handleFormSubmit = async (values: z.infer<typeof FormSchema>) => {
const selectedMember = members?.find(
(m) => m.gotrue_id === values.userId || m.primary_email === values.userId
)
if (!selectedMember) {
toast.error('Selected user not found')
return
}
onSubmit({
userId: selectedMember.gotrue_id ?? values.userId,
userName: selectedMember.username ?? '',
userEmail: selectedMember.primary_email,
roles: values.roles,
})
}
const handleClose = () => {
form.reset()
onClose()
}
const toggleRole = (roleName: string) => {
const currentRoles = form.getValues('roles')
const roleExists = currentRoles.some((r) => r.role === roleName)
if (roleExists) {
// Remove role
const newRoles = currentRoles.filter((r) => r.role !== roleName)
form.setValue('roles', newRoles, { shouldValidate: true })
} else {
// Add role with no expiry date
const newRoles = [...currentRoles, { role: roleName, expires_at: undefined }]
form.setValue('roles', newRoles, { shouldValidate: true })
}
}
const updateRoleExpiry = (roleName: string, expires_at: Date | undefined) => {
const currentRoles = form.getValues('roles')
const newRoles = currentRoles.map((r) => (r.role === roleName ? { ...r, expires_at } : r))
form.setValue('roles', newRoles, { shouldValidate: true })
}
const selectedUserId = form.watch('userId')
const selectedRoles = form.watch('roles')
return (
<Modal
hideFooter
size="medium"
visible={visible}
onCancel={handleClose}
header={editMode ? 'Edit user database access' : 'Add user database access'}
>
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="!border-t-0">
<>
<Modal.Content className="space-y-6">
<p className="text-sm text-foreground-light">
{editMode
? 'Update the roles assigned to this user. The expiry date applies to all roles.'
: 'Grant a user just-in-time access to your database by assigning them one or more PostgreSQL roles. Optionally set an expiry date for automatic access revocation.'}
</p>
{/* User Selection */}
<div className="space-y-2">
<label className="text-sm" htmlFor="userId">
User
</label>
{editMode ? (
<div className="flex items-center space-x-2 p-3 bg-surface-100 rounded-md border">
<div className="flex flex-col">
<span className="text-sm">{editMode.userEmail}</span>
{editMode.userName && (
<span className="text-xs text-foreground-lighter">{editMode.userName}</span>
)}
</div>
</div>
) : (
<Select_Shadcn_
value={selectedUserId}
onValueChange={(value) =>
form.setValue('userId', value, { shouldValidate: true })
}
disabled={isLoadingMembers}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Select a user..." />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{availableMembers?.map((member) => {
const userId = member.gotrue_id
return (
<SelectItem_Shadcn_ key={userId} value={userId}>
<div className="flex flex-col">
<span>{member.primary_email}</span>
{member.username && (
<span className="text-xs text-foreground-lighter">
{member.username}
</span>
)}
</div>
</SelectItem_Shadcn_>
)
})}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
)}
</div>
{/* Role Selection */}
<div className="space-y-2">
<label className="text-sm">PostgreSQL Roles</label>
<div className="border rounded-md max-h-96 overflow-y-auto">
{isLoadingRoles ? (
<div className="p-4 text-sm text-foreground-light">Loading roles...</div>
) : assignableRoles.length === 0 ? (
<div className="p-4 text-sm text-foreground-light">
No assignable roles found
</div>
) : (
<div className="divide-y">
{assignableRoles.map((role) => {
const roleData = selectedRoles.find((r) => r.role === role.name)
const isSelected = !!roleData
return (
<div key={role.name} className="p-3 hover:bg-surface-100">
<div className="flex items-start space-x-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRole(role.name)}
className="rounded border-control mt-1"
/>
<div className="flex-1 space-y-2">
<div className="text-sm font-medium">{role.name}</div>
{isSelected && (
<div className="flex items-center gap-2">
<DatePicker
selectsRange={false}
triggerButtonType="default"
triggerButtonSize="tiny"
to={roleData?.expires_at?.toISOString()}
minDate={new Date()}
onChange={({ to }) => {
updateRoleExpiry(role.name, to ? new Date(to) : undefined)
}}
hideClear={false}
>
{roleData?.expires_at
? format(roleData.expires_at, 'PPp')
: 'Set expiry date'}
</DatePicker>
{roleData?.expires_at && (
<Button
type="text"
size="tiny"
icon={<X className="h-3 w-3" />}
onClick={() => updateRoleExpiry(role.name, undefined)}
/>
)}
</div>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{selectedRoles.length > 0 && (
<p className="text-xs text-foreground-light">
Selected {selectedRoles.length} role{selectedRoles.length !== 1 ? 's' : ''}
</p>
)}
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content className="flex items-center justify-end space-x-2">
<Button type="default" disabled={isSubmitting} onClick={handleClose}>
Cancel
</Button>
<Button
htmlType="submit"
loading={isSubmitting}
disabled={isSubmitting || !form.formState.isValid || selectedRoles.length === 0}
>
{editMode ? 'Update access' : 'Grant access'}
</Button>
</Modal.Content>
</>
</form>
</Form_Shadcn_>
</Modal>
)
}
export default AddUserAccessModal
@@ -1,24 +1,23 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Loader2, Plus } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { DocsButton } from 'components/ui/DocsButton'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useJitDbAccessQuery } from 'data/jit-db-access/jit-db-access-query'
import { useJitDbAccessUpdateMutation } from 'data/jit-db-access/jit-db-access-update-mutation'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { useJitDbAccessGrantMutation } from 'data/jit-db-access/jit-db-access-grant-mutation'
import { useJitDbAccessRevokeMutation } from 'data/jit-db-access/jit-db-access-revoke-mutation'
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 { ButtonTooltip } from 'components/ui/ButtonTooltip'
import AddUserAccessModal from './AddUserAccessModal'
import MembersView from './JitDbAccessMembersView'
import { DOCS_URL } from 'lib/constants'
import {
Alert,
@@ -30,8 +29,6 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
Card,
CardContent,
Switch,
@@ -48,64 +45,57 @@ import {
} from 'ui-patterns'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
import { JitDbAccessDeleteDialog } from './JitDbAccessDeleteDialog'
import { JitDbAccessRuleSheet } from './JitDbAccessRuleSheet'
import { JitDbAccessRulesTable } from './JitDbAccessRulesTable'
import {
getAssignableJitRoleOptions,
getJitMemberOptions,
mapJitMembersToUserRules,
serializeDraftRolesForGrantMutation,
} from './jitDbAccess.adapters'
import type { JitUserRule, SheetMode } from './jitDbAccess.types'
import { createDraft, draftFromRule } from './jitDbAccess.utils'
const JitDbAccessConfiguration = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isEnabled, setIsEnabled] = useState(false)
const [showAddUserModal, setShowAddUserModal] = useState(false)
const [editingUser, setEditingUser] = useState<
| {
userId: string
userName: string
userEmail: string
currentRoles: Array<{ role: string; expires_at?: Date }>
}
| undefined
>(undefined)
const { data: organization } = useSelectedOrganizationQuery()
const [enabled, setEnabled] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>('add')
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [showInlineValidation, setShowInlineValidation] = useState(false)
const [showEnableJitDialog, setShowEnableJitDialog] = useState(false)
const [userPendingDelete, setUserPendingDelete] = useState<JitUserRule | null>(null)
const [draft, setDraft] = useState(() => createDraft([]))
const {
data: jitDbAccessConfiguration,
isLoading,
isSuccess,
} = useJitDbAccessQuery({
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: jitMembers = [] } = useJitDbAccessMembersQuery({
const { data: organizationMembers, isLoading: isLoadingOrganizationMembers } =
useOrganizationMembersQuery({ slug: organization?.slug })
const { data: databaseRoles, isLoading: isLoadingDatabaseRoles } = useDatabaseRolesQuery({
projectRef: ref,
})
const { mutate: updateJitDbAccess, isLoading: isSubmitting } = useJitDbAccessUpdateMutation({
onSuccess: () => {
toast.success('Successfully updated just-in-time (JIT) database access configuration')
},
onError: (error) => {
setIsEnabled(initialIsEnabled)
toast.error(
`Failed to update just-in-time (JIT) database access enforcement: ${error.message}`
)
},
})
const { mutate: grantUserAccess, isLoading: isGranting } = useJitDbAccessGrantMutation({
onSuccess: () => {
toast.success(
editingUser ? 'Successfully updated user access' : 'Successfully granted user access'
)
setShowAddUserModal(false)
setEditingUser(undefined)
},
onError: (error) => {
toast.error(`Failed to ${editingUser ? 'update' : 'grant'} user access: ${error.message}`)
},
})
const { mutate: revokeUserAccess, isLoading: isRevoking } = useJitDbAccessRevokeMutation({
onSuccess: () => {
toast.success('Successfully revoked user access')
},
onError: (error) => {
toast.error(`Failed to revoke user access: ${error.message}`)
},
connectionString: project?.connectionString,
})
const { can: canUpdateJitDbAccess } = useAsyncCheckPermissions(
@@ -117,211 +107,409 @@ const JitDbAccessConfiguration = () => {
},
}
)
const initialIsEnabled = isSuccess
? jitDbAccessConfiguration.appliedSuccessfully && jitDbAccessConfiguration.state == 'enabled'
: false
const initialIsEnabled =
isSuccessConfiguration &&
!!jitDbAccessConfiguration &&
jitDbAccessConfiguration.appliedSuccessfully &&
jitDbAccessConfiguration.state === 'enabled'
const hasAccessToJitDbAccess = !(
jitDbAccessConfiguration !== undefined &&
'isUnavailable' in jitDbAccessConfiguration &&
jitDbAccessConfiguration.isUnavailable
)
const env = process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? 'prod' : 'staging'
useEffect(() => {
if (!isLoading && jitDbAccessConfiguration) {
setIsEnabled(initialIsEnabled)
if (!isLoadingConfiguration && jitDbAccessConfiguration) {
setEnabled(initialIsEnabled)
}
}, [isLoading])
}, [initialIsEnabled, isLoadingConfiguration, jitDbAccessConfiguration])
const roleOptions = useMemo(() => getAssignableJitRoleOptions(databaseRoles), [databaseRoles])
const roleIds = useMemo(() => roleOptions.map((role) => role.id), [roleOptions])
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 === editingUserId) ?? null,
[users, editingUserId]
)
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 isDuplicateSelectedMember =
sheetMode === 'add' && draft.memberId !== '' && membersWithRules.has(draft.memberId)
const enabledRoleCount = useMemo(
() => draft.grants.filter((grant) => grant.enabled).length,
[draft.grants]
)
const activeRuleCount = useMemo(
() => users.filter((user) => user.status.active > 0).length,
[users]
)
const inlineValidation = useMemo(
() => ({
member: !draft.memberId
? 'Select a member for this JIT access rule.'
: isDuplicateSelectedMember
? 'This member already has a JIT access rule. Edit their existing rule from the list.'
: undefined,
roles: enabledRoleCount > 0 ? undefined : 'Select at least one role.',
}),
[draft.memberId, enabledRoleCount, isDuplicateSelectedMember]
)
const resetSheetState = () => {
setSheetOpen(false)
setEditingUserId(null)
setShowInlineValidation(false)
}
const closeSheet = () => {
resetSheetState()
}
const submitJitToggle = (nextEnabled: boolean) => {
if (!ref) {
console.error('Project ref is required')
return
}
setEnabled(nextEnabled)
const toggleJitDbAccess = async () => {
if (!ref) return console.error('Project ref is required')
setIsEnabled(!isEnabled)
updateJitDbAccess({
projectRef: ref,
requestedConfig: { state: !isEnabled ? 'enabled' : 'disabled' },
requestedConfig: { state: nextEnabled ? 'enabled' : 'disabled' },
})
}
const handleGrantAccess = (values: {
userId: string
userName: string
userEmail: string
roles: Array<{ role: string; expires_at?: Date }>
}) => {
if (!ref) return console.error('Project ref is required')
const { mutate: updateJitDbAccess, isPending: isUpdatingJitDbAccess } =
useJitDbAccessUpdateMutation({
onSuccess: (_, variables) => {
const nextEnabled = variables.requestedConfig.state === 'enabled'
// Convert Date objects to Unix timestamps (seconds) for each role
const rolesWithTimestamps = values.roles.map((r) => ({
role: r.role,
expires_at: r.expires_at ? Math.floor(r.expires_at.getTime() / 1000) : undefined,
}))
if (nextEnabled) {
toast.success('JIT access enabled')
} else {
toast.success(
activeRuleCount > 0
? `JIT access disabled. ${activeRuleCount} configured member${activeRuleCount === 1 ? '' : 's'} can no longer request temporary database access.`
: 'JIT access disabled.'
)
}
},
onError: (error) => {
setEnabled(initialIsEnabled)
toast.error(`Failed to update just-in-time (JIT) database access: ${error.message}`)
},
})
const { mutate: grantUserAccess, isPending: isGrantingAccess } = useJitDbAccessGrantMutation({
onSuccess: () => {
toast.success(
sheetMode === 'edit'
? 'Successfully updated user access'
: 'Successfully granted user access'
)
resetSheetState()
},
onError: (error) => {
toast.error(
`Failed to ${sheetMode === 'edit' ? 'update' : 'grant'} user access: ${error.message}`
)
},
})
const { mutate: revokeUserAccess, isPending: isRevokingAccess } = useJitDbAccessRevokeMutation({
onSuccess: (_, variables) => {
toast.success('Successfully revoked user access')
setUserPendingDelete(null)
if (editingUserId === variables.userId) {
resetSheetState()
}
},
onError: (error) => {
toast.error(`Failed to revoke user access: ${error.message}`)
},
})
const isMutating = isUpdatingJitDbAccess || isGrantingAccess || isRevokingAccess
const disableRuleActions = isMutating || isLoadingDatabaseRoles || isLoadingOrganizationMembers
const isRulesLoading = isLoadingJitMembers || isLoadingProjectMembers
const handleJitToggleChange = (checked: boolean) => {
if (!hasAccessToJitDbAccess || !canUpdateJitDbAccess) return
if (checked && !enabled) {
if (activeRuleCount > 0) {
setShowEnableJitDialog(true)
return
}
submitJitToggle(true)
return
}
if (!checked && enabled) {
submitJitToggle(false)
}
}
const handleConfirmEnableJit = () => {
setShowEnableJitDialog(false)
submitJitToggle(true)
}
const openAddRuleSheet = () => {
if (!canUpdateJitDbAccess) return
setSheetMode('add')
setEditingUserId(null)
setDraft(createDraft(roleIds))
setShowInlineValidation(false)
setSheetOpen(true)
}
const openEditRuleSheet = (user: JitUserRule) => {
if (!canUpdateJitDbAccess) return
setSheetMode('edit')
setEditingUserId(user.id)
setDraft(draftFromRule(user, roleIds))
setShowInlineValidation(false)
setSheetOpen(true)
}
const openDeleteDialog = (user: JitUserRule) => {
if (!canUpdateJitDbAccess) return
setUserPendingDelete(user)
}
const handleSaveRule = () => {
setShowInlineValidation(true)
if (inlineValidation.member || inlineValidation.roles) return
if (!ref) {
console.error('Project ref is required')
return
}
const roles = serializeDraftRolesForGrantMutation(draft)
if (roles.length === 0) return
grantUserAccess({
projectRef: ref,
userId: values.userId,
roles: rolesWithTimestamps,
userId: draft.memberId,
roles,
})
}
const handleRevokeAccess = (userId: string, userEmail: string) => {
if (!ref) return console.error('Project ref is required')
const handleConfirmDelete = () => {
if (!ref || !userPendingDelete) {
if (!ref) console.error('Project ref is required')
return
}
revokeUserAccess({
projectRef: ref,
userId,
userId: userPendingDelete.memberId,
})
}
const switchDisabled =
isLoadingConfiguration ||
isUpdatingJitDbAccess ||
!canUpdateJitDbAccess ||
!hasAccessToJitDbAccess
const switchTooltipText = !canUpdateJitDbAccess
? 'You need additional permissions to update JIT database access for your project.'
: !hasAccessToJitDbAccess
? 'Your project does not have access to JIT database access. Please update to the latest Postgres version.'
: undefined
return (
<>
<PageSection id="jit-db-access-configuration">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Just-in-time (JIT) database access</PageSectionTitle>
<PageSectionTitle>Just-in-Time (JIT)</PageSectionTitle>
</PageSectionSummary>
<DocsButton href={`${DOCS_URL}/guides/platform/just-in-time-database-access`} />
</PageSectionMeta>
<PageSectionContent>
<PageSectionContent className="space-y-4">
{isErrorJitDbAccessConfiguration && (
<AlertError
projectRef={ref}
subject="Failed to retrieve JIT database access configuration"
error={jitDbAccessConfigurationError as { message: string } | null}
/>
)}
<Card>
<CardContent className="space-y-4">
<FormLayout
layout="flex-row-reverse"
label="Enable JIT access"
description="Allow configured project members to request temporary database access."
>
<div className="flex w-fit flex-shrink-0 items-center justify-end gap-2">
{(isLoadingConfiguration || isUpdatingJitDbAccess) && (
<Loader2 className="animate-spin" strokeWidth={1.5} size={16} />
)}
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
size="large"
checked={enabled}
onCheckedChange={handleJitToggleChange}
disabled={switchDisabled}
/>
</div>
</TooltipTrigger>
{switchTooltipText && (
<TooltipContent side="bottom">{switchTooltipText}</TooltipContent>
)}
</Tooltip>
</div>
</FormLayout>
{isSuccessConfiguration && !jitDbAccessConfiguration?.appliedSuccessfully && (
<Alert withIcon variant="warning" title="JIT access was not updated successfully">
Please try updating again, or contact support if this error persists
</Alert>
)}
</CardContent>
</Card>
{!hasAccessToJitDbAccess && (
<Alert
withIcon
variant="warning"
title="Just-in-time database access is unavailable"
className="mb-4"
>
Your project does not have access to just-in-time database access. This feature
requires Postgres version 17 or later. Please{' '}
<Alert withIcon variant="warning" title="Just-in-time database access is unavailable">
This feature requires Postgres version 17 or later. Please{' '}
<Link
href={`/project/${ref}/settings/infrastructure`}
className="underline font-medium"
className="font-medium underline"
>
upgrade your database
</Link>{' '}
to the latest Postgres version to enable this feature.
to the latest Postgres version to enable JIT access.
</Alert>
)}
{hasAccessToJitDbAccess && (
<Card>
<CardContent className="space-y-4">
<FormLayout
layout="flex-row-reverse"
label="Enable JIT database access"
description="Allow JIT access to your database using Personal Access Tokens (PAT)."
>
<AlertDialog>
<AlertDialogTrigger asChild>
<div className="flex items-center justify-end mt-2.5 space-x-2">
{(isLoading || isSubmitting) && (
<Loader2 className="animate-spin" strokeWidth={1.5} size={16} />
)}
{isSuccess && (
<Tooltip>
<TooltipTrigger asChild>
{/* [Joshen] Added div as tooltip is messing with data state property of toggle */}
<div>
<Switch
size="large"
checked={isEnabled}
disabled={
isLoading ||
isSubmitting ||
!canUpdateJitDbAccess ||
!hasAccessToJitDbAccess
}
/>
</div>
</TooltipTrigger>
{(!canUpdateJitDbAccess || !hasAccessToJitDbAccess) && (
<TooltipContent side="bottom" className="w-64 text-center">
{!canUpdateJitDbAccess
? 'You need additional permissions to update Just-in-time database access for your project'
: !hasAccessToJitDbAccess
? 'Your project does not have access to Just-in-time database access. Please update to the latest Postgres version'
: undefined}
</TooltipContent>
)}
</Tooltip>
)}
</div>
</AlertDialogTrigger>
<AlertDialogContent size="medium">
<AlertDialogHeader>
<AlertDialogTitle>
{jitDbAccessConfiguration?.state !== 'enabled'
? 'Access must be configured'
: 'Users will lose access'}
</AlertDialogTitle>
<AlertDialogDescription>
{jitDbAccessConfiguration?.state !== 'enabled'
? 'Access via JIT must be granted on a per user basis. Any existing mappings will immediately become active when JIT access is enabled. Confirm to proceed now?'
: 'Users with JIT access will no longer be able to connect. The user to role mappings will not be removed and access will automatically be restored if JIT access is re-enabled. Confirm to proceed now?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="warning"
disabled={isSubmitting}
onClick={toggleJitDbAccess}
>
{jitDbAccessConfiguration?.state !== 'enabled'
? 'Enable JIT Access'
: 'Disable JIT Access'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</FormLayout>
{isSuccess && !jitDbAccessConfiguration?.appliedSuccessfully && (
<Alert withIcon variant="warning" title="JIT access was not updated successfully">
Please try updating again, or contact support if this error persists
</Alert>
)}
</CardContent>
</Card>
)}
{isEnabled && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">User Access</h3>
<p className="text-sm text-foreground-light">
Manage which users have JIT database access
</p>
</div>
<Button
type="default"
icon={<Plus />}
onClick={() => setShowAddUserModal(true)}
disabled={!canUpdateJitDbAccess}
>
Add user
</Button>
</div>
<MembersView
onEditUser={(user) => {
setEditingUser(user)
setShowAddUserModal(true)
}}
onRevokeAccess={handleRevokeAccess}
{enabled && hasAccessToJitDbAccess && (
<>
{isErrorJitMembers && (
<AlertError
projectRef={ref}
subject="Failed to retrieve JIT access rules"
error={jitMembersError as { message: string } | null}
/>
)}
<JitDbAccessRulesTable
users={users}
isLoading={isRulesLoading}
canUpdate={!!canUpdateJitDbAccess}
disableActions={disableRuleActions}
onAddRule={openAddRuleSheet}
onEditRule={openEditRuleSheet}
onDeleteRule={openDeleteDialog}
/>
</div>
</>
)}
</PageSectionContent>
</PageSection>
<AddUserAccessModal
visible={showAddUserModal}
onClose={() => {
setShowAddUserModal(false)
setEditingUser(undefined)
<JitDbAccessRuleSheet
open={sheetOpen}
mode={sheetMode}
draft={draft}
editingUser={editingUser}
memberOptions={memberOptionsForSheet}
availableMembersForAddCount={availableMembersForAdd.length}
showInlineValidation={showInlineValidation}
inlineValidation={inlineValidation}
isSubmitting={isGrantingAccess}
onDraftChange={setDraft}
onCancel={closeSheet}
onSave={handleSaveRule}
onRequestDelete={() => {
if (editingUser) openDeleteDialog(editingUser)
}}
onSubmit={handleGrantAccess}
isSubmitting={isGranting}
editMode={editingUser}
usersWithAccess={jitMembers.map((m) => m.user_id)}
/>
<JitDbAccessDeleteDialog
user={userPendingDelete}
open={userPendingDelete !== null}
isDeleting={isRevokingAccess}
onOpenChange={(open) => {
if (!open) setUserPendingDelete(null)
}}
onConfirm={handleConfirmDelete}
/>
<AlertDialog open={showEnableJitDialog} onOpenChange={setShowEnableJitDialog}>
<AlertDialogContent size="small">
<AlertDialogHeader>
<AlertDialogTitle>JIT access will activate existing rules</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-sm">
<p>
Enabling JIT will allow {activeRuleCount} configured member
{activeRuleCount === 1 ? '' : 's'} to request temporary database access
immediately.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isUpdatingJitDbAccess}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="warning"
disabled={isUpdatingJitDbAccess}
onClick={handleConfirmEnableJit}
>
Enable JIT access
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,57 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from 'ui'
import type { JitUserRule } from './jitDbAccess.types'
interface JitDbAccessDeleteDialogProps {
user: JitUserRule | null
open: boolean
isDeleting?: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export function JitDbAccessDeleteDialog({
user,
open,
isDeleting = false,
onOpenChange,
onConfirm,
}: JitDbAccessDeleteDialogProps) {
const userDisplayName = user?.name ?? user?.email ?? 'this user'
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent size="small">
<AlertDialogHeader>
<AlertDialogTitle>Delete JIT access rule</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-sm">
<p>
Remove the JIT access rule for <strong>{userDisplayName}</strong>?
</p>
<p>
This revokes any assigned database roles for this member and removes their JIT
access configuration.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction variant="danger" disabled={isDeleting} onClick={onConfirm}>
Delete rule
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -1,251 +0,0 @@
import { AlertCircle, HelpCircle, Trash2, Pencil } from 'lucide-react'
import { format } from 'date-fns'
import { useState } from 'react'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { useJitDbAccessMembersQuery } from 'data/jit-db-access/jit-db-access-members-query'
import { useProjectMembersQuery } from 'data/projects/project-members-query'
import { useProfile } from 'lib/profile'
import { partition } from 'lodash'
import { useMemo } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Loading,
Tooltip,
TooltipContent,
TooltipTrigger,
Table,
TableHeader,
TableHead,
TableBody,
TableCell,
TableRow,
Card,
CardContent,
Badge,
Skeleton,
} from 'ui'
import { Admonition } from 'ui-patterns'
//import { MemberRow } from './MemberRow'
export interface MembersViewProps {
onEditUser?: (user: {
userId: string
userName: string
userEmail: string
currentRoles: Array<{ role: string; expires_at?: Date }>
}) => void
onRevokeAccess?: (userId: string, userEmail: string) => void
}
const MembersView = ({ onEditUser, onRevokeAccess }: MembersViewProps) => {
const { ref } = useParams()
const { profile } = useProfile()
const [userToRevoke, setUserToRevoke] = useState<{ userId: string; userEmail: string } | null>(
null
)
const {
data: members = [],
error: membersError,
isLoading: isLoadingMembers,
isError: isErrorMembers,
isSuccess: isSuccessMembers,
} = useJitDbAccessMembersQuery({ projectRef: ref })
const {
data: projectMembers = [],
error: projectMembersError,
isLoading: isLoadingProjectMembers,
isError: isErrorProjectMembers,
isSuccess: isSuccessProjectMembers,
} = useProjectMembersQuery({ projectRef: ref })
const memberMap = new Map(projectMembers.map((m) => [m.user_id, m]))
const decoratedMembers = members.map((item) => {
const member = memberMap.get(item.user_id)
if (member) {
return {
...item,
primary_email: member.primary_email,
username: member.username,
}
}
return item // if no match, leave it as is
})
return (
<>
{isLoadingMembers && (
<Card>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
)}
{isErrorMembers && (
<AlertError error={membersError} subject="Failed to retrieve organization members" />
)}
{isSuccessMembers && (
<>
{decoratedMembers.length === 0 ? (
<Card>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-8 w-8 text-foreground-light mb-3" />
<p className="text-sm text-foreground-light">No users have JIT database access</p>
<p className="text-xs text-foreground-lighter mt-1">
Click "Add user" to grant access
</p>
</div>
</CardContent>
</Card>
) : (
<div className="rounded w-full overflow-hidden">
<Card>
<Loading active={!members}>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Expiry</TableHead>
<TableHead className="w-32 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{decoratedMembers.map((member) => (
<TableRow key={member.user_id}>
<TableCell>
<div className="flex flex-col">
<span className="text-sm">
{member.primary_email || member.user_id}
</span>
{member.username && (
<span className="text-xs text-foreground-lighter">
{member.username}
</span>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{member.user_roles?.map((roleObj, idx) => (
<Badge key={idx} variant="outline">
{roleObj.role}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{member.user_roles?.map((roleObj, idx) => (
<span key={idx} className="text-xs text-foreground-light">
{roleObj.role}:{' '}
{roleObj.expires_at
? format(new Date(roleObj.expires_at * 1000), 'PPPp')
: 'Never'}
</span>
))}
</div>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<Pencil />}
className="px-1"
onClick={() =>
onEditUser?.({
userId: member.user_id,
userName: member.username || '',
userEmail: member.primary_email || member.user_id,
currentRoles:
member.user_roles?.map((r) => ({
role: r.role,
expires_at: r.expires_at
? new Date(r.expires_at * 1000)
: undefined,
})) || [],
})
}
/>
</TooltipTrigger>
<TooltipContent>Edit access</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<Trash2 />}
className="px-1"
onClick={() =>
setUserToRevoke({
userId: member.user_id,
userEmail: member.primary_email || member.user_id,
})
}
/>
</TooltipTrigger>
<TooltipContent>Revoke access</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Loading>
</Card>
</div>
)}
</>
)}
<AlertDialog open={!!userToRevoke} onOpenChange={() => setUserToRevoke(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke database access</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to revoke database access for{' '}
<span className="font-medium">{userToRevoke?.userEmail}</span>? This will remove all
assigned roles and they will no longer be able to connect to the database.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setUserToRevoke(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (userToRevoke) {
onRevokeAccess?.(userToRevoke.userId, userToRevoke.userEmail)
setUserToRevoke(null)
}
}}
>
Revoke access
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default MembersView
@@ -0,0 +1,230 @@
import dayjs from 'dayjs'
import { DatePicker } from 'components/ui/DatePicker'
import { InlineLink } from 'components/ui/InlineLink'
import { DOCS_URL } from 'lib/constants'
import {
Checkbox_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { JIT_EXPIRY_MODE_OPTIONS, JIT_MAX_CUSTOM_EXPIRY_YEARS } from './jitDbAccess.constants'
import type { JitRoleGrantDraft, JitRoleOption } from './jitDbAccess.types'
import { getRelativeDatetimeByMode } from './jitDbAccess.utils'
interface JitDbAccessRoleGrantFieldsProps {
role: JitRoleOption
grant: JitRoleGrantDraft
onChange: (next: JitRoleGrantDraft) => void
}
export function JitDbAccessRoleGrantFields({
role,
grant,
onChange,
}: JitDbAccessRoleGrantFieldsProps) {
const isSuperuserRole = role.id === 'postgres'
const isReadOnlyRole = role.id === 'supabase_read_only_user'
const checkboxId = `jit-role-${role.id}`
return (
<div className={grant.enabled ? 'bg-surface-100' : 'bg-background'}>
<label
htmlFor={checkboxId}
className={`grid w-full cursor-pointer select-none grid-cols-[16px_minmax(0,1fr)] items-start gap-x-3 px-4 py-3 transition-colors ${
grant.enabled ? 'hover:bg-surface-200/40' : 'hover:bg-surface-100/50'
}`}
>
<Checkbox_Shadcn_
id={checkboxId}
checked={grant.enabled}
onCheckedChange={(value) => {
const isEnabled = value === true
if (!isEnabled) {
onChange({ ...grant, enabled: false })
return
}
if (
(grant.hasExpiry && grant.expiry) ||
(!grant.hasExpiry && grant.expiryMode === 'never')
) {
onChange({ ...grant, enabled: true })
return
}
onChange({
...grant,
enabled: true,
hasExpiry: true,
expiryMode: '1h',
expiry: getRelativeDatetimeByMode('1h'),
})
}}
aria-label={`Enable ${role.label}`}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<code className="text-code-inline dark:!bg-surface-300 dark:!border-control !tracking-normal">
{role.label}
</code>
</div>
</label>
{grant.enabled && (
<div className="grid grid-cols-[16px_minmax(0,1fr)] gap-x-3 px-4 pb-3">
<div aria-hidden />
<div className="space-y-4">
{isSuperuserRole && (
<Admonition
type="warning"
showIcon={false}
layout="vertical"
title="Grants full database control"
description={
<>
The selected role has unrestricted access and bypasses row-level security.
Consider using a{' '}
<InlineLink href={`${DOCS_URL}/guides/database/postgres/roles`}>
custom Postgres role
</InlineLink>{' '}
with only the permissions required.
</>
}
/>
)}
{isReadOnlyRole && (
<Admonition
type="warning"
showIcon={false}
layout="vertical"
title="Grants read-only access to all schemas"
description={
<>
The selected role has read-only access to all schemas. Consider using a{' '}
<InlineLink href={`${DOCS_URL}/guides/database/postgres/roles`}>
custom Postgres role
</InlineLink>{' '}
with only the permissions required.
</>
}
className="rounded-md"
/>
)}
<div className="space-y-2 border-t border-muted pt-3">
<p className="text-sm text-foreground">Expires in</p>
<div className="flex gap-2">
<div className="flex-1">
<Select_Shadcn_
value={grant.expiryMode}
onValueChange={(value) => {
const nextMode = value as JitRoleGrantDraft['expiryMode']
if (nextMode === 'never') {
onChange({
...grant,
expiryMode: nextMode,
hasExpiry: false,
expiry: '',
})
return
}
if (nextMode === 'custom') {
onChange({
...grant,
expiryMode: nextMode,
hasExpiry: true,
expiry: grant.expiry || getRelativeDatetimeByMode('1h'),
})
return
}
onChange({
...grant,
expiryMode: nextMode,
hasExpiry: true,
expiry: getRelativeDatetimeByMode(nextMode),
})
}}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Expires in" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{JIT_EXPIRY_MODE_OPTIONS.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
{grant.expiryMode === 'custom' && (
<DatePicker
selectsRange={false}
triggerButtonSize="small"
contentSide="top"
to={grant.expiry || undefined}
minDate={new Date()}
maxDate={dayjs().add(JIT_MAX_CUSTOM_EXPIRY_YEARS, 'year').toDate()}
onChange={(value) => {
const selectedDate = value.to || value.from || ''
onChange({
...grant,
hasExpiry: true,
expiry: selectedDate,
})
}}
triggerButtonClassName="min-w-[120px]"
>
{grant.expiry ? dayjs(grant.expiry).format('DD MMM, HH:mm') : 'Select date'}
</DatePicker>
)}
</div>
{grant.expiryMode === 'never' && (
<div className="mt-3 mx-0.5 flex w-full items-center gap-x-2">
<WarningIcon />
<span className="text-left text-xs text-foreground-lighter">
No expiry means ongoing database access until manually revoked.
</span>
</div>
)}
</div>
<div className="space-y-2">
<p className="text-sm text-foreground">
Restricted IP addresses{' '}
<span className="font-normal text-foreground-lighter">(optional)</span>
</p>
<Input_Shadcn_
value={grant.ipRanges}
onChange={(event) =>
onChange({
...grant,
hasIpRestriction: event.target.value.trim().length > 0,
ipRanges: event.target.value,
})
}
placeholder="e.g. 192.168.0.0/24, 203.0.113.4/32"
/>
<p className="text-xs text-foreground-lighter">Comma-separated CIDR ranges</p>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,183 @@
import { DOCS_URL } from 'lib/constants'
import { InlineLink } from 'components/ui/InlineLink'
import {
Button,
ScrollArea,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { JitDbAccessRoleGrantFields } from './JitDbAccessRoleGrantFields'
import type { JitMemberOption, JitUserRule, JitUserRuleDraft, SheetMode } from './jitDbAccess.types'
interface JitDbAccessRuleSheetProps {
open: boolean
mode: SheetMode
draft: JitUserRuleDraft
editingUser: JitUserRule | null
memberOptions: JitMemberOption[]
availableMembersForAddCount: number
showInlineValidation: boolean
inlineValidation: {
member?: string
roles?: string
}
isSubmitting?: boolean
onDraftChange: (next: JitUserRuleDraft) => void
onCancel: () => void
onSave: () => void
onRequestDelete: () => void
}
export function JitDbAccessRuleSheet({
open,
mode,
draft,
editingUser,
memberOptions,
availableMembersForAddCount,
showInlineValidation,
inlineValidation,
isSubmitting = false,
onDraftChange,
onCancel,
onSave,
onRequestDelete,
}: JitDbAccessRuleSheetProps) {
const updateGrant = (
roleId: string,
updater: (grant: JitUserRuleDraft['grants'][number]) => JitUserRuleDraft['grants'][number]
) => {
onDraftChange({
...draft,
grants: draft.grants.map((grant) => (grant.roleId === roleId ? updater(grant) : grant)),
})
}
return (
<Sheet open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
<SheetContent
showClose={false}
size="default"
className="flex h-full w-full max-w-full flex-col gap-0 sm:!w-[560px] sm:max-w-[560px]"
>
<SheetHeader>
<SheetTitle>
{mode === 'edit' ? 'Edit JIT access rule' : 'New JIT access rule'}
</SheetTitle>
<SheetDescription className="sr-only">
Configure which database roles a user can request with JIT access.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
<div className="space-y-8 px-5 py-6 sm:px-6">
<FormItemLayout layout="vertical" isReactForm={false} label="Member">
<Select_Shadcn_
value={draft.memberId}
disabled={mode === 'edit' || (mode === 'add' && availableMembersForAddCount === 0)}
onValueChange={(value) => onDraftChange({ ...draft, memberId: value })}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select a member" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{memberOptions.map((member) => (
<SelectItem_Shadcn_ key={member.id} value={member.id}>
{member.name ? (
<>
{member.name}{' '}
<span className="text-foreground-lighter">({member.email})</span>
</>
) : (
member.email
)}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
{mode === 'edit' && (
<p className="mt-2 text-xs text-foreground-lighter">
The member cannot be changed when editing an existing JIT access rule.
</p>
)}
{mode === 'add' && availableMembersForAddCount === 0 && (
<p className="mt-2 text-xs text-foreground-lighter">
All project members already have JIT access rules. Edit an existing rule from the
table above.
</p>
)}
{showInlineValidation && inlineValidation.member && (
<p className="mt-2 text-xs text-destructive">{inlineValidation.member}</p>
)}
</FormItemLayout>
<FormItemLayout layout="vertical" isReactForm={false} label="Roles and settings">
<div className="overflow-hidden rounded-md border">
{draft.grants.length === 0 ? (
<div className="p-4 text-sm text-foreground-light">No assignable roles found</div>
) : (
draft.grants.map((grant, index) => (
<div key={grant.roleId} className={index > 0 ? 'border-t' : ''}>
<JitDbAccessRoleGrantFields
role={{ id: grant.roleId, label: grant.roleId }}
grant={grant}
onChange={(next) => updateGrant(grant.roleId, () => next)}
/>
</div>
))
)}
</div>
<p className="mt-2 text-xs leading-normal text-foreground-lighter">
Define scoped permissions with{' '}
<InlineLink
href={`${DOCS_URL}/guides/database/postgres/roles`}
className="decoration-foreground-muted"
>
custom Postgres roles
</InlineLink>{' '}
before creating a JIT access rule. Narrow roles limit the impact of direct database
access.
</p>
{showInlineValidation && inlineValidation.roles && (
<p className="text-xs text-destructive">{inlineValidation.roles}</p>
)}
</FormItemLayout>
</div>
</ScrollArea>
<SheetFooter className="mt-auto w-full border-t py-4">
{mode === 'edit' && editingUser && (
<div className="mr-auto">
<Button type="danger" onClick={onRequestDelete} disabled={isSubmitting}>
Delete rule
</Button>
</div>
)}
<Button type="default" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="primary" onClick={onSave} disabled={isSubmitting}>
{mode === 'edit' ? 'Save rule' : 'Create rule'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,214 @@
import { EllipsisVertical, Pencil, Plus, Trash2 } from 'lucide-react'
import {
Badge,
Button,
Card,
CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import type { JitUserRule } from './jitDbAccess.types'
import { getJitStatusDisplay } from './jitDbAccess.utils'
interface JitDbAccessRulesTableProps {
users: JitUserRule[]
isLoading?: boolean
canUpdate: boolean
disableActions?: boolean
onAddRule: () => void
onEditRule: (user: JitUserRule) => void
onDeleteRule: (user: JitUserRule) => void
}
export function JitDbAccessRulesTable({
users,
isLoading = false,
canUpdate,
disableActions = false,
onAddRule,
onEditRule,
onDeleteRule,
}: JitDbAccessRulesTableProps) {
const addDisabled = disableActions || !canUpdate
if (isLoading) {
return (
<Card>
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-9 w-24" />
</div>
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
)
}
return (
<Card>
<CardContent className="space-y-4 p-0">
<div className="flex items-center justify-between px-4 pb-2 pt-6">
<div>
<h3 className="text-sm text-foreground">JIT access rules</h3>
<p className="text-sm text-foreground-light">
Configure which members can request temporary database access.
</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
type="default"
icon={<Plus size={14} />}
onClick={onAddRule}
disabled={addDisabled}
>
Add rule
</Button>
</div>
</TooltipTrigger>
{!canUpdate && (
<TooltipContent side="bottom">
You need additional permissions to manage JIT access rules.
</TooltipContent>
)}
</Tooltip>
</div>
<Table className="border-t">
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-1">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={4}>
<p className="text-sm text-foreground">No JIT access rules</p>
<p className="text-sm text-foreground-lighter">
Add your first JIT access rule above
</p>
</TableCell>
</TableRow>
) : (
users.map((user) => {
const statusDisplay = getJitStatusDisplay(user.status)
const enabledGrants = user.grants.filter((grant) => grant.enabled)
const rowIsInteractive = canUpdate && !disableActions
return (
<TableRow
key={user.id}
className={rowIsInteractive ? 'relative inset-focus cursor-pointer' : undefined}
onClick={
rowIsInteractive
? (event) => {
if ((event.target as HTMLElement).closest('button')) return
onEditRule(user)
}
: undefined
}
onKeyDown={
rowIsInteractive
? (event) => {
if ((event.target as HTMLElement).closest('button')) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onEditRule(user)
}
}
: undefined
}
tabIndex={rowIsInteractive ? 0 : undefined}
>
<TableCell className="text-sm">
{user.name && <p>{user.name}</p>}
<p className="text-foreground-lighter">{user.email}</p>
</TableCell>
<TableCell className="text-sm text-foreground-light">
{enabledGrants.length} role{enabledGrants.length === 1 ? '' : 's'}
</TableCell>
<TableCell className="text-sm text-foreground-light">
{statusDisplay.badges.length > 0 ? (
<span className="flex flex-wrap gap-1.5">
{statusDisplay.badges.map((badge) => (
<Badge key={badge.label} variant={badge.variant}>
{badge.label}
</Badge>
))}
</span>
) : null}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
icon={<EllipsisVertical />}
aria-label="More actions"
type="default"
size="tiny"
className="w-7"
disabled={!canUpdate || disableActions}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-40">
<DropdownMenuItem
className="gap-x-2"
onClick={(event) => {
event.stopPropagation()
onEditRule(user)
}}
disabled={!canUpdate || disableActions}
>
<Pencil size={14} className="text-foreground-lighter" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="gap-x-2"
onClick={(event) => {
event.stopPropagation()
onDeleteRule(user)
}}
disabled={!canUpdate || disableActions}
>
<Trash2 size={14} className="text-foreground-lighter" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
@@ -1,137 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { FormActions } from 'components/ui/Forms/FormActions'
import { useDatabaseRoleCreateMutation } from 'data/database-roles/database-role-create-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
SidePanel,
Switch,
} from 'ui'
interface ManageJitAccessPanelProps {
visible: boolean
onClose: () => void
}
const FormSchema = z.object({
name: z.string().trim().min(1, 'You must provide a name').default(''),
isSuperuser: z.boolean().default(false),
canLogin: z.boolean().default(false),
canCreateRole: z.boolean().default(false),
canCreateDb: z.boolean().default(false),
isReplicationRole: z.boolean().default(false),
canBypassRls: z.boolean().default(false),
})
const initialValues = {
name: '',
isSuperuser: false,
canLogin: false,
canCreateRole: false,
canCreateDb: false,
isReplicationRole: false,
canBypassRls: false,
}
export const ManageJitAccessPanel = ({ visible, onClose }: ManageJitAccessPanelProps) => {
const formId = 'add-jit-access'
const { data: project } = useSelectedProjectQuery()
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
})
const { mutate: createDatabaseRole, isLoading: isCreating } = useDatabaseRoleCreateMutation({
onSuccess: (_, vars) => {
toast.success(`Successfully updated user's access: ${vars.payload.name}`)
handleClose()
},
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!project) return console.error('Project is required')
// createDatabaseRole({
// projectRef: project.ref,
// connectionString: project.connectionString,
// payload: values,
// })
}
const handleClose = () => {
onClose()
form.reset(initialValues)
}
return (
<SidePanel
size="large"
visible={visible}
header="Manage user's JIT access"
className="mr-0 transform transition-all duration-300 ease-in-out"
loading={false}
onCancel={handleClose}
customFooter={
<div className="flex w-full justify-end space-x-3 border-t border-default px-3 py-4">
<FormActions
form={formId}
isSubmitting={isCreating}
hasChanges={form.formState.isDirty}
handleReset={handleClose}
/>
</div>
}
>
<Form_Shadcn_ {...form}>
<form
id={formId}
className="grid gap-6 w-full px-8 py-8"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItem_Shadcn_ className="grid gap-2 md:grid md:grid-cols-12 space-y-0">
<FormLabel_Shadcn_ className="flex flex-col space-y-2 col-span-4 text-sm justify-center text-foreground-light">
Name
</FormLabel_Shadcn_>
<FormControl_Shadcn_ className="col-span-8">
<Input_Shadcn_ {...field} className="w-full" />
</FormControl_Shadcn_>
<FormMessage_Shadcn_ className="col-start-5 col-span-8" />
</FormItem_Shadcn_>
)}
/>
<div className="grid gap-2 mt-4 md:grid md:grid-cols-12">
<div className="col-span-4">
<FormLabel_Shadcn_ className="flex flex-col space-y-2 col-span-4 text-sm justify-center text-foreground-light">
Role privileges
</FormLabel_Shadcn_>
</div>
<div className="col-span-8 grid gap-4">
<SidePanel.Separator />
<div className="grid gap-4">
<p className="text-sm">These privileges cannot be granted via the Dashboard:</p>
</div>
</div>
</div>
</form>
</Form_Shadcn_>
</SidePanel>
)
}
@@ -0,0 +1,151 @@
import { type DatabaseRolesData, type PgRole } from 'data/database-roles/database-roles-query'
import type { JitDbAccessMembersData } from 'data/jit-db-access/jit-db-access-members-query'
import type { OrganizationMembersData } from 'data/organizations/organization-members-query'
import type { ProjectMembersData } from 'data/projects/project-members-query'
import type {
JitMemberOption,
JitRoleGrantDraft,
JitRoleOption,
JitUserRule,
JitUserRuleDraft,
} from './jitDbAccess.types'
import {
cloneGrants,
computeStatusFromGrants,
createEmptyGrant,
mergeRoleIds,
toUnixSeconds,
} from './jitDbAccess.utils'
function isAssignableJitRole(role: PgRole) {
return (
role.canLogin &&
!role.isSuperuser &&
!role.name.startsWith('pg_') &&
(!role.name.startsWith('supabase_') || role.name === 'supabase_read_only_user') &&
!['pgbouncer', 'authenticator'].includes(role.name)
)
}
function serializeAllowedNetworks(roleObj: {
allowed_networks?: {
allowed_cidrs?: Array<{ cidr: string }>
allowed_cidrs_v6?: Array<{ cidr: string }>
}
}) {
const cidrs = roleObj.allowed_networks?.allowed_cidrs?.map((item) => item.cidr) ?? []
const cidrsV6 = roleObj.allowed_networks?.allowed_cidrs_v6?.map((item) => item.cidr) ?? []
return [...cidrs, ...cidrsV6]
}
export function getAssignableJitRoleOptions(
databaseRoles?: DatabaseRolesData | null
): JitRoleOption[] {
return (
databaseRoles
?.filter(isAssignableJitRole)
.map((role) => ({ id: role.name, label: role.name }))
.sort((a, b) => a.label.localeCompare(b.label)) ?? []
)
}
export function getJitMemberOptions(
organizationMembers?: OrganizationMembersData | null,
projectMembers?: ProjectMembersData | null
): JitMemberOption[] {
const byId = new Map<string, JitMemberOption>()
for (const member of organizationMembers ?? []) {
const id = member.gotrue_id ?? member.primary_email
if (!id) continue
byId.set(id, {
id,
email: member.primary_email ?? id,
name: member.username || undefined,
})
}
for (const member of projectMembers ?? []) {
const id = member.user_id ?? member.primary_email
if (!id) continue
byId.set(id, {
id,
email: member.primary_email ?? byId.get(id)?.email ?? id,
name: member.username ?? byId.get(id)?.name,
})
}
return Array.from(byId.values()).sort((a, b) => a.email.localeCompare(b.email))
}
export function mapJitMembersToUserRules(
jitMembers: JitDbAccessMembersData | undefined,
projectMembers: ProjectMembersData | undefined,
roleOptions: JitRoleOption[]
): JitUserRule[] {
const memberMap = new Map((projectMembers ?? []).map((member) => [member.user_id, member]))
const baseRoleIds = roleOptions.map((role) => role.id)
return (jitMembers ?? []).map((item) => {
const mappedMember = memberMap.get(item.user_id)
const assignedRoles: JitRoleGrantDraft[] = (item.user_roles ?? []).map((roleObj) => {
const expiresAt = typeof roleObj.expires_at === 'number' ? roleObj.expires_at : undefined
const hasExpiry = typeof expiresAt === 'number'
const allowedNetworks = serializeAllowedNetworks(roleObj)
return {
...createEmptyGrant(roleObj.role),
roleId: roleObj.role,
enabled: true,
hasExpiry,
expiryMode: hasExpiry ? 'custom' : 'never',
expiry: hasExpiry ? new Date(expiresAt * 1000).toISOString() : '',
hasIpRestriction: allowedNetworks.length > 0,
ipRanges: allowedNetworks.join(', '),
}
})
const assignedByRoleId = new Map(assignedRoles.map((grant) => [grant.roleId, grant]))
const allRoleIds = mergeRoleIds(
baseRoleIds,
assignedRoles.map((grant) => grant.roleId)
)
const grants = allRoleIds.map((roleId) => ({
...createEmptyGrant(roleId),
...(assignedByRoleId.get(roleId) ?? {}),
roleId,
}))
const email = mappedMember?.primary_email ?? item.user_id
const name = mappedMember?.username ?? undefined
return {
id: item.user_id,
memberId: item.user_id,
email,
name,
grants: cloneGrants(grants),
status: computeStatusFromGrants(grants),
}
})
}
export function serializeDraftRolesForGrantMutation(draft: JitUserRuleDraft) {
return draft.grants
.filter((grant) => grant.enabled)
.map((grant) => {
const expires_at = grant.hasExpiry ? toUnixSeconds(grant.expiry) : undefined
// TODO(DEPR-366): IP restrictions are prototype-only in the UI for now and are not sent to
// the SEC-462 backend payload until API support is available.
void grant.hasIpRestriction
void grant.ipRanges
return {
role: grant.roleId,
...(typeof expires_at === 'number' ? { expires_at } : {}),
}
})
}
@@ -0,0 +1,12 @@
import type { JitExpiryMode } from './jitDbAccess.types'
export const JIT_EXPIRY_MODE_OPTIONS: Array<{ value: JitExpiryMode; label: string }> = [
{ value: '1h', label: '1 hour' },
{ value: '1d', label: '1 day' },
{ value: '7d', label: '7 days' },
{ value: '30d', label: '30 days' },
{ value: 'custom', label: 'Custom' },
{ value: 'never', label: 'Never' },
]
export const JIT_MAX_CUSTOM_EXPIRY_YEARS = 1
@@ -0,0 +1,50 @@
export type JitExpiryMode = '1h' | '1d' | '7d' | '30d' | 'custom' | 'never'
export type JitStatus = {
active: number
expired: number
activeIp: number
expiredIp: number
}
export type JitStatusBadge = {
label: string
variant: 'default' | 'success' | 'warning'
}
export type JitMemberOption = {
id: string
email: string
name?: string
}
export type JitRoleOption = {
id: string
label: string
}
export type JitRoleGrantDraft = {
roleId: string
enabled: boolean
expiryMode: JitExpiryMode
hasExpiry: boolean
expiry: string
hasIpRestriction: boolean
ipRanges: string
}
export type JitUserRuleDraft = {
memberId: string
grants: JitRoleGrantDraft[]
}
export type JitUserRule = {
id: string
memberId: string
email: string
name?: string
grants: JitRoleGrantDraft[]
status: JitStatus
}
export type SheetMode = 'add' | 'edit'
@@ -0,0 +1,99 @@
import dayjs from 'dayjs'
import { describe, expect, it } from 'vitest'
import { serializeDraftRolesForGrantMutation } from './jitDbAccess.adapters'
import type { JitUserRuleDraft } from './jitDbAccess.types'
import {
computeStatusFromGrants,
createEmptyGrant,
getRelativeDatetimeByMode,
} from './jitDbAccess.utils'
describe('jitDbAccess.utils', () => {
it('returns empty expiry string for never/custom-only fallback modes', () => {
expect(getRelativeDatetimeByMode('never')).toBe('')
expect(getRelativeDatetimeByMode('custom')).toBe('')
})
it('creates future datetimes for preset expiry modes', () => {
const inOneHour = dayjs(getRelativeDatetimeByMode('1h'))
expect(inOneHour.isValid()).toBe(true)
expect(inOneHour.isAfter(dayjs())).toBe(true)
})
it('computes active and expired status counts including IP counts', () => {
const activeGrant = {
...createEmptyGrant('role_active'),
enabled: true,
hasExpiry: true,
expiry: dayjs().add(1, 'day').toISOString(),
hasIpRestriction: true,
ipRanges: '192.0.2.0/24',
}
const expiredGrant = {
...createEmptyGrant('role_expired'),
enabled: true,
hasExpiry: true,
expiry: dayjs().subtract(1, 'day').toISOString(),
hasIpRestriction: true,
ipRanges: '203.0.113.0/24',
}
const perpetualGrant = {
...createEmptyGrant('role_never'),
enabled: true,
hasExpiry: false,
expiryMode: 'never' as const,
expiry: '',
}
expect(computeStatusFromGrants([activeGrant, expiredGrant, perpetualGrant])).toEqual({
active: 2,
expired: 1,
activeIp: 1,
expiredIp: 1,
})
})
})
describe('serializeDraftRolesForGrantMutation', () => {
it('serializes supported role fields and omits prototype-only IP fields', () => {
const expiry = '2026-06-01T12:00:00.000Z'
const draft: JitUserRuleDraft = {
memberId: 'user-1',
grants: [
{
...createEmptyGrant('postgres'),
enabled: true,
hasExpiry: true,
expiryMode: 'custom',
expiry,
hasIpRestriction: true,
ipRanges: '192.0.2.0/24',
},
{
...createEmptyGrant('supabase_read_only_user'),
enabled: true,
hasExpiry: false,
expiryMode: 'never',
expiry: '',
},
{
...createEmptyGrant('ignored_disabled'),
enabled: false,
},
],
}
expect(serializeDraftRolesForGrantMutation(draft)).toEqual([
{
role: 'postgres',
expires_at: dayjs(expiry).unix(),
},
{
role: 'supabase_read_only_user',
},
])
})
})
@@ -0,0 +1,137 @@
import dayjs from 'dayjs'
import type {
JitExpiryMode,
JitRoleGrantDraft,
JitStatus,
JitStatusBadge,
JitUserRule,
JitUserRuleDraft,
} from './jitDbAccess.types'
export function getRelativeDatetimeByMode(mode: JitExpiryMode) {
if (mode === '1h') return dayjs().add(1, 'hour').toISOString()
if (mode === '1d') return dayjs().add(1, 'day').toISOString()
if (mode === '7d') return dayjs().add(7, 'day').toISOString()
if (mode === '30d') return dayjs().add(30, 'day').toISOString()
return ''
}
export function inferExpiryMode(grant: Pick<JitRoleGrantDraft, 'hasExpiry'>): JitExpiryMode {
if (!grant.hasExpiry) return 'never'
return 'custom'
}
export function createEmptyGrant(roleId: string): JitRoleGrantDraft {
return {
roleId,
enabled: false,
expiryMode: '1h',
hasExpiry: true,
expiry: getRelativeDatetimeByMode('1h'),
hasIpRestriction: false,
ipRanges: '',
}
}
export function createEmptyGrants(roleIds: string[]) {
return roleIds.map((roleId) => createEmptyGrant(roleId))
}
export function cloneGrants(grants: JitRoleGrantDraft[]) {
return grants.map((grant) => ({ ...grant }))
}
export function createDraft(roleIds: string[]): JitUserRuleDraft {
return { memberId: '', grants: createEmptyGrants(roleIds) }
}
export function mergeRoleIds(baseRoleIds: string[], extraRoleIds: string[]) {
const seen = new Set<string>()
const merged: string[] = []
for (const roleId of [...baseRoleIds, ...extraRoleIds]) {
if (seen.has(roleId)) continue
seen.add(roleId)
merged.push(roleId)
}
return merged
}
export function draftFromRule(rule: JitUserRule, baseRoleIds: string[]): JitUserRuleDraft {
const byRoleId = new Map(rule.grants.map((grant) => [grant.roleId, grant]))
const mergedRoleIds = mergeRoleIds(
baseRoleIds,
rule.grants.map((grant) => grant.roleId)
)
return {
memberId: rule.memberId,
grants: mergedRoleIds.map((roleId) => {
const nextGrant = {
...createEmptyGrant(roleId),
...(byRoleId.get(roleId) ?? {}),
}
return {
...nextGrant,
expiryMode: inferExpiryMode(nextGrant),
}
}),
}
}
export function computeStatusFromGrants(grants: JitRoleGrantDraft[]): JitStatus {
const enabledGrants = grants.filter((grant) => grant.enabled)
let active = 0
let expired = 0
let activeIp = 0
let expiredIp = 0
enabledGrants.forEach((grant) => {
const hasIp = grant.hasIpRestriction && grant.ipRanges.trim().length > 0
if (!grant.hasExpiry || !grant.expiry) {
active += 1
if (hasIp) activeIp += 1
return
}
const isExpired = dayjs(grant.expiry).isValid() && dayjs(grant.expiry).isBefore(dayjs())
if (isExpired) {
expired += 1
if (hasIp) expiredIp += 1
return
}
active += 1
if (hasIp) activeIp += 1
})
return { active, expired, activeIp, expiredIp }
}
export function getJitStatusDisplay(status: JitStatus): { badges: JitStatusBadge[] } {
const { active, expired, activeIp } = status
const badges: JitStatusBadge[] = []
if (active > 0) {
const label = activeIp > 0 ? `${active} active · ${activeIp} IP` : `${active} active`
badges.push({ label, variant: 'success' })
}
if (expired > 0) {
badges.push({ label: `${expired} expired`, variant: 'default' })
}
return { badges }
}
export function toUnixSeconds(datetimeIso: string) {
const value = dayjs(datetimeIso)
if (!value.isValid()) return undefined
return value.unix()
}