mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
JIT sheet
This commit is contained in:
1 parent
3f8aee5ec7
commit
06c1237c52
1 file changed
+602
-113
@@ -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<Partial<JITRoleGrantDraft> & { 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 (
|
||||
<div className={`px-4 py-3 ${grant.enabled ? 'bg-surface-100' : 'bg-background'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox_Shadcn_
|
||||
checked={grant.enabled}
|
||||
onCheckedChange={(value) => onChange({ ...grant, enabled: value === true })}
|
||||
aria-label={`Enable ${role.label}`}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="inline-flex items-center rounded border px-2 py-0.5 text-xs font-mono text-foreground-light bg-surface-200">
|
||||
{role.label}
|
||||
</div>
|
||||
{role.description && (
|
||||
<p className="text-xs text-foreground-lighter mt-1 leading-normal">
|
||||
{role.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grant.enabled && (
|
||||
<div className="space-y-4 pl-7 pt-3">
|
||||
{isSuperuserRole && (
|
||||
<div className="rounded-md border border-warning/40 bg-warning/10 px-4 py-4">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Superuser access grants full database control
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light mt-1 leading-relaxed">
|
||||
<code className="text-code-inline">postgres</code> 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.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button type="default" size="tiny" asChild>
|
||||
<Link href={`${DOCS_URL}/guides/platform/jit-access`} target="_blank">
|
||||
Learn more
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<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 || relativeDatetimeLocal(24),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onChange({
|
||||
...grant,
|
||||
expiryMode: nextMode,
|
||||
hasExpiry: true,
|
||||
expiry: getRelativeDatetimeByMode(nextMode),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Expires in" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="1h">1 hour</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="1d">1 day</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="7d">7 days</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="30d">30 days</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="custom">Custom</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="never">No expiry</SelectItem_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</div>
|
||||
|
||||
{grant.expiryMode === 'custom' && (
|
||||
<DatePicker
|
||||
selectsRange={false}
|
||||
triggerButtonSize="small"
|
||||
contentSide="top"
|
||||
minDate={new Date()}
|
||||
onChange={(date) => {
|
||||
const selectedDate = date.to || date.from
|
||||
onChange({
|
||||
...grant,
|
||||
hasExpiry: true,
|
||||
expiry: selectedDate ? toDatetimeLocalValue(new Date(selectedDate)) : '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Calendar size={14} />
|
||||
Select date
|
||||
</span>
|
||||
</DatePicker>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-foreground">
|
||||
Restricted IP addresses{' '}
|
||||
<span className="text-foreground-lighter font-normal">(optional)</span>
|
||||
</p>
|
||||
|
||||
{showIpEditor ? (
|
||||
<div className="flex gap-2">
|
||||
<Input_Shadcn_
|
||||
className="flex-1"
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...grant,
|
||||
hasIpRestriction: true,
|
||||
ipRanges:
|
||||
grant.ipRanges.trim().length === 0 || grant.ipRanges.trim().endsWith(',')
|
||||
? grant.ipRanges
|
||||
: `${grant.ipRanges}, `,
|
||||
})
|
||||
}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border bg-background/80 p-4 flex justify-center">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => onChange({ ...grant, hasIpRestriction: true })}
|
||||
>
|
||||
Add IP range
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const JITAccess = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<JITUser | null>(null)
|
||||
// const [users, setUsers] = useState<JITUser[]>([])
|
||||
const [users, setUsers] = useState<JITUser[]>(MOCK_USERS)
|
||||
const selectedStatusDisplay = selectedUser ? getJitStatusDisplay(selectedUser.roles) : null
|
||||
const [users, setUsers] = useState<JITUserRule[]>(MOCK_USERS)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<SheetMode>('add')
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState<JITUserRuleDraft>(() => 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 (
|
||||
<PageSection id="jit-access">
|
||||
@@ -123,7 +596,6 @@ export const JITAccess = () => {
|
||||
label="JIT access"
|
||||
description="Allow time-limited database access to specific project members."
|
||||
>
|
||||
{/* Swich and tooltip */}
|
||||
<div className="flex items-center justify-end w-fit flex-shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -137,9 +609,7 @@ export const JITAccess = () => {
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{isPostgresVersionOutdated && (
|
||||
<TooltipContent side="bottom">
|
||||
Upgrade Postgres to use JIT
|
||||
</TooltipContent>
|
||||
<TooltipContent side="bottom">Upgrade Postgres to use JIT</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -174,7 +644,7 @@ export const JITAccess = () => {
|
||||
Define which members can receive temporary database access.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="default" icon={<UserPlus size={14} />}>
|
||||
<Button type="default" icon={<UserPlus size={14} />} onClick={openAddUserSheet}>
|
||||
Add user
|
||||
</Button>
|
||||
</div>
|
||||
@@ -190,11 +660,9 @@ export const JITAccess = () => {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
{/* Empty state */}
|
||||
{users.length === 0 ? (
|
||||
<TableRow className="[&>td]:hover:bg-inherit">
|
||||
<TableCell colSpan={3}>
|
||||
<TableCell colSpan={4}>
|
||||
<p className="text-sm text-foreground">No users yet</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Add a user above to allow JIT access.
|
||||
@@ -203,32 +671,32 @@ export const JITAccess = () => {
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => {
|
||||
const statusDisplay = getJitStatusDisplay(user.roles)
|
||||
const statusDisplay = getJitStatusDisplay(user.status)
|
||||
const enabledGrants = user.grants.filter((grant) => grant.enabled)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="relative cursor-pointer inset-focus"
|
||||
onClick={(event) => {
|
||||
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}
|
||||
>
|
||||
<TableCell className="text-sm">
|
||||
{user.name && <p> {user.name} </p>}
|
||||
<p className="text-foreground-lighter">
|
||||
{user.email}
|
||||
</p>
|
||||
{user.name && <p>{user.name}</p>}
|
||||
<p className="text-foreground-lighter">{user.email}</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light text-sm">
|
||||
{user.roles.length} role{user.roles.length > 1 ? 's' : ''}
|
||||
{enabledGrants.length} role{enabledGrants.length === 1 ? '' : 's'}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light text-sm">
|
||||
<Badge variant={statusDisplay.variant}>{statusDisplay.label}</Badge>
|
||||
@@ -245,15 +713,14 @@ export const JITAccess = () => {
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-40">
|
||||
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2"
|
||||
onClick={() => setSelectedUser(user)}
|
||||
onClick={() => openEditUserSheet(user)}
|
||||
>
|
||||
<Eye size={14} className="text-foreground-lighter" />
|
||||
Inspect
|
||||
<Pencil size={14} className="text-foreground-lighter" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => { }}>
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => {}}>
|
||||
<Trash2 size={14} className="text-foreground-lighter" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -266,60 +733,82 @@ export const JITAccess = () => {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Sheet open={!!selectedUser} onOpenChange={(open) => !open && setSelectedUser(null)}>
|
||||
<SheetContent className="flex flex-col gap-0">
|
||||
<Sheet open={sheetOpen} onOpenChange={(open) => !open && closeSheet()}>
|
||||
<SheetContent size="default" className="!min-w-[560px] flex flex-col h-full gap-0">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{selectedUser?.name ?? selectedUser?.email ?? 'User details'}
|
||||
{sheetMode === 'edit' ? 'Edit JIT access' : 'Grant JIT access'}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
View JIT access details for this user.
|
||||
<SheetDescription className="sr-only">
|
||||
Configure user role grants and restrictions for JIT database access.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="overflow-auto flex-grow px-0">
|
||||
<SheetSection>
|
||||
<div className="grid gap-4 py-4">
|
||||
{selectedUser && (
|
||||
<>
|
||||
{selectedUser.name && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="text-sm text-foreground-light">Name</span>
|
||||
<span className="col-span-3 text-sm">{selectedUser.name}</span>
|
||||
|
||||
<ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
|
||||
<div className="px-5 sm:px-6 py-6 space-y-8">
|
||||
<FormItemLayout layout="vertical" isReactForm={false} label="User">
|
||||
<Select_Shadcn_
|
||||
value={draft.memberId}
|
||||
onValueChange={(value) => setDraft((prev) => ({ ...prev, memberId: value }))}
|
||||
>
|
||||
<SelectTrigger_Shadcn_>
|
||||
<SelectValue_Shadcn_ placeholder="Select a member" />
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{MOCK_MEMBERS.map((member) => (
|
||||
<SelectItem_Shadcn_ key={member.id} value={member.id}>
|
||||
{member.name ? `${member.name} (${member.email})` : member.email}
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</FormItemLayout>
|
||||
|
||||
<FormItemLayout layout="vertical" isReactForm={false} label="Roles and settings">
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{AVAILABLE_ROLES.map((role, index) => {
|
||||
const grant =
|
||||
draft.grants.find((draftGrant) => draftGrant.roleId === role.id) ??
|
||||
createEmptyGrant(role.id)
|
||||
|
||||
return (
|
||||
<div key={role.id} className={index > 0 ? 'border-t' : ''}>
|
||||
<RoleRuleEditor
|
||||
role={role}
|
||||
grant={grant}
|
||||
onChange={(next) => updateGrant(role.id, () => next)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="text-sm text-foreground-light">Email</span>
|
||||
<span className="col-span-3 text-sm">{selectedUser.email}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="text-sm text-foreground-light">Roles</span>
|
||||
<span className="col-span-3 text-sm">
|
||||
{selectedUser.roles.length} role{selectedUser.roles.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="text-sm text-foreground-light">Status</span>
|
||||
<span className="col-span-3">
|
||||
{selectedStatusDisplay && (
|
||||
<Badge variant={selectedStatusDisplay.variant}>
|
||||
{selectedStatusDisplay.label}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetSection>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground-lighter mt-2 leading-normal">
|
||||
Use custom{' '}
|
||||
<InlineLink href={`${DOCS_URL}/guides/database/postgres/roles`}>
|
||||
Postgres roles
|
||||
</InlineLink>{' '}
|
||||
to enforce least-privilege access when enabling JIT.
|
||||
</p>
|
||||
</FormItemLayout>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="!justify-between w-full mt-auto py-4 border-t">
|
||||
<Button type="default" onClick={closeSheet}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSave} disabled={!canSave}>
|
||||
{sheetMode === 'edit' ? 'Update access' : 'Grant access'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageSectionContent>
|
||||
</PageSection >
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user