mirror of
https://github.com/supabase/supabase.git
synced 2026-06-04 11:51:55 +08:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 project’s 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 didn’t apply"
|
||||
description={
|
||||
<>
|
||||
The change didn’t apply. Try turning JIT access on or off again, or{' '}
|
||||
The change didn’t 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
19
packages/api-types/types/api.d.ts
vendored
19
packages/api-types/types/api.d.ts
vendored
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user