From 06c1237c52083fbbc31f6a0d7d5dc60de05b767e Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Tue, 24 Feb 2026 14:34:11 +1100
Subject: [PATCH] JIT sheet
---
.../Settings/Database/JITAccess.tsx | 715 +++++++++++++++---
1 file changed, 602 insertions(+), 113 deletions(-)
diff --git a/apps/studio/components/interfaces/Settings/Database/JITAccess.tsx b/apps/studio/components/interfaces/Settings/Database/JITAccess.tsx
index a72c82bbe3a..9baa97ebf63 100644
--- a/apps/studio/components/interfaces/Settings/Database/JITAccess.tsx
+++ b/apps/studio/components/interfaces/Settings/Database/JITAccess.tsx
@@ -1,24 +1,33 @@
-import { EllipsisVertical, Eye, Trash2, UserPlus } from 'lucide-react'
-import Link from 'next/link'
-import { useState } from 'react'
-
import { useParams } from 'common'
+import { DatePicker } from 'components/ui/DatePicker'
import { DocsButton } from 'components/ui/DocsButton'
+import { InlineLink } from 'components/ui/InlineLink'
import { DOCS_URL } from 'lib/constants'
+import { Calendar, EllipsisVertical, Pencil, Plus, Trash2, UserPlus } from 'lucide-react'
+import Link from 'next/link'
+import { useMemo, useState } from 'react'
import {
Badge,
Button,
Card,
CardContent,
+ Checkbox_Shadcn_,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
+ Input_Shadcn_,
+ ScrollArea,
+ Select_Shadcn_,
+ SelectContent_Shadcn_,
+ SelectItem_Shadcn_,
+ SelectTrigger_Shadcn_,
+ SelectValue_Shadcn_,
Sheet,
SheetContent,
SheetDescription,
+ SheetFooter,
SheetHeader,
- SheetSection,
SheetTitle,
Switch,
Table,
@@ -38,74 +47,538 @@ import {
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns'
-import { InlineLink } from 'components/ui/InlineLink'
import { Admonition } from 'ui-patterns/admonition'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
-// Prototype: JIT state is an array of rules; display is derived from it
-type JitRole = { expired: boolean; ipRestricted: boolean; role: string }
+type JitStatus = { active: number; expired: number; ipRestricted?: boolean }
-function getJitStatusDisplay(roles: JitRole[]): {
+type JITMember = { id: string; email: string; name?: string }
+
+type JITRoleOption = {
+ id: string
label: string
- variant: 'default' | 'success' | 'warning'
-} {
- const activeCount = roles.filter((r) => !r.expired).length
- const expiredCount = roles.filter((r) => r.expired).length
- const activeIp = roles.some((r) => !r.expired && r.ipRestricted)
- const expiredIp = roles.some((r) => r.expired && r.ipRestricted)
-
- const activePart = activeCount ? `${activeCount} active${activeIp ? ' · IP' : ''}` : null
- const expiredPart = expiredCount ? `${expiredCount} expired${expiredIp ? ' · IP' : ''}` : null
- const label = [activePart, expiredPart].filter(Boolean).join(', ') || 'No sessions'
-
- if (activeCount > 0 && expiredCount === 0) return { label, variant: 'success' }
- if (activeCount === 0 && expiredCount > 0) return { label, variant: 'warning' }
- return { label, variant: 'default' }
+ description?: string
}
-type JITUser = { id: string; email: string; name?: string; roles: JitRole[] }
-const MOCK_USERS: JITUser[] = [
+type JITRoleGrantDraft = {
+ roleId: string
+ enabled: boolean
+ expiryMode: '1h' | '1d' | '7d' | '30d' | 'custom' | 'never'
+ hasExpiry: boolean
+ expiry: string
+ hasIpRestriction: boolean
+ ipRanges: string
+}
+
+type JITUserRuleDraft = {
+ memberId: string
+ grants: JITRoleGrantDraft[]
+}
+
+type JITUserRule = {
+ id: string
+ memberId: string
+ email: string
+ name?: string
+ grants: JITRoleGrantDraft[]
+ status: JitStatus
+}
+
+type SheetMode = 'add' | 'edit'
+
+const AVAILABLE_ROLES: JITRoleOption[] = [
{
- id: '1',
- email: 'alice@example.com',
- name: 'Alice',
- roles: [
- { expired: false, ipRestricted: false, role: 'fake_db_role' },
- { expired: true, ipRestricted: false, role: 'other_role' },
- ],
+ id: 'supabase_read_only_user',
+ label: 'supabase_read_only_user',
+ description: 'Read-only access to all schemas',
},
{
- id: '2',
- email: 'bob@example.com',
- roles: [{ expired: true, ipRestricted: false, role: 'fake_db_role' }],
+ id: 'postgres',
+ label: 'postgres',
+ description: 'Full database superuser access',
},
{
- id: '3',
- email: 'carol@example.com',
- name: 'Carol',
- roles: [{ expired: false, ipRestricted: true, role: 'fake_db_role' }],
+ id: 'custom_role_a',
+ label: 'custom_role_a',
},
{
- id: '4',
- email: 'dave@example.com',
- name: 'Dave',
- roles: [
- { expired: false, ipRestricted: true, role: 'fake_db_role' },
- { expired: true, ipRestricted: false, role: 'other_role' },
- ],
+ id: 'custom_role_b',
+ label: 'custom_role_b',
+ },
+ {
+ id: 'custom_role_c',
+ label: 'custom_role_c',
},
]
+
+const MOCK_MEMBERS: JITMember[] = [
+ { id: 'member-1', name: 'Alice', email: 'alice@example.com' },
+ { id: 'member-2', name: 'Bob', email: 'bob@example.com' },
+ { id: 'member-3', name: 'Carol', email: 'carol@example.com' },
+ { id: 'member-4', name: 'Dave', email: 'dave@example.com' },
+ { id: 'member-5', name: 'Eve', email: 'eve@example.com' },
+ { id: 'member-6', name: 'Frank', email: 'frank@example.com' },
+]
+
// Prototype: mock Postgres version
const isPostgresVersionOutdated = false
+function pad(value: number) {
+ return String(value).padStart(2, '0')
+}
+
+function toDatetimeLocalValue(date: Date) {
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
+ date.getHours()
+ )}:${pad(date.getMinutes())}`
+}
+
+function relativeDatetimeLocal(hoursDelta: number) {
+ const date = new Date()
+ date.setHours(date.getHours() + hoursDelta)
+ return toDatetimeLocalValue(date)
+}
+
+function getRelativeDatetimeByMode(mode: JITRoleGrantDraft['expiryMode']) {
+ if (mode === '1h') return relativeDatetimeLocal(1)
+ if (mode === '1d') return relativeDatetimeLocal(24)
+ if (mode === '7d') return relativeDatetimeLocal(24 * 7)
+ if (mode === '30d') return relativeDatetimeLocal(24 * 30)
+ return ''
+}
+
+function inferExpiryMode(grant: JITRoleGrantDraft): JITRoleGrantDraft['expiryMode'] {
+ if (!grant.hasExpiry) return 'never'
+ return 'custom'
+}
+
+function createEmptyGrant(roleId: string): JITRoleGrantDraft {
+ return {
+ roleId,
+ enabled: false,
+ expiryMode: '30d',
+ hasExpiry: false,
+ expiry: '',
+ hasIpRestriction: false,
+ ipRanges: '',
+ }
+}
+
+function createEmptyGrants(): JITRoleGrantDraft[] {
+ return AVAILABLE_ROLES.map((role) => createEmptyGrant(role.id))
+}
+
+function cloneGrants(grants: JITRoleGrantDraft[]) {
+ return grants.map((grant) => ({ ...grant }))
+}
+
+function createDraft(): JITUserRuleDraft {
+ return { memberId: '', grants: createEmptyGrants() }
+}
+
+function withGrantOverrides(
+ overrides: Array & { roleId: string }>
+): JITRoleGrantDraft[] {
+ const byRoleId = new Map(overrides.map((override) => [override.roleId, override]))
+
+ return AVAILABLE_ROLES.map((role) => ({
+ ...createEmptyGrant(role.id),
+ ...(byRoleId.get(role.id) ?? {}),
+ roleId: role.id,
+ }))
+}
+
+function computeStatusFromGrants(grants: JITRoleGrantDraft[]): JitStatus {
+ const enabled = grants.filter((grant) => grant.enabled)
+
+ let active = 0
+ let expired = 0
+
+ enabled.forEach((grant) => {
+ if (!grant.hasExpiry || !grant.expiry) {
+ active += 1
+ return
+ }
+
+ const expiryMs = new Date(grant.expiry).getTime()
+ if (Number.isNaN(expiryMs) || expiryMs >= Date.now()) active += 1
+ else expired += 1
+ })
+
+ const ipRestricted = enabled.some(
+ (grant) => grant.hasIpRestriction && grant.ipRanges.trim().length > 0
+ )
+
+ return { active, expired, ipRestricted }
+}
+
+function getJitStatusDisplay(status: JitStatus): {
+ label: string
+ variant: 'default' | 'success' | 'warning'
+} {
+ const { active, expired, ipRestricted } = status
+ const parts: string[] = []
+
+ if (active > 0) parts.push(expired > 0 ? `${active} active, ${expired} expired` : 'Active')
+ else if (expired > 0) parts.push('Expired')
+
+ if (ipRestricted) parts.push('IP restricted')
+
+ const label = parts.length ? parts.join(' · ') : 'No sessions'
+
+ if (active > 0 && expired === 0 && !ipRestricted) return { label, variant: 'success' }
+ if (active === 0 && expired > 0) return { label, variant: 'warning' }
+ return { label, variant: 'default' }
+}
+
+function createRuleFromMember(
+ id: string,
+ memberId: string,
+ grants: JITRoleGrantDraft[],
+ members: JITMember[]
+): JITUserRule {
+ const member = members.find((m) => m.id === memberId)
+ const nextGrants = cloneGrants(grants)
+
+ return {
+ id,
+ memberId,
+ email: member?.email ?? 'unknown@example.com',
+ name: member?.name,
+ grants: nextGrants,
+ status: computeStatusFromGrants(nextGrants),
+ }
+}
+
+function draftFromRule(rule: JITUserRule): JITUserRuleDraft {
+ const byRoleId = new Map(rule.grants.map((grant) => [grant.roleId, grant]))
+ return {
+ memberId: rule.memberId,
+ grants: AVAILABLE_ROLES.map((role) => {
+ const nextGrant = {
+ ...createEmptyGrant(role.id),
+ ...(byRoleId.get(role.id) ?? {}),
+ }
+ return {
+ ...nextGrant,
+ expiryMode: inferExpiryMode(nextGrant),
+ }
+ }),
+ }
+}
+
+const MOCK_USERS: JITUserRule[] = [
+ createRuleFromMember(
+ 'rule-1',
+ 'member-1',
+ withGrantOverrides([
+ { roleId: 'supabase_read_only_user', enabled: true, expiryMode: '30d' },
+ {
+ roleId: 'postgres',
+ enabled: true,
+ hasExpiry: true,
+ expiryMode: 'custom',
+ expiry: relativeDatetimeLocal(-12),
+ },
+ ]),
+ MOCK_MEMBERS
+ ),
+ createRuleFromMember(
+ 'rule-2',
+ 'member-2',
+ withGrantOverrides([
+ {
+ roleId: 'supabase_read_only_user',
+ enabled: true,
+ hasExpiry: true,
+ expiryMode: 'custom',
+ expiry: relativeDatetimeLocal(-4),
+ },
+ ]),
+ MOCK_MEMBERS
+ ),
+ createRuleFromMember(
+ 'rule-3',
+ 'member-3',
+ withGrantOverrides([
+ {
+ roleId: 'postgres',
+ enabled: true,
+ expiryMode: '30d',
+ hasIpRestriction: true,
+ ipRanges: '203.0.113.0/24',
+ },
+ ]),
+ MOCK_MEMBERS
+ ),
+ createRuleFromMember(
+ 'rule-4',
+ 'member-4',
+ withGrantOverrides([
+ {
+ roleId: 'custom_role_b',
+ enabled: true,
+ expiryMode: '30d',
+ hasIpRestriction: true,
+ ipRanges: '198.51.100.0/24',
+ },
+ {
+ roleId: 'postgres',
+ enabled: true,
+ hasExpiry: true,
+ expiryMode: 'custom',
+ expiry: relativeDatetimeLocal(6),
+ },
+ ]),
+ MOCK_MEMBERS
+ ),
+]
+
+function RoleRuleEditor({
+ role,
+ grant,
+ onChange,
+}: {
+ role: JITRoleOption
+ grant: JITRoleGrantDraft
+ onChange: (next: JITRoleGrantDraft) => void
+}) {
+ const isSuperuserRole = role.id === 'postgres'
+ const showIpEditor = grant.hasIpRestriction || grant.ipRanges.trim().length > 0
+
+ return (
+
+
+
onChange({ ...grant, enabled: value === true })}
+ aria-label={`Enable ${role.label}`}
+ className="mt-0.5"
+ />
+
+
+ {role.label}
+
+ {role.description && (
+
+ {role.description}
+
+ )}
+
+
+
+ {grant.enabled && (
+
+ {isSuperuserRole && (
+
+
+ Superuser access grants full database control
+
+
+ postgres has full administrative access
+ and bypasses row-level security. Even temporary access can modify or delete any
+ data. Consider using a custom Postgres role with only the permissions required.
+
+
+
+
+ Learn more
+
+
+
+
+ )}
+
+
+
Expires in
+
+
+ {
+ 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 || relativeDatetimeLocal(24),
+ })
+ return
+ }
+
+ onChange({
+ ...grant,
+ expiryMode: nextMode,
+ hasExpiry: true,
+ expiry: getRelativeDatetimeByMode(nextMode),
+ })
+ }}
+ >
+
+
+
+
+ 1 hour
+ 1 day
+ 7 days
+ 30 days
+ Custom
+ No expiry
+
+
+
+
+ {grant.expiryMode === 'custom' && (
+
{
+ const selectedDate = date.to || date.from
+ onChange({
+ ...grant,
+ hasExpiry: true,
+ expiry: selectedDate ? toDatetimeLocalValue(new Date(selectedDate)) : '',
+ })
+ }}
+ >
+
+
+ Select date
+
+
+ )}
+
+
+
+
+
+ Restricted IP addresses{' '}
+ (optional)
+
+
+ {showIpEditor ? (
+
+
+ onChange({
+ ...grant,
+ hasIpRestriction: event.target.value.trim().length > 0,
+ ipRanges: event.target.value,
+ })
+ }
+ placeholder="e.g 192.168.0.0/24"
+ />
+ }
+ onClick={() =>
+ onChange({
+ ...grant,
+ hasIpRestriction: true,
+ ipRanges:
+ grant.ipRanges.trim().length === 0 || grant.ipRanges.trim().endsWith(',')
+ ? grant.ipRanges
+ : `${grant.ipRanges}, `,
+ })
+ }
+ >
+ Add another
+
+
+ ) : (
+
+ }
+ onClick={() => onChange({ ...grant, hasIpRestriction: true })}
+ >
+ Add IP range
+
+
+ )}
+
+
+ )}
+
+ )
+}
export const JITAccess = () => {
const { ref: projectRef } = useParams()
+
const [enabled, setEnabled] = useState(false)
- const [selectedUser, setSelectedUser] = useState(null)
- // const [users, setUsers] = useState([])
- const [users, setUsers] = useState(MOCK_USERS)
- const selectedStatusDisplay = selectedUser ? getJitStatusDisplay(selectedUser.roles) : null
+ const [users, setUsers] = useState(MOCK_USERS)
+ const [sheetOpen, setSheetOpen] = useState(false)
+ const [sheetMode, setSheetMode] = useState('add')
+ const [editingUserId, setEditingUserId] = useState(null)
+ const [draft, setDraft] = useState(() => createDraft())
+
+ const enabledRoleCount = useMemo(
+ () => draft.grants.filter((grant) => grant.enabled).length,
+ [draft.grants]
+ )
+ const canSave = Boolean(draft.memberId) && enabledRoleCount > 0
+
+ const closeSheet = () => {
+ setSheetOpen(false)
+ }
+
+ const openAddUserSheet = () => {
+ setSheetMode('add')
+ setEditingUserId(null)
+ setDraft(createDraft())
+ setSheetOpen(true)
+ }
+
+ const openEditUserSheet = (user: JITUserRule) => {
+ setSheetMode('edit')
+ setEditingUserId(user.id)
+ setDraft(draftFromRule(user))
+ setSheetOpen(true)
+ }
+
+ const updateGrant = (
+ roleId: string,
+ updater: (grant: JITRoleGrantDraft) => JITRoleGrantDraft
+ ) => {
+ setDraft((prev) => ({
+ ...prev,
+ grants: prev.grants.map((grant) => (grant.roleId === roleId ? updater(grant) : grant)),
+ }))
+ }
+
+ const handleSave = () => {
+ if (!canSave) return
+
+ const ruleId = sheetMode === 'edit' && editingUserId ? editingUserId : `rule-${Date.now()}`
+ const nextRule = createRuleFromMember(ruleId, draft.memberId, draft.grants, MOCK_MEMBERS)
+
+ setUsers((prev) => {
+ if (sheetMode === 'edit' && editingUserId) {
+ return prev.map((user) => (user.id === editingUserId ? nextRule : user))
+ }
+ return [...prev, nextRule]
+ })
+
+ closeSheet()
+ }
return (
@@ -123,7 +596,6 @@ export const JITAccess = () => {
label="JIT access"
description="Allow time-limited database access to specific project members."
>
- {/* Swich and tooltip */}
@@ -137,9 +609,7 @@ export const JITAccess = () => {
{isPostgresVersionOutdated && (
-
- Upgrade Postgres to use JIT
-
+ Upgrade Postgres to use JIT
)}
@@ -174,7 +644,7 @@ export const JITAccess = () => {
Define which members can receive temporary database access.
- }>
+ } onClick={openAddUserSheet}>
Add user
@@ -190,11 +660,9 @@ export const JITAccess = () => {
-
- {/* Empty state */}
{users.length === 0 ? (
-
+
No users yet
Add a user above to allow JIT access.
@@ -203,32 +671,32 @@ export const JITAccess = () => {
) : (
users.map((user) => {
- const statusDisplay = getJitStatusDisplay(user.roles)
+ const statusDisplay = getJitStatusDisplay(user.status)
+ const enabledGrants = user.grants.filter((grant) => grant.enabled)
+
return (
{
if ((event.target as HTMLElement).closest('button')) return
- setSelectedUser(user)
+ openEditUserSheet(user)
}}
onKeyDown={(event) => {
if ((event.target as HTMLElement).closest('button')) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
- setSelectedUser(user)
+ openEditUserSheet(user)
}
}}
tabIndex={0}
>
- {user.name && {user.name}
}
-
- {user.email}
-
+ {user.name && {user.name}
}
+ {user.email}
- {user.roles.length} role{user.roles.length > 1 ? 's' : ''}
+ {enabledGrants.length} role{enabledGrants.length === 1 ? '' : 's'}
{statusDisplay.label}
@@ -245,15 +713,14 @@ export const JITAccess = () => {
/>
-
setSelectedUser(user)}
+ onClick={() => openEditUserSheet(user)}
>
-
- Inspect
+
+ Edit
- { }}>
+ {}}>
Delete
@@ -266,60 +733,82 @@ export const JITAccess = () => {
)}
-
)}
- !open && setSelectedUser(null)}>
-
+ !open && closeSheet()}>
+
- {selectedUser?.name ?? selectedUser?.email ?? 'User details'}
+ {sheetMode === 'edit' ? 'Edit JIT access' : 'Grant JIT access'}
-
- View JIT access details for this user.
+
+ Configure user role grants and restrictions for JIT database access.
-
-
-
- {selectedUser && (
- <>
- {selectedUser.name && (
-
-
Name
-
{selectedUser.name}
+
+
+
+
+ setDraft((prev) => ({ ...prev, memberId: value }))}
+ >
+
+
+
+
+ {MOCK_MEMBERS.map((member) => (
+
+ {member.name ? `${member.name} (${member.email})` : member.email}
+
+ ))}
+
+
+
+
+
+
+ {AVAILABLE_ROLES.map((role, index) => {
+ const grant =
+ draft.grants.find((draftGrant) => draftGrant.roleId === role.id) ??
+ createEmptyGrant(role.id)
+
+ return (
+
0 ? 'border-t' : ''}>
+ updateGrant(role.id, () => next)}
+ />
- )}
-
- Email
- {selectedUser.email}
-
-
- Roles
-
- {selectedUser.roles.length} role{selectedUser.roles.length > 1 ? 's' : ''}
-
-
-
- Status
-
- {selectedStatusDisplay && (
-
- {selectedStatusDisplay.label}
-
- )}
-
-
- >
- )}
-
-
-
+ )
+ })}
+
+
+
+ Use custom{' '}
+
+ Postgres roles
+ {' '}
+ to enforce least-privilege access when enabling JIT.
+
+
+
+
+
+
+
+ Cancel
+
+
+ {sheetMode === 'edit' ? 'Update access' : 'Grant access'}
+
+
-
+
)
}