JitDbAccessRuleSheet.tsx351 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { useParams } from 'common'
3import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
4import { useEffect, useMemo } from 'react'
5import { useForm } from 'react-hook-form'
6import { toast } from 'sonner'
7import {
8 Button,
9 Form,
10 FormControl,
11 FormField,
12 ScrollArea,
13 Select,
14 SelectContent,
15 SelectItem,
16 SelectTrigger,
17 SelectValue,
18 Sheet,
19 SheetContent,
20 SheetDescription,
21 SheetFooter,
22 SheetHeader,
23 SheetTitle,
24} from 'ui'
25import { Admonition } from 'ui-patterns'
26import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
27import { z } from 'zod'
28
29import type {
30 JitExpiryMode,
31 JitMemberOption,
32 JitUserRuleDraft,
33 SheetMode,
34} from './JitDbAccess.types'
35import {
36 createDraft,
37 draftFromRule,
38 getAssignableJitRoleOptions,
39 getInvalidIpRangeRows,
40 mapJitMembersToUserRules,
41 serializeDraftRolesForGrantMutation,
42} from './JitDbAccess.utils'
43import { JitDbAccessRoleGrantFields } from './JitDbAccessRoleGrantFields'
44import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
45import { InlineLink } from '@/components/ui/InlineLink'
46import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
47import { useJitDbAccessGrantMutation } from '@/data/jit-db-access/jit-db-access-grant-mutation'
48import { useJitDbAccessMembersQuery } from '@/data/jit-db-access/jit-db-access-members-query'
49import { useProjectMembersQuery } from '@/data/projects/project-members-query'
50import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
51import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
52import { DOCS_URL } from '@/lib/constants'
53
54const grantSchema = z.object({
55 roleId: z.string(),
56 enabled: z.boolean(),
57 branchesOnly: z.boolean(),
58 expiryMode: z.custom<JitExpiryMode>(),
59 hasExpiry: z.boolean(),
60 expiry: z.string(),
61 ipRanges: z.array(z.object({ value: z.string() })),
62})
63
64function createJitRuleSchema(mode: SheetMode, membersWithRules: Set<string>) {
65 return z
66 .object({
67 memberId: z.string().min(1, 'Select a member for this temporary access rule.'),
68 grants: z.array(grantSchema),
69 })
70 .superRefine((data, ctx) => {
71 if (mode === 'add' && membersWithRules.has(data.memberId)) {
72 ctx.addIssue({
73 code: z.ZodIssueCode.custom,
74 path: ['memberId'],
75 message:
76 'This member already has a temporary access rule. Edit their existing rule from the list.',
77 })
78 }
79
80 const enabledGrantCount = data.grants.filter((g) => g.enabled).length
81 if (enabledGrantCount === 0) {
82 ctx.addIssue({
83 code: z.ZodIssueCode.custom,
84 path: ['grants'],
85 message: 'Select at least one role.',
86 })
87 return
88 }
89
90 data.grants.forEach((grant, grantIndex) => {
91 if (!grant.enabled) return
92
93 const invalidCidrs = new Set(getInvalidIpRangeRows(grant.ipRanges))
94
95 grant.ipRanges.forEach((ipRange, ipRangeIndex) => {
96 const value = ipRange.value.trim()
97 if (value.length === 0 || !invalidCidrs.has(value)) return
98
99 ctx.addIssue({
100 code: z.ZodIssueCode.custom,
101 path: ['grants', grantIndex, 'ipRanges', ipRangeIndex, 'value'],
102 message: 'Please enter a valid CIDR range',
103 })
104 })
105 })
106 })
107}
108
109interface JitDbAccessRuleSheetProps {
110 memberOptions: JitMemberOption[]
111 membersWithRules: Set<string>
112 availableMembersForAddCount: number
113}
114
115/**
116 * [Joshen] Form schema can be further refactored to simplify
117 * It's weird that we're rendering the role options based on the form, when it's just based on
118 * the available database roles - should decouple
119 */
120
121export function JitDbAccessRuleSheet({
122 memberOptions,
123 membersWithRules,
124 availableMembersForAddCount,
125}: JitDbAccessRuleSheetProps) {
126 const { ref: projectRef } = useParams()
127 const { data: project } = useSelectedProjectQuery()
128
129 const [isNewRule, setIsNewRule] = useQueryState('jit_new', parseAsBoolean.withDefault(false))
130 const [ruleIdToEdit, setRuleIdToEdit] = useQueryState('jit_edit', parseAsString)
131
132 const { data: databaseRoles, isSuccess: isSuccessDatabaseRoles } = useDatabaseRolesQuery({
133 projectRef,
134 connectionString: project?.connectionString,
135 })
136 const roleOptions = useMemo(() => getAssignableJitRoleOptions(databaseRoles), [databaseRoles])
137 const roleIds = useMemo(() => roleOptions.map((role) => role.id), [roleOptions])
138
139 const { data: jitMembers, isSuccess: isSuccessJitMembers } = useJitDbAccessMembersQuery({
140 projectRef,
141 })
142 const { data: projectMembers, isSuccess: isSuccessProjectMembers } = useProjectMembersQuery({
143 projectRef,
144 })
145 const users = useMemo(
146 () => mapJitMembersToUserRules(jitMembers, projectMembers, roleOptions),
147 [jitMembers, projectMembers, roleOptions]
148 )
149 const user = users.find((x) => x.id === ruleIdToEdit)
150 const mode: SheetMode = !!user ? 'edit' : 'add'
151
152 const isDataReady = isSuccessDatabaseRoles && isSuccessJitMembers && isSuccessProjectMembers
153 const open = isNewRule || (!!ruleIdToEdit && !!user)
154
155 const defaultValues = !isNewRule && !!user ? draftFromRule(user, roleIds) : createDraft(roleIds)
156 const FormSchema = useMemo(
157 () => createJitRuleSchema(mode, membersWithRules),
158 [mode, membersWithRules]
159 )
160 const form = useForm<JitUserRuleDraft>({
161 defaultValues,
162 resolver: zodResolver(FormSchema as any),
163 })
164 const grants = form.watch('grants')
165
166 const onCloseSheet = () => {
167 setIsNewRule(false)
168 setRuleIdToEdit(null)
169 }
170
171 const {
172 confirmOnClose,
173 handleOpenChange,
174 modalProps: closeConfirmationModalProps,
175 } = useConfirmOnClose({
176 checkIsDirty: () => form.formState.isDirty,
177 onClose: onCloseSheet,
178 })
179
180 const { mutate: grantUserAccess, isPending: isSubmitting } = useJitDbAccessGrantMutation({
181 onSuccess: () => {
182 toast.success(
183 mode === 'edit' ? 'Successfully updated user access' : 'Successfully granted user access'
184 )
185 onCloseSheet()
186 },
187 onError: (error) => {
188 toast.error(`Failed to ${mode === 'edit' ? 'update' : 'grant'} user access: ${error.message}`)
189 },
190 })
191
192 const updateGrant = (
193 roleId: string,
194 updater: (grant: JitUserRuleDraft['grants'][number]) => JitUserRuleDraft['grants'][number]
195 ) => {
196 const nextGrants = grants.map((grant) => (grant.roleId === roleId ? updater(grant) : grant))
197 form.setValue('grants', nextGrants, { shouldDirty: true })
198 }
199
200 const handleSaveRule = (data: z.infer<typeof FormSchema>) => {
201 if (!projectRef) return console.error('Project ref is required')
202
203 const roles = serializeDraftRolesForGrantMutation(data)
204 if (roles.length === 0) return
205
206 grantUserAccess({ projectRef, userId: data.memberId, roles })
207 }
208
209 useEffect(() => {
210 if (!!ruleIdToEdit && isDataReady && !user) {
211 toast('Access rule cannot be found')
212 setRuleIdToEdit(null)
213 }
214 }, [isDataReady, ruleIdToEdit, setRuleIdToEdit, user])
215
216 useEffect(() => {
217 if (open && isDataReady) form.reset(defaultValues)
218 // eslint-disable-next-line react-hooks/exhaustive-deps
219 }, [open, isDataReady])
220
221 return (
222 <>
223 <Sheet open={open} onOpenChange={handleOpenChange}>
224 <SheetContent
225 showClose={false}
226 size="default"
227 className="flex h-full w-full max-w-full flex-col gap-0 sm:w-[560px]! sm:max-w-[560px]"
228 >
229 <SheetHeader>
230 <SheetTitle>
231 {mode === 'edit' ? 'Edit temporary access rule' : 'New temporary access rule'}
232 </SheetTitle>
233 <SheetDescription className="sr-only">
234 Configure which database roles a user can request with temporary access.
235 </SheetDescription>
236 </SheetHeader>
237
238 <Form {...form}>
239 <ScrollArea className="flex-1 max-h-[calc(100vh-116px)]">
240 <div className="space-y-8 px-5 py-6 sm:px-6">
241 <FormField
242 control={form.control}
243 name="memberId"
244 render={({ field }) => (
245 <FormItemLayout layout="vertical" label="Member">
246 <FormControl>
247 <Select
248 value={field.value}
249 disabled={
250 mode === 'edit' || (mode === 'add' && availableMembersForAddCount === 0)
251 }
252 onValueChange={field.onChange}
253 >
254 <SelectTrigger>
255 <SelectValue placeholder="Select a member" />
256 </SelectTrigger>
257 <SelectContent>
258 {memberOptions.map((member) => (
259 <SelectItem key={member.id} value={member.id}>
260 {member.name ? (
261 <>
262 {member.name}{' '}
263 <span className="text-foreground-lighter">
264 ({member.email})
265 </span>
266 </>
267 ) : (
268 member.email
269 )}
270 </SelectItem>
271 ))}
272 </SelectContent>
273 </Select>
274 </FormControl>
275
276 {mode === 'add' && availableMembersForAddCount === 0 && (
277 <p className="mt-2 text-foreground-lighter">
278 All project members already have temporary access rules. Edit an existing
279 rule from the table above.
280 </p>
281 )}
282 </FormItemLayout>
283 )}
284 />
285
286 <FormField
287 control={form.control}
288 name="grants"
289 render={() => (
290 <FormItemLayout
291 layout="vertical"
292 label="Roles and settings"
293 description={
294 <>
295 Use{' '}
296 <InlineLink
297 href={`${DOCS_URL}/guides/database/postgres/roles`}
298 className="decoration-foreground-muted"
299 >
300 custom Postgres roles
301 </InlineLink>{' '}
302 with narrow permissions to reduce the impact of direct database access.
303 </>
304 }
305 >
306 {grants.length === 0 ? (
307 <Admonition
308 type="note"
309 description="No assignable roles found."
310 className="bg-background"
311 />
312 ) : (
313 <div className="overflow-hidden rounded-md border">
314 {grants.map((grant, index) => (
315 <div key={grant.roleId} className={index > 0 ? 'border-t' : ''}>
316 <JitDbAccessRoleGrantFields
317 control={form.control}
318 grantIndex={index}
319 role={{ id: grant.roleId, label: grant.roleId }}
320 grant={grant}
321 onChange={(next) => updateGrant(grant.roleId, () => next)}
322 />
323 </div>
324 ))}
325 </div>
326 )}
327 </FormItemLayout>
328 )}
329 />
330 </div>
331 </ScrollArea>
332 </Form>
333
334 <SheetFooter className="mt-auto w-full border-t py-4">
335 <Button type="default" onClick={confirmOnClose} disabled={isSubmitting}>
336 Cancel
337 </Button>
338 <Button
339 type="primary"
340 onClick={form.handleSubmit(handleSaveRule)}
341 loading={isSubmitting}
342 >
343 {mode === 'edit' ? 'Save rule' : 'Create rule'}
344 </Button>
345 </SheetFooter>
346 </SheetContent>
347 </Sheet>
348 <DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
349 </>
350 )
351}