JitDbAccessRoleGrantFields.tsx 10 KB

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