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