JitDbAccessRoleGrantFields.tsx288 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | import type { Control } from 'react-hook-form' |
| 3 | import { |
| 4 | Checkbox, |
| 5 | Select, |
| 6 | SelectContent, |
| 7 | SelectItem, |
| 8 | SelectTrigger, |
| 9 | SelectValue, |
| 10 | WarningIcon, |
| 11 | } from 'ui' |
| 12 | import { TimestampInfo } from 'ui-patterns' |
| 13 | import { Admonition } from 'ui-patterns/admonition' |
| 14 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 15 | import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray' |
| 16 | |
| 17 | import type { JitRoleGrantDraft, JitRoleOption, JitUserRuleDraft } from './JitDbAccess.types' |
| 18 | import { createEmptyIpRange, getRelativeDatetimeByMode } from './JitDbAccess.utils' |
| 19 | import { DatePicker } from '@/components/ui/DatePicker' |
| 20 | import { InlineLink } from '@/components/ui/InlineLink' |
| 21 | import { DOCS_URL } from '@/lib/constants' |
| 22 | |
| 23 | const 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 | |
| 32 | const MAX_CUSTOM_EXPIRY_YEARS = 1 |
| 33 | const BRANCH_SCOPE_OPTIONS = [ |
| 34 | { value: 'all', label: 'All project databases' }, |
| 35 | { value: 'preview', label: 'Preview branches only' }, |
| 36 | ] as const |
| 37 | |
| 38 | interface JitDbAccessRoleGrantFieldsProps { |
| 39 | control: Control<JitUserRuleDraft> |
| 40 | grantIndex: number |
| 41 | role: JitRoleOption |
| 42 | grant: JitRoleGrantDraft |
| 43 | onChange: (next: JitRoleGrantDraft) => void |
| 44 | } |
| 45 | |
| 46 | export 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 | } |