CreateHookSheet.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import {
  3. ident,
  4. joinSqlFragments,
  5. safeSql,
  6. type SafeSqlFragment,
  7. } from '@supabase/pg-meta/src/pg-format'
  8. import { useParams } from 'common'
  9. import randomBytes from 'randombytes'
  10. import { useEffect, useMemo } from 'react'
  11. import { SubmitHandler, useForm } from 'react-hook-form'
  12. import { toast } from 'sonner'
  13. import {
  14. Button,
  15. Form,
  16. FormControl,
  17. FormField,
  18. Input,
  19. RadioGroupStacked,
  20. RadioGroupStackedItem,
  21. Separator,
  22. Sheet,
  23. SheetContent,
  24. SheetFooter,
  25. SheetHeader,
  26. SheetSection,
  27. SheetTitle,
  28. Switch,
  29. } from 'ui'
  30. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  31. import { InfoTooltip } from 'ui-patterns/info-tooltip'
  32. import * as z from 'zod'
  33. import { Hook, HOOK_DEFINITION_TITLE, HOOKS_DEFINITIONS } from './hooks.constants'
  34. import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils'
  35. import { convertArgumentTypes } from '@/components/interfaces/Database/Functions/Functions.utils'
  36. import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
  37. import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
  38. import { DocsButton } from '@/components/ui/DocsButton'
  39. import FunctionSelector from '@/components/ui/FunctionSelector'
  40. import SchemaSelector from '@/components/ui/SchemaSelector'
  41. import { AuthConfigResponse } from '@/data/auth/auth-config-query'
  42. import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation'
  43. import { executeSql } from '@/data/sql/execute-sql-query'
  44. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  45. import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
  46. import { DOCS_URL } from '@/lib/constants'
  47. interface CreateHookSheetProps {
  48. visible: boolean
  49. title: HOOK_DEFINITION_TITLE | null
  50. authConfig: AuthConfigResponse
  51. onClose: () => void
  52. onDelete: () => void
  53. }
  54. export function generateAuthHookSecret() {
  55. const secretByteLength = 60
  56. const buffer = randomBytes(secretByteLength)
  57. const base64String = buffer.toString('base64')
  58. return `v1,whsec_${base64String}`
  59. }
  60. const FORM_ID = 'create-edit-auth-hook'
  61. const FormSchema = z
  62. .object({
  63. hookType: z.string(),
  64. enabled: z.boolean(),
  65. selectedType: z.union([z.literal('https'), z.literal('postgres')]),
  66. httpsValues: z.object({
  67. url: z.string(),
  68. secret: z.string(),
  69. }),
  70. postgresValues: z.object({
  71. schema: z.string(),
  72. functionName: z.string(),
  73. }),
  74. })
  75. .superRefine((data, ctx) => {
  76. if (data.selectedType === 'https') {
  77. if (!data.httpsValues.url.startsWith('https://')) {
  78. ctx.addIssue({
  79. path: ['httpsValues', 'url'],
  80. code: z.ZodIssueCode.custom,
  81. message: 'The URL must start with https://',
  82. })
  83. }
  84. if (!data.httpsValues.secret) {
  85. ctx.addIssue({
  86. path: ['httpsValues', 'secret'],
  87. code: z.ZodIssueCode.custom,
  88. message: 'Missing secret value',
  89. })
  90. }
  91. }
  92. if (data.selectedType === 'postgres') {
  93. if (!data.postgresValues.schema) {
  94. ctx.addIssue({
  95. path: ['postgresValues', 'schema'],
  96. code: z.ZodIssueCode.custom,
  97. message: 'You must select a schema',
  98. })
  99. }
  100. if (!data.postgresValues.functionName) {
  101. ctx.addIssue({
  102. path: ['postgresValues', 'functionName'],
  103. code: z.ZodIssueCode.custom,
  104. message: 'You must select a Postgres function',
  105. })
  106. }
  107. }
  108. return true
  109. })
  110. export const CreateHookSheet = ({
  111. visible,
  112. title,
  113. authConfig,
  114. onClose,
  115. onDelete,
  116. }: CreateHookSheetProps) => {
  117. const { ref: projectRef } = useParams()
  118. const { data: project } = useSelectedProjectQuery()
  119. const definition = useMemo(
  120. () => HOOKS_DEFINITIONS.find((d) => d.title === title) || HOOKS_DEFINITIONS[0],
  121. [title]
  122. )
  123. const supportedReturnTypes =
  124. definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED'
  125. ? ['json', 'jsonb', 'void']
  126. : ['json', 'jsonb']
  127. const hook: Hook = useMemo(() => {
  128. return {
  129. ...definition,
  130. enabled: authConfig?.[definition.enabledKey] || false,
  131. method: extractMethod(
  132. authConfig?.[definition.uriKey] || '',
  133. authConfig?.[definition.secretsKey] || ''
  134. ),
  135. }
  136. }, [definition, authConfig])
  137. // if the hook has all parameters, then it is not being created.
  138. const isCreating = !isValidHook(hook)
  139. const form = useForm<z.infer<typeof FormSchema>>({
  140. resolver: zodResolver(FormSchema as any),
  141. defaultValues: {
  142. hookType: title || '',
  143. enabled: true,
  144. selectedType: 'postgres',
  145. httpsValues: {
  146. url: '',
  147. secret: '',
  148. },
  149. postgresValues: {
  150. schema: 'public',
  151. functionName: '',
  152. },
  153. },
  154. })
  155. const isDirty = form.formState.isDirty
  156. const values = form.watch()
  157. const {
  158. confirmOnClose,
  159. handleOpenChange,
  160. modalProps: discardChangesModalProps,
  161. } = useConfirmOnClose({
  162. checkIsDirty: () => isDirty,
  163. onClose,
  164. })
  165. const statements = useMemo(() => {
  166. let permissionChanges: Array<SafeSqlFragment> = []
  167. if (hook.method.type === 'postgres') {
  168. if (
  169. hook.method.schema !== '' &&
  170. hook.method.functionName !== '' &&
  171. hook.method.functionName !== values.postgresValues.functionName
  172. ) {
  173. permissionChanges = getRevokePermissionStatements(
  174. hook.method.schema,
  175. hook.method.functionName
  176. )
  177. }
  178. }
  179. if (values.postgresValues.functionName !== '') {
  180. const schema = values.postgresValues.schema
  181. const functionName = values.postgresValues.functionName
  182. permissionChanges = [
  183. ...permissionChanges,
  184. safeSql`-- Grant access to function to briven_auth_admin
  185. grant execute on function ${ident(schema)}.${ident(functionName)} to briven_auth_admin;`,
  186. safeSql`-- Grant access to schema to briven_auth_admin
  187. grant usage on schema ${ident(schema)} to briven_auth_admin;`,
  188. safeSql`-- Revoke function permissions from authenticated, anon and public
  189. revoke execute on function ${ident(schema)}.${ident(functionName)} from authenticated, anon, public;`,
  190. ]
  191. }
  192. return permissionChanges
  193. }, [hook, values.postgresValues.schema, values.postgresValues.functionName])
  194. const { mutate: updateAuthHooks, isPending: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({
  195. onSuccess: () => {
  196. toast.success(`Successfully created ${values.hookType}.`)
  197. if (statements.length > 0) {
  198. executeSql({
  199. projectRef,
  200. connectionString: project!.connectionString,
  201. sql: joinSqlFragments(statements, '\n'),
  202. })
  203. }
  204. onClose()
  205. },
  206. onError: (error) => {
  207. toast.error(`Failed to create hook: ${error.message}`)
  208. },
  209. })
  210. const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
  211. if (!project) return console.error('Project is required')
  212. const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title)
  213. if (!definition) {
  214. return
  215. }
  216. const enabledLabel = definition.enabledKey
  217. const uriLabel = definition.uriKey
  218. const secretsLabel = definition.secretsKey
  219. let url = ''
  220. if (values.selectedType === 'postgres') {
  221. url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}`
  222. } else {
  223. url = values.httpsValues.url
  224. }
  225. const payload = {
  226. [enabledLabel]: values.enabled,
  227. [uriLabel]: url,
  228. [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null,
  229. }
  230. updateAuthHooks({ projectRef: projectRef!, config: payload })
  231. }
  232. useEffect(() => {
  233. if (visible) {
  234. if (definition) {
  235. const values = extractMethod(
  236. authConfig?.[definition.uriKey] || '',
  237. authConfig?.[definition.secretsKey] || ''
  238. )
  239. form.reset({
  240. hookType: definition.title,
  241. enabled: isCreating ? true : authConfig?.[definition.enabledKey],
  242. selectedType: values.type,
  243. httpsValues: {
  244. url: (values.type === 'https' && values.url) || '',
  245. secret: (values.type === 'https' && values.secret) || '',
  246. },
  247. postgresValues: {
  248. schema: (values.type === 'postgres' && values.schema) || 'public',
  249. functionName: (values.type === 'postgres' && values.functionName) || '',
  250. },
  251. })
  252. } else {
  253. form.reset({
  254. hookType: title || '',
  255. enabled: true,
  256. selectedType: 'postgres',
  257. httpsValues: {
  258. url: '',
  259. secret: '',
  260. },
  261. postgresValues: {
  262. schema: 'public',
  263. functionName: '',
  264. },
  265. })
  266. }
  267. }
  268. // eslint-disable-next-line react-hooks/exhaustive-deps
  269. }, [authConfig, title, visible, definition])
  270. return (
  271. <Sheet open={visible} onOpenChange={handleOpenChange}>
  272. <SheetContent
  273. aria-describedby={undefined}
  274. size="lg"
  275. showClose={false}
  276. className="flex flex-col gap-0"
  277. >
  278. <SheetHeader className="py-3 flex flex-row justify-between items-center border-b-0">
  279. <SheetTitle className="truncate">
  280. {isCreating ? `Add ${title}` : `Update ${title}`}
  281. </SheetTitle>
  282. <DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} />
  283. </SheetHeader>
  284. <Separator />
  285. <SheetSection className="overflow-auto grow px-0">
  286. <Form {...form}>
  287. <form
  288. id={FORM_ID}
  289. className="space-y-6 w-full py-5 flex-1"
  290. onSubmit={form.handleSubmit(onSubmit)}
  291. >
  292. <FormField
  293. key="enabled"
  294. name="enabled"
  295. control={form.control}
  296. render={({ field }) => (
  297. <FormItemLayout
  298. layout="flex"
  299. className="px-5"
  300. label={`Enable ${values.hookType}`}
  301. description={
  302. values.hookType === 'Send SMS hook'
  303. ? 'SMS Provider settings will be disabled in favor of SMS hooks'
  304. : undefined
  305. }
  306. >
  307. <FormControl>
  308. <Switch
  309. checked={field.value}
  310. onCheckedChange={field.onChange}
  311. disabled={field.disabled}
  312. />
  313. </FormControl>
  314. </FormItemLayout>
  315. )}
  316. />
  317. <Separator />
  318. <FormField
  319. control={form.control}
  320. name="selectedType"
  321. render={({ field }) => (
  322. <FormItemLayout label="Hook type" className="px-5">
  323. <FormControl>
  324. <RadioGroupStacked
  325. value={field.value}
  326. onValueChange={(value) => field.onChange(value)}
  327. >
  328. <RadioGroupStackedItem
  329. value="postgres"
  330. id="postgres"
  331. key="postgres"
  332. label="Postgres"
  333. description="Used to call a Postgres function."
  334. />
  335. <RadioGroupStackedItem
  336. value="https"
  337. id="https"
  338. key="https"
  339. label="HTTPS"
  340. description="Used to call any HTTPS endpoint."
  341. />
  342. </RadioGroupStacked>
  343. </FormControl>
  344. </FormItemLayout>
  345. )}
  346. />
  347. {values.selectedType === 'postgres' ? (
  348. <>
  349. <div className="grid grid-cols-2 gap-8 px-5">
  350. <FormField
  351. key="postgresValues.schema"
  352. control={form.control}
  353. name="postgresValues.schema"
  354. render={({ field }) => (
  355. <FormItemLayout
  356. label="Postgres Schema"
  357. description="Postgres schema where the function is defined"
  358. >
  359. <FormControl>
  360. <SchemaSelector
  361. size="small"
  362. showError={false}
  363. stopScrollPropagation
  364. selectedSchemaName={field.value}
  365. onSelectSchema={(name) => field.onChange(name)}
  366. disabled={field.disabled}
  367. />
  368. </FormControl>
  369. </FormItemLayout>
  370. )}
  371. />
  372. <FormField
  373. key="postgresValues.functionName"
  374. control={form.control}
  375. name="postgresValues.functionName"
  376. render={({ field }) => (
  377. <FormItemLayout
  378. label="Postgres function"
  379. description="This function will be called by Briven Auth each time the hook is triggered"
  380. >
  381. <FormControl>
  382. <FunctionSelector
  383. size="small"
  384. schema={values.postgresValues.schema}
  385. value={field.value}
  386. stopScrollPropagation
  387. onChange={field.onChange}
  388. disabled={field.disabled}
  389. filterFunction={(func) => {
  390. if (supportedReturnTypes.includes(func.return_type)) {
  391. const { value } = convertArgumentTypes(func.argument_types)
  392. if (value.length !== 1) return false
  393. return value[0].type === 'json' || value[0].type === 'jsonb'
  394. }
  395. return false
  396. }}
  397. noResultsLabel={
  398. <span>
  399. No function with a single JSON/B argument
  400. <br />
  401. and JSON/B
  402. {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED'
  403. ? ' or void'
  404. : ''}{' '}
  405. return type found in this schema.
  406. </span>
  407. }
  408. />
  409. </FormControl>
  410. </FormItemLayout>
  411. )}
  412. />
  413. </div>
  414. {statements.length > 0 && (
  415. <div className="h-72 w-full gap-3 flex flex-col">
  416. <p className="text-sm text-foreground-light px-5">
  417. The following statements will be executed on the selected function:
  418. </p>
  419. <CodeEditor
  420. isReadOnly
  421. id="postgres-hook-editor"
  422. language="pgsql"
  423. value={statements.join('\n\n')}
  424. />
  425. </div>
  426. )}
  427. </>
  428. ) : (
  429. <div className="flex flex-col gap-4 px-5">
  430. <FormField
  431. key="httpsValues.url"
  432. control={form.control}
  433. name="httpsValues.url"
  434. render={({ field }) => (
  435. <FormItemLayout
  436. label="URL"
  437. description="Briven Auth will send a HTTPS POST request to this URL each time the hook is triggered."
  438. >
  439. <FormControl>
  440. <Input {...field} />
  441. </FormControl>
  442. </FormItemLayout>
  443. )}
  444. />
  445. <FormField
  446. key="httpsValues.secret"
  447. control={form.control}
  448. name="httpsValues.secret"
  449. render={({ field }) => (
  450. <FormItemLayout
  451. label="Secret"
  452. description={
  453. <div className="flex items-center gap-x-2">
  454. <p>
  455. Should be a base64 encoded hook secret with a prefix{' '}
  456. <code className="text-code-inline">v1,whsec_</code>.
  457. </p>
  458. <InfoTooltip side="bottom" className="w-60 text-center">
  459. <code className="text-code-inline">v1</code> denotes the signature
  460. version and <code className="text-code-inline">whsec_</code> signifies
  461. a symmetric secret.
  462. </InfoTooltip>
  463. </div>
  464. }
  465. >
  466. <FormControl>
  467. <div className="flex flex-row">
  468. <Input {...field} className="rounded-r-none border-r-0" />
  469. <Button
  470. type="default"
  471. size="small"
  472. className="rounded-l-none text-xs"
  473. onClick={() => {
  474. const authHookSecret = generateAuthHookSecret()
  475. form.setValue('httpsValues.secret', authHookSecret, {
  476. shouldDirty: true,
  477. })
  478. }}
  479. >
  480. Generate secret
  481. </Button>
  482. </div>
  483. </FormControl>
  484. </FormItemLayout>
  485. )}
  486. />
  487. </div>
  488. )}
  489. </form>
  490. </Form>
  491. </SheetSection>
  492. <SheetFooter>
  493. {!isCreating && (
  494. <div className="flex-1">
  495. <Button type="danger" onClick={() => onDelete()}>
  496. Delete hook
  497. </Button>
  498. </div>
  499. )}
  500. <Button disabled={isUpdatingAuthHooks} type="default" onClick={confirmOnClose}>
  501. Cancel
  502. </Button>
  503. <Button
  504. form={FORM_ID}
  505. htmlType="submit"
  506. disabled={isUpdatingAuthHooks}
  507. loading={isUpdatingAuthHooks}
  508. >
  509. {isCreating ? 'Create hook' : 'Update hook'}
  510. </Button>
  511. </SheetFooter>
  512. </SheetContent>
  513. <DiscardChangesConfirmationDialog {...discardChangesModalProps} />
  514. </Sheet>
  515. )
  516. }