chore(studio): JIT access UI improvements (#44161)

## What kind of change does this PR introduce?

UI and copywriting improvements for temporary access.

## What is the current behavior?

The temporary access UI still used older JIT/ephemeral naming in some
places, did not clearly explain the setup requirements, and had to infer
unavailable states from Platform error message text.

## What is the new behavior?

The settings UI now uses temporary access naming consistently, explains
that temporary access uses short-lived tokens for manual database
connections, and renders clearer unavailable states for projects that
require either a Postgres upgrade or a platform migration.

The Studio query now consumes Platform’s structured `unavailableReason`
contract instead of parsing human-readable error strings, so the UI owns
the copy while Platform owns the eligibility reason.

Validation:

- `pnpm eslint
components/interfaces/Settings/Database/JitDatabaseAccess/JitDbAccessConfiguration.tsx
data/jit-db-access/jit-db-access-query.ts`
- `pnpm tsc --noEmit --pretty false`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* IP range input now supports one CIDR range per row with add/remove
rows and form integration.

* **Documentation**
* Replaced “JIT” wording with “Temporary” / “Ephemeral token-based”
access across UI, dialogs, toasts, and help links.
  * Added minimum PostgreSQL version requirement (17.6.1.081+).

* **Improvements**
  * Per-row CIDR validation with precise nested error messages.
* Refined layout spacing and moved the temporary-access configuration
earlier in Database settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Etienne Stalmans <etienne@supabase.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny White
2026-04-28 17:26:59 +10:00
committed by GitHub
parent 939b4034d9
commit bedb2efb87
14 changed files with 221 additions and 163 deletions

View File

@@ -6,18 +6,21 @@ export const JitDbAccessPreview = () => {
const { ref = '_' } = useParams()
return (
<div>
<p className="text-sm text-foreground-light mb-4">
Grant project members temporary database role access through Just-in-Time (JIT) controls in{' '}
<InlineLink href={`/project/${ref}/database/settings`}>Database Settings</InlineLink>.
<div className="space-y-4">
<p className="text-sm text-foreground-light">
Grant project members temporary database role access through short-lived tokens, controlled
in <InlineLink href={`/project/${ref}/database/settings`}>Database Settings</InlineLink>.
</p>
<div className="space-y-2 !mt-4">
<div className="space-y-2">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>Show JIT database access controls in Database Settings</li>
<li>Allow configuring role grants and member-level JIT rules</li>
<li>Show temporary access controls in Database Settings</li>
<li>Allow configuring role grants and member-level temporary access rules</li>
</ul>
</div>
<p className="text-sm text-foreground-light">
The minimum Postgres version needed for this feature is 17.6.1.081 (or higher).
</p>
</div>
)
}

View File

@@ -1,12 +0,0 @@
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

View File

@@ -12,6 +12,11 @@ export type JitStatusBadge = {
variant: 'default' | 'success' | 'warning'
}
export type JitDbAccessUnavailableReason =
| 'postgres_upgrade_required'
| 'manual_migration_required'
| 'temporarily_unavailable'
export type JitMemberOption = {
id: string
email: string
@@ -23,14 +28,17 @@ export type JitRoleOption = {
label: string
}
export type JitIpRangeDraft = {
value: string
}
export type JitRoleGrantDraft = {
roleId: string
enabled: boolean
expiryMode: JitExpiryMode
hasExpiry: boolean
expiry: string
hasIpRestriction: boolean
ipRanges: string
ipRanges: JitIpRangeDraft[]
}
export type JitUserRuleDraft = {

View File

@@ -5,10 +5,9 @@ import type { JitUserRuleDraft } from './JitDbAccess.types'
import {
computeStatusFromGrants,
createEmptyGrant,
getInvalidCidrs,
getInvalidIpRangeRows,
getJitMemberOptions,
getRelativeDatetimeByMode,
parseCommaSeparatedCidrs,
serializeDraftRolesForGrantMutation,
} from './JitDbAccess.utils'
import type { OrganizationMembersData } from '@/data/organizations/organization-members-query'
@@ -31,8 +30,7 @@ describe('jitDbAccess.utils', () => {
enabled: true,
hasExpiry: true,
expiry: dayjs().add(1, 'day').toISOString(),
hasIpRestriction: true,
ipRanges: '192.0.2.0/24',
ipRanges: [{ value: '192.0.2.0/24' }],
}
const expiredGrant = {
@@ -40,8 +38,7 @@ describe('jitDbAccess.utils', () => {
enabled: true,
hasExpiry: true,
expiry: dayjs().subtract(1, 'day').toISOString(),
hasIpRestriction: true,
ipRanges: '203.0.113.0/24',
ipRanges: [{ value: '203.0.113.0/24' }],
}
const perpetualGrant = {
@@ -60,17 +57,15 @@ describe('jitDbAccess.utils', () => {
})
})
it('parses comma-separated CIDR lists and trims whitespace', () => {
expect(parseCommaSeparatedCidrs('192.0.2.0/24, 2001:db8::/64 , ,203.0.113.4/32')).toEqual([
'192.0.2.0/24',
'2001:db8::/64',
'203.0.113.4/32',
])
})
it('returns invalid CIDRs from comma-separated input', () => {
it('returns invalid CIDRs from repeated input rows', () => {
expect(
getInvalidCidrs('192.0.2.0/24, not-a-cidr, 10.0.0.1/33, 2001:db8::/64, 2001:db8::/129')
getInvalidIpRangeRows([
{ value: '192.0.2.0/24' },
{ value: 'not-a-cidr' },
{ value: '10.0.0.1/33' },
{ value: '2001:db8::/64' },
{ value: '2001:db8::/129' },
])
).toEqual(['not-a-cidr', '10.0.0.1/33', '2001:db8::/129'])
})
})
@@ -87,8 +82,7 @@ describe('serializeDraftRolesForGrantMutation', () => {
hasExpiry: true,
expiryMode: 'custom',
expiry,
hasIpRestriction: true,
ipRanges: '192.0.2.0/24, 2001:db8::/64',
ipRanges: [{ value: '192.0.2.0/24' }, { value: ' ' }, { value: '2001:db8::/64' }],
},
{
...createEmptyGrant('supabase_read_only_user'),

View File

@@ -3,6 +3,7 @@ import { IPv4CidrRange, IPv6CidrRange } from 'ip-num'
import type {
JitExpiryMode,
JitIpRangeDraft,
JitMemberOption,
JitRoleGrantDraft,
JitRoleOption,
@@ -36,13 +37,24 @@ export function createEmptyGrant(roleId: string): JitRoleGrantDraft {
expiryMode: '1h',
hasExpiry: true,
expiry: getRelativeDatetimeByMode('1h'),
hasIpRestriction: false,
ipRanges: '',
ipRanges: [createEmptyIpRange()],
}
}
export function createEmptyIpRange(): JitIpRangeDraft {
return { value: '' }
}
function parseIpRangeRows(value: JitIpRangeDraft[]) {
return value.map((item) => item.value.trim()).filter((item) => item.length > 0)
}
function cloneIpRanges(ipRanges: JitIpRangeDraft[]) {
return ipRanges.map((ipRange) => ({ ...ipRange }))
}
function cloneGrants(grants: JitRoleGrantDraft[]) {
return grants.map((grant) => ({ ...grant }))
return grants.map((grant) => ({ ...grant, ipRanges: cloneIpRanges(grant.ipRanges) }))
}
export function createDraft(roleIds: string[]): JitUserRuleDraft {
@@ -80,6 +92,7 @@ export function draftFromRule(rule: JitUserRule, baseRoleIds: string[]): JitUser
return {
...nextGrant,
expiryMode: inferExpiryMode(nextGrant),
ipRanges: cloneIpRanges(nextGrant.ipRanges),
}
}),
}
@@ -94,7 +107,7 @@ export function computeStatusFromGrants(grants: JitRoleGrantDraft[]): JitStatus
let expiredIp = 0
enabledGrants.forEach((grant) => {
const hasIp = grant.hasIpRestriction && grant.ipRanges.trim().length > 0
const hasIp = parseIpRangeRows(grant.ipRanges).length > 0
if (!grant.hasExpiry || !grant.expiry) {
active += 1
@@ -151,13 +164,6 @@ function toUnixSeconds(datetimeIso: string) {
return value.unix()
}
export function parseCommaSeparatedCidrs(value: string) {
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
function isValidCidr(value: string) {
try {
if (value.includes(':')) {
@@ -172,8 +178,8 @@ function isValidCidr(value: string) {
}
}
export function getInvalidCidrs(value: string) {
return parseCommaSeparatedCidrs(value).filter((cidr) => !isValidCidr(cidr))
export function getInvalidIpRangeRows(value: JitIpRangeDraft[]) {
return parseIpRangeRows(value).filter((cidr) => !isValidCidr(cidr))
}
function isAssignableJitRole(role: PgRole) {
@@ -264,8 +270,10 @@ export function mapJitMembersToUserRules(
hasExpiry,
expiryMode: hasExpiry ? 'custom' : 'never',
expiry: hasExpiry ? new Date(expiresAt * 1000).toISOString() : '',
hasIpRestriction: allowedNetworks.length > 0,
ipRanges: allowedNetworks.join(', '),
ipRanges:
allowedNetworks.length > 0
? allowedNetworks.map((cidr) => ({ value: cidr }))
: [createEmptyIpRange()],
}
})
@@ -295,15 +303,13 @@ export function mapJitMembersToUserRules(
}
export function serializeDraftRolesForGrantMutation(draft: JitUserRuleDraft) {
const serializeAllowedNetworks = (value: string) => {
const cidrs = parseCommaSeparatedCidrs(value)
const serializeAllowedNetworks = (value: JitIpRangeDraft[]) => {
const cidrs = parseIpRangeRows(value)
if (cidrs.length === 0) return undefined
const allowed_cidrs = cidrs.filter((cidr) => !cidr.includes(':')).map((cidr) => ({ cidr }))
const allowed_cidrs_v6 = cidrs.filter((cidr) => cidr.includes(':')).map((cidr) => ({ cidr }))
if (allowed_cidrs.length === 0 && allowed_cidrs_v6.length === 0) return undefined
return {
...(allowed_cidrs.length > 0 ? { allowed_cidrs } : {}),
...(allowed_cidrs_v6.length > 0 ? { allowed_cidrs_v6 } : {}),

View File

@@ -107,18 +107,18 @@ export const JitDbAccessConfiguration = () => {
const nextEnabled = variables.requestedConfig.state === 'enabled'
if (nextEnabled) {
toast.success('JIT access enabled')
toast.success('Temporary 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'
? `Temporary access disabled. ${activeRuleCount} configured member${activeRuleCount === 1 ? '' : 's'} can no longer request temporary database access.`
: 'Temporary access disabled'
)
}
},
onError: (error) => {
setEnabled(initialIsEnabled ?? false)
toast.error(`Failed to update just-in-time (JIT) database access: ${error.message}`)
toast.error(`Failed to update temporary access: ${error.message}`)
},
})
@@ -138,18 +138,13 @@ export const JitDbAccessConfiguration = () => {
const isRulesLoading = isLoadingJitMembers || isLoadingProjectMembers
const initialIsEnabled =
isSuccessConfiguration &&
!!jitDbAccessConfiguration &&
'appliedSuccessfully' in jitDbAccessConfiguration &&
jitDbAccessConfiguration.appliedSuccessfully &&
'state' in jitDbAccessConfiguration &&
(jitDbAccessConfiguration as { state: string }).state === 'enabled'
const hasAccessToJitDbAccess = !(
jitDbAccessConfiguration !== undefined &&
'isUnavailable' in jitDbAccessConfiguration &&
jitDbAccessConfiguration.isUnavailable
)
jitDbAccessConfiguration?.state === 'enabled'
? jitDbAccessConfiguration?.appliedSuccessfully
: false
const isJitDbAccessUnavailable = jitDbAccessConfiguration?.state === 'unavailable'
const unavailableReason = isJitDbAccessUnavailable
? jitDbAccessConfiguration.unavailableReason
: undefined
const roleOptions = useMemo(() => getAssignableJitRoleOptions(databaseRoles), [databaseRoles])
@@ -214,7 +209,7 @@ export const JitDbAccessConfiguration = () => {
}
const handleJitToggleChange = (checked: boolean) => {
if (!hasAccessToJitDbAccess || !canUpdateJitDbAccess) return
if (isJitDbAccessUnavailable || !canUpdateJitDbAccess) return
if (checked && !enabled) {
if (activeRuleCount > 0) {
@@ -265,6 +260,26 @@ export const JitDbAccessConfiguration = () => {
'appliedSuccessfully' in jitDbAccessConfiguration &&
!jitDbAccessConfiguration.appliedSuccessfully
const projectReference = ref ? (
<>
This project <code className="text-code-inline">{ref}</code>
</>
) : (
'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)
@@ -276,43 +291,64 @@ export const JitDbAccessConfiguration = () => {
<PageSection id="jit-db-access-configuration">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Just-in-Time (JIT)</PageSectionTitle>
<PageSectionTitle>Temporary access</PageSectionTitle>
</PageSectionSummary>
<DocsButton href={`${DOCS_URL}/guides/platform/just-in-time-database-access`} />
<DocsButton href={`${DOCS_URL}/guides/platform/temporary-access`} />
</PageSectionMeta>
<PageSectionContent className="space-y-4">
{isErrorJitDbAccessConfiguration && (
<AlertError
projectRef={ref}
subject="Failed to retrieve JIT database access configuration"
subject="Failed to load temporary access"
error={jitDbAccessConfigurationError as { message: string } | null}
showInstructions={false}
/>
)}
{!isErrorJitDbAccessConfiguration && !hasAccessToJitDbAccess && (
{!isErrorJitDbAccessConfiguration && isJitDbAccessUnavailable && (
<Admonition
type="note"
layout="responsive"
title="Postgres upgrade required"
description="Just-in-time access requires a newer Postgres version. Upgrade your projects Postgres version to enable JIT access."
title={unavailableTitle}
description={
unavailableReason === 'temporarily_unavailable' ? (
unavailableDescription
) : (
<>
{projectReference} {unavailableDescription}
</>
)
}
actions={
ref ? (
unavailableReason === 'postgres_upgrade_required' && ref ? (
<Button type="default" asChild>
<Link href={`/project/${ref}/settings/infrastructure`}>Upgrade Postgres</Link>
</Button>
) : undefined
) : (
<Button type="default" asChild>
<SupportLink
queryParams={{
category: SupportCategories.PROBLEM,
projectRef: ref,
subject: unavailableTitle,
}}
>
Contact support
</SupportLink>
</Button>
)
}
/>
)}
{!isErrorJitDbAccessConfiguration && hasAccessToJitDbAccess && (
{!isErrorJitDbAccessConfiguration && !isJitDbAccessUnavailable && (
<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."
label="Allow temporary access"
description="Let project members request temporary database access."
>
<div className="flex w-fit flex-shrink-0 items-center justify-end gap-2">
{(isLoadingConfiguration || isUpdatingJitDbAccess) && (
@@ -345,14 +381,14 @@ export const JitDbAccessConfiguration = () => {
<Admonition
type="warning"
layout="horizontal"
title="JIT access update failed"
title="Temporary access update didnt apply"
description={
<>
The change didnt apply. Try turning JIT access on or off again, or{' '}
The change didnt apply. Try enabling or disabling temporary access again, or{' '}
<SupportLink
queryParams={{
category: SupportCategories.DASHBOARD_BUG,
subject: 'JIT access was not updated successfully',
subject: 'Temporary access was not updated successfully',
}}
className={InlineLinkClassName}
>
@@ -367,13 +403,14 @@ export const JitDbAccessConfiguration = () => {
</Card>
)}
{enabled && hasAccessToJitDbAccess && !isUpdatingJitDbAccess && (
{enabled && !isJitDbAccessUnavailable && !isUpdatingJitDbAccess && (
<>
{isErrorJitMembers && (
<AlertError
projectRef={ref}
subject="Failed to retrieve JIT access rules"
subject="Failed to load temporary access rules"
error={jitMembersError as { message: string } | null}
showInstructions={false}
/>
)}
@@ -408,11 +445,11 @@ export const JitDbAccessConfiguration = () => {
<AlertDialog open={showEnableJitDialog} onOpenChange={setShowEnableJitDialog}>
<AlertDialogContent size="small">
<AlertDialogHeader>
<AlertDialogTitle>JIT access will activate existing rules</AlertDialogTitle>
<AlertDialogTitle>This will activate existing rules</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-sm">
<p>
Enabling JIT will allow {activeRuleCount} configured member
Enabling temporary access will allow {activeRuleCount} pre-configured member
{activeRuleCount === 1 ? '' : 's'} to request temporary database access
immediately.
</p>
@@ -426,7 +463,7 @@ export const JitDbAccessConfiguration = () => {
disabled={isUpdatingJitDbAccess}
onClick={handleConfirmEnableJit}
>
Enable JIT access
Enable temporary access
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -31,15 +31,15 @@ export function JitDbAccessDeleteDialog({
<AlertDialog open={!!user} onOpenChange={(open) => !open && !isDeleting && onClose()}>
<AlertDialogContent size="medium">
<AlertDialogHeader>
<AlertDialogTitle>Delete JIT access rule</AlertDialogTitle>
<AlertDialogTitle>Delete temporary access rule</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2 text-sm">
<p>
Remove the JIT access rule for{' '}
Remove the temporary access rule for{' '}
<strong className="text-foreground">{userDisplayName}</strong>?
</p>
<p>
This revokes any assigned database roles for this member and removes their JIT
This revokes any assigned database roles for this member and removes their temporary
access configuration.
</p>
</div>

View File

@@ -1,7 +1,8 @@
import dayjs from 'dayjs'
import type { Control } from 'react-hook-form'
import {
Checkbox,
Input_Shadcn_,
cn,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
@@ -11,27 +12,43 @@ import {
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { Admonition } from 'ui-patterns/admonition'
import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray'
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'
import type { JitRoleGrantDraft, JitRoleOption, JitUserRuleDraft } from './JitDbAccess.types'
import { createEmptyIpRange, getRelativeDatetimeByMode } from './JitDbAccess.utils'
import { DatePicker } from '@/components/ui/DatePicker'
import { InlineLink } from '@/components/ui/InlineLink'
import { DOCS_URL } from '@/lib/constants'
const EXPIRY_MODE_OPTIONS: Array<{ value: JitRoleGrantDraft['expiryMode']; 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' },
]
const MAX_CUSTOM_EXPIRY_YEARS = 1
interface JitDbAccessRoleGrantFieldsProps {
control: Control<JitUserRuleDraft>
grantIndex: number
role: JitRoleOption
grant: JitRoleGrantDraft
onChange: (next: JitRoleGrantDraft) => void
}
export function JitDbAccessRoleGrantFields({
control,
grantIndex,
role,
grant,
onChange,
}: JitDbAccessRoleGrantFieldsProps) {
const isSuperuserRole = role.id === 'postgres'
const isReadOnlyRole = role.id === 'supabase_read_only_user'
const showRoleAdmonition = isSuperuserRole || isReadOnlyRole
const checkboxId = `jit-role-${role.id}`
return (
@@ -86,6 +103,7 @@ export function JitDbAccessRoleGrantFields({
type="warning"
showIcon={false}
layout="vertical"
className="rounded-md mb-2"
title="Grants full database control"
description={
<>
@@ -115,11 +133,11 @@ export function JitDbAccessRoleGrantFields({
with only the permissions required.
</>
}
className="rounded-md"
className="rounded-md mb-2"
/>
)}
<div className="space-y-2 border-t border-muted pt-3">
<div className={cn('space-y-2', !showRoleAdmonition && 'border-t border-muted pt-3')}>
<p className="text-sm text-foreground">Expires in</p>
<div className="flex gap-2">
<div className="flex-1">
@@ -158,7 +176,7 @@ export function JitDbAccessRoleGrantFields({
<SelectValue_Shadcn_ placeholder="Expires in" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{JIT_EXPIRY_MODE_OPTIONS.map((option) => (
{EXPIRY_MODE_OPTIONS.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
@@ -174,7 +192,7 @@ export function JitDbAccessRoleGrantFields({
contentSide="top"
to={grant.expiry || undefined}
minDate={new Date()}
maxDate={dayjs().add(JIT_MAX_CUSTOM_EXPIRY_YEARS, 'year').toDate()}
maxDate={dayjs().add(MAX_CUSTOM_EXPIRY_YEARS, 'year').toDate()}
onChange={(value) => {
const selectedDate = value.to || value.from || ''
onChange({
@@ -216,18 +234,19 @@ export function JitDbAccessRoleGrantFields({
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"
<SingleValueFieldArray
control={control}
name={`grants.${grantIndex}.ipRanges` as const}
valueFieldName="value"
createEmptyRow={createEmptyIpRange}
placeholder="192.168.0.0/24"
addLabel="Add IP restriction"
removeLabel="Remove IP restriction"
minimumRows={1}
inputAutoComplete="off"
rowsClassName="space-y-2"
addButtonClassName="w-min"
/>
<p className="text-xs text-foreground-lighter">Comma-separated CIDR ranges</p>
</div>
</div>
</div>

View File

@@ -36,7 +36,7 @@ import {
createDraft,
draftFromRule,
getAssignableJitRoleOptions,
getInvalidCidrs,
getInvalidIpRangeRows,
mapJitMembersToUserRules,
serializeDraftRolesForGrantMutation,
} from './JitDbAccess.utils'
@@ -57,14 +57,13 @@ const grantSchema = z.object({
expiryMode: z.custom<JitExpiryMode>(),
hasExpiry: z.boolean(),
expiry: z.string(),
hasIpRestriction: z.boolean(),
ipRanges: z.string(),
ipRanges: z.array(z.object({ value: z.string() })),
})
function createJitRuleSchema(mode: SheetMode, membersWithRules: Set<string>) {
return z
.object({
memberId: z.string().min(1, 'Select a member for this JIT access rule.'),
memberId: z.string().min(1, 'Select a member for this temporary access rule.'),
grants: z.array(grantSchema),
})
.superRefine((data, ctx) => {
@@ -73,12 +72,12 @@ function createJitRuleSchema(mode: SheetMode, membersWithRules: Set<string>) {
code: z.ZodIssueCode.custom,
path: ['memberId'],
message:
'This member already has a JIT access rule. Edit their existing rule from the list.',
'This member already has a temporary access rule. Edit their existing rule from the list.',
})
}
const enabledGrants = data.grants.filter((g) => g.enabled)
if (enabledGrants.length === 0) {
const enabledGrantCount = data.grants.filter((g) => g.enabled).length
if (enabledGrantCount === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['grants'],
@@ -87,19 +86,22 @@ function createJitRuleSchema(mode: SheetMode, membersWithRules: Set<string>) {
return
}
for (const grant of enabledGrants) {
const invalidCidrs = getInvalidCidrs(grant.ipRanges)
if (invalidCidrs.length > 0) {
const preview = invalidCidrs.slice(0, 3).join(', ')
const overflow = invalidCidrs.length > 3
data.grants.forEach((grant, grantIndex) => {
if (!grant.enabled) return
const invalidCidrs = new Set(getInvalidIpRangeRows(grant.ipRanges))
grant.ipRanges.forEach((ipRange, ipRangeIndex) => {
const value = ipRange.value.trim()
if (value.length === 0 || !invalidCidrs.has(value)) return
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['grants'],
message: `Invalid CIDR range${invalidCidrs.length > 1 ? 's' : ''} for role "${grant.roleId}": ${preview}${overflow ? ', ...' : ''}`,
path: ['grants', grantIndex, 'ipRanges', ipRangeIndex, 'value'],
message: 'Please enter a valid CIDR range',
})
break
}
}
})
})
})
}
@@ -225,10 +227,10 @@ export function JitDbAccessRuleSheet({
>
<SheetHeader>
<SheetTitle>
{mode === 'edit' ? 'Edit JIT access rule' : 'New JIT access rule'}
{mode === 'edit' ? 'Edit temporary access rule' : 'New temporary access rule'}
</SheetTitle>
<SheetDescription className="sr-only">
Configure which database roles a user can request with JIT access.
Configure which database roles a user can request with temporary access.
</SheetDescription>
</SheetHeader>
@@ -272,8 +274,8 @@ export function JitDbAccessRuleSheet({
{mode === 'add' && availableMembersForAddCount === 0 && (
<p className="mt-2 text-foreground-lighter">
All project members already have JIT access rules. Edit an existing rule
from the table above.
All project members already have temporary access rules. Edit an existing
rule from the table above.
</p>
)}
</FormItemLayout>
@@ -311,6 +313,8 @@ export function JitDbAccessRuleSheet({
{grants.map((grant, index) => (
<div key={grant.roleId} className={index > 0 ? 'border-t' : ''}>
<JitDbAccessRoleGrantFields
control={form.control}
grantIndex={index}
role={{ id: grant.roleId, label: grant.roleId }}
grant={grant}
onChange={(next) => updateGrant(grant.roleId, () => next)}

View File

@@ -49,7 +49,7 @@ export function JitDbAccessRulesTable({
const addRuleTooltip = !canUpdate
? 'Additional permissions required'
: allProjectMembersHaveRules
? 'All project members already have JIT access rules'
? 'All project members already have temporary access rules'
: undefined
if (isLoading) {
@@ -74,9 +74,9 @@ export function JitDbAccessRulesTable({
<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>
<h3 className="text-sm text-foreground">Temporary access rules</h3>
<p className="text-sm text-foreground-light">
Configure which members can request temporary database access.
Manage member access, allowed roles, and expiry settings.
</p>
</div>
@@ -107,9 +107,9 @@ export function JitDbAccessRulesTable({
{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">No rules yet</p>
<p className="text-sm text-foreground-lighter">
Add your first JIT access rule above
Add your first temporary access rule above
</p>
</TableCell>
</TableRow>

View File

@@ -17,23 +17,7 @@ async function getJitDbAccessConfiguration(
signal,
})
// jit access might not be available on the project due to
// postgres version
if (error) {
const responseError = error as ResponseError
const isNotAvailableError =
responseError.code === 400 && responseError.message?.includes('unavailable')
if (isNotAvailableError) {
return {
appliedSuccessfully: false,
state: 'unavailable' as string,
isUnavailable: true,
} as const
} else {
handleError(error)
}
}
if (error) handleError(error)
return data
}

View File

@@ -44,7 +44,7 @@ export const useJitDbAccessUpdateMutation = ({
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update just-in-time (JIT) database access: ${data.message}`)
toast.error(`Failed to update temporary access: ${data.message}`)
} else {
onError(data, variables, context)
}

View File

@@ -51,9 +51,9 @@ const DatabaseSettings: NextPageWithLayout = () => {
<PageContainer size="small" className="flex flex-col gap-8 pb-12">
<DatabaseReadOnlyAlert />
<ResetDbPassword />
{jitDbAccessEnabled && <JitDbAccessConfiguration />}
<ConnectionPooling />
<SSLConfiguration />
{jitDbAccessEnabled && <JitDbAccessConfiguration />}
{showNewDiskManagementUI ? (
// This form is hidden if Disk and Compute form is enabled, new form is on ./settings/compute-and-disk
<DiskManagementPanelForm />

View File

@@ -3144,6 +3144,21 @@ export interface components {
}[]
}[]
}
JitStateResponse:
| {
appliedSuccessfully?: boolean
/** @enum {string} */
state: 'enabled' | 'disabled'
}
| {
/** @enum {string} */
state: 'unavailable'
/** @enum {string} */
unavailableReason:
| 'manual_migration_required'
| 'postgres_upgrade_required'
| 'temporarily_unavailable'
}
LegacyApiKeysResponse: {
enabled: boolean
}
@@ -11197,7 +11212,7 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['JitAccessResponse']
'application/json': components['schemas']['JitStateResponse']
}
}
/** @description Unauthorized */
@@ -11251,7 +11266,7 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['JitAccessResponse']
'application/json': components['schemas']['JitStateResponse']
}
}
/** @description Unauthorized */