JitDbAccessRoleGrantFields.tsx288 lines · main
1import dayjs from 'dayjs'
2import type { Control } from 'react-hook-form'
3import {
4 Checkbox,
5 Select,
6 SelectContent,
7 SelectItem,
8 SelectTrigger,
9 SelectValue,
10 WarningIcon,
11} from 'ui'
12import { TimestampInfo } from 'ui-patterns'
13import { Admonition } from 'ui-patterns/admonition'
14import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
15import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray'
16
17import type { JitRoleGrantDraft, JitRoleOption, JitUserRuleDraft } from './JitDbAccess.types'
18import { createEmptyIpRange, getRelativeDatetimeByMode } from './JitDbAccess.utils'
19import { DatePicker } from '@/components/ui/DatePicker'
20import { InlineLink } from '@/components/ui/InlineLink'
21import { DOCS_URL } from '@/lib/constants'
22
23const EXPIRY_MODE_OPTIONS: Array<{ value: JitRoleGrantDraft['expiryMode']; label: string }> = [
24 { value: '1h', label: '1 hour' },
25 { value: '1d', label: '1 day' },
26 { value: '7d', label: '7 days' },
27 { value: '30d', label: '30 days' },
28 { value: 'custom', label: 'Custom' },
29 { value: 'never', label: 'Never' },
30]
31
32const MAX_CUSTOM_EXPIRY_YEARS = 1
33const BRANCH_SCOPE_OPTIONS = [
34 { value: 'all', label: 'All project databases' },
35 { value: 'preview', label: 'Preview branches only' },
36] as const
37
38interface JitDbAccessRoleGrantFieldsProps {
39 control: Control<JitUserRuleDraft>
40 grantIndex: number
41 role: JitRoleOption
42 grant: JitRoleGrantDraft
43 onChange: (next: JitRoleGrantDraft) => void
44}
45
46export function JitDbAccessRoleGrantFields({
47 control,
48 grantIndex,
49 role,
50 grant,
51 onChange,
52}: JitDbAccessRoleGrantFieldsProps) {
53 const isSuperuserRole = role.id === 'postgres'
54 const isReadOnlyRole = role.id === 'briven_read_only_user'
55 const checkboxId = `jit-role-${role.id}`
56
57 return (
58 <div className={grant.enabled ? 'bg-surface-100' : 'bg-background'}>
59 <label
60 htmlFor={checkboxId}
61 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 hover:bg-surface-200/40"
62 >
63 <Checkbox
64 id={checkboxId}
65 checked={grant.enabled}
66 onCheckedChange={(value) => {
67 const isEnabled = value === true
68
69 if (!isEnabled) {
70 return onChange({ ...grant, enabled: false })
71 }
72
73 if (
74 (grant.hasExpiry && grant.expiry) ||
75 (!grant.hasExpiry && grant.expiryMode === 'never')
76 ) {
77 return onChange({ ...grant, enabled: true })
78 }
79
80 onChange({
81 ...grant,
82 enabled: true,
83 hasExpiry: true,
84 expiryMode: '1h',
85 expiry: getRelativeDatetimeByMode('1h'),
86 })
87 }}
88 aria-label={`Enable ${role.label}`}
89 className="mt-0.5"
90 />
91 <div className="min-w-0 flex-1">
92 <code className="text-code-inline dark:bg-surface-300! dark:border-control! tracking-normal!">
93 {role.label}
94 </code>
95 </div>
96 </label>
97
98 {grant.enabled && (
99 <div className="grid grid-cols-[16px_minmax(0,1fr)] gap-x-3 px-4 pb-3">
100 <div aria-hidden />
101
102 <div className="space-y-4">
103 {isSuperuserRole && (
104 <Admonition
105 type="warning"
106 layout="vertical"
107 className="mb-3"
108 title="The selected role has unrestricted access and bypasses row-level security"
109 description={
110 <>
111 Consider using a{' '}
112 <InlineLink href={`${DOCS_URL}/guides/database/postgres/roles`}>
113 custom Postgres role
114 </InlineLink>{' '}
115 with only the permissions required.
116 </>
117 }
118 />
119 )}
120
121 {isReadOnlyRole && (
122 <Admonition
123 type="warning"
124 layout="vertical"
125 title="The selected role has read-only access to all schemas"
126 description={
127 <>
128 Consider using a{' '}
129 <InlineLink href={`${DOCS_URL}/guides/database/postgres/roles`}>
130 custom Postgres role
131 </InlineLink>{' '}
132 with only the permissions required.
133 </>
134 }
135 className="mb-3"
136 />
137 )}
138
139 <FormItemLayout
140 isReactForm={false}
141 label="Applies to"
142 description={
143 <p className="text-xs text-foreground-lighter">
144 {grant.branchesOnly
145 ? 'Can only be requested from preview branch databases.'
146 : 'Can be requested from production and preview branch databases.'}
147 </p>
148 }
149 >
150 <Select
151 value={grant.branchesOnly ? 'preview' : 'all'}
152 onValueChange={(value) => onChange({ ...grant, branchesOnly: value === 'preview' })}
153 >
154 <SelectTrigger className="w-full">
155 <SelectValue placeholder="Select database scope" />
156 </SelectTrigger>
157 <SelectContent>
158 {BRANCH_SCOPE_OPTIONS.map((option) => (
159 <SelectItem key={option.value} value={option.value}>
160 {option.label}
161 </SelectItem>
162 ))}
163 </SelectContent>
164 </Select>
165 </FormItemLayout>
166
167 <FormItemLayout
168 isReactForm={false}
169 label="Expires in"
170 description={
171 grant.hasExpiry && grant.expiry ? (
172 <p className="text-xs text-foreground-lighter">
173 Expires at{' '}
174 <TimestampInfo
175 utcTimestamp={grant.expiry}
176 className="text-foreground-lighter"
177 labelFormat="DD MMM, HH:mm"
178 />
179 </p>
180 ) : grant.expiryMode === 'never' ? (
181 <div className="mt-3 mx-0.5 flex w-full items-center gap-x-2">
182 <WarningIcon />
183 <span className="text-left text-xs text-foreground-lighter">
184 No expiry means ongoing database access until manually revoked.
185 </span>
186 </div>
187 ) : undefined
188 }
189 >
190 <div className="flex gap-2">
191 <div className="flex-1">
192 <Select
193 value={grant.expiryMode}
194 onValueChange={(value) => {
195 const nextMode = value as JitRoleGrantDraft['expiryMode']
196
197 if (nextMode === 'never') {
198 return onChange({
199 ...grant,
200 expiryMode: nextMode,
201 hasExpiry: false,
202 expiry: '',
203 })
204 }
205
206 if (nextMode === 'custom') {
207 return onChange({
208 ...grant,
209 expiryMode: nextMode,
210 hasExpiry: true,
211 expiry: grant.expiry || getRelativeDatetimeByMode('1h'),
212 })
213 }
214
215 onChange({
216 ...grant,
217 expiryMode: nextMode,
218 hasExpiry: true,
219 expiry: getRelativeDatetimeByMode(nextMode),
220 })
221 }}
222 >
223 <SelectTrigger>
224 <SelectValue placeholder="Expires in" />
225 </SelectTrigger>
226 <SelectContent>
227 {EXPIRY_MODE_OPTIONS.map((option) => (
228 <SelectItem key={option.value} value={option.value}>
229 {option.label}
230 </SelectItem>
231 ))}
232 </SelectContent>
233 </Select>
234 </div>
235
236 {grant.expiryMode === 'custom' && (
237 <DatePicker
238 selectsRange={false}
239 triggerButtonSize="small"
240 contentSide="top"
241 to={grant.expiry || undefined}
242 minDate={new Date()}
243 maxDate={dayjs().add(MAX_CUSTOM_EXPIRY_YEARS, 'year').toDate()}
244 onChange={(value) => {
245 const selectedDate = value.to || value.from || ''
246 onChange({
247 ...grant,
248 hasExpiry: true,
249 expiry: selectedDate,
250 })
251 }}
252 triggerButtonClassName="min-w-[120px]"
253 >
254 {grant.expiry ? dayjs(grant.expiry).format('DD MMM, HH:mm') : 'Select date'}
255 </DatePicker>
256 )}
257 </div>
258 </FormItemLayout>
259
260 <FormItemLayout
261 isReactForm={false}
262 label={
263 <p className="text-sm text-foreground">
264 Restricted IP addresses{' '}
265 <span className="font-normal text-foreground-lighter">(optional)</span>
266 </p>
267 }
268 >
269 <SingleValueFieldArray
270 control={control}
271 name={`grants.${grantIndex}.ipRanges` as const}
272 valueFieldName="value"
273 createEmptyRow={createEmptyIpRange}
274 placeholder="192.168.0.0/24"
275 addLabel="Add IP restriction"
276 removeLabel="Remove IP restriction"
277 minimumRows={1}
278 inputAutoComplete="off"
279 rowsClassName="space-y-2"
280 addButtonClassName="w-min"
281 />
282 </FormItemLayout>
283 </div>
284 </div>
285 )}
286 </div>
287 )
288}