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