| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import {
- ident,
- joinSqlFragments,
- safeSql,
- type SafeSqlFragment,
- } from '@supabase/pg-meta/src/pg-format'
- import { useParams } from 'common'
- import randomBytes from 'randombytes'
- import { useEffect, useMemo } from 'react'
- import { SubmitHandler, useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- Form,
- FormControl,
- FormField,
- Input,
- RadioGroupStacked,
- RadioGroupStackedItem,
- Separator,
- Sheet,
- SheetContent,
- SheetFooter,
- SheetHeader,
- SheetSection,
- SheetTitle,
- Switch,
- } from 'ui'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import { InfoTooltip } from 'ui-patterns/info-tooltip'
- import * as z from 'zod'
- import { Hook, HOOK_DEFINITION_TITLE, HOOKS_DEFINITIONS } from './hooks.constants'
- import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils'
- import { convertArgumentTypes } from '@/components/interfaces/Database/Functions/Functions.utils'
- import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
- import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
- import { DocsButton } from '@/components/ui/DocsButton'
- import FunctionSelector from '@/components/ui/FunctionSelector'
- import SchemaSelector from '@/components/ui/SchemaSelector'
- import { AuthConfigResponse } from '@/data/auth/auth-config-query'
- import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation'
- import { executeSql } from '@/data/sql/execute-sql-query'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
- import { DOCS_URL } from '@/lib/constants'
- interface CreateHookSheetProps {
- visible: boolean
- title: HOOK_DEFINITION_TITLE | null
- authConfig: AuthConfigResponse
- onClose: () => void
- onDelete: () => void
- }
- export function generateAuthHookSecret() {
- const secretByteLength = 60
- const buffer = randomBytes(secretByteLength)
- const base64String = buffer.toString('base64')
- return `v1,whsec_${base64String}`
- }
- const FORM_ID = 'create-edit-auth-hook'
- const FormSchema = z
- .object({
- hookType: z.string(),
- enabled: z.boolean(),
- selectedType: z.union([z.literal('https'), z.literal('postgres')]),
- httpsValues: z.object({
- url: z.string(),
- secret: z.string(),
- }),
- postgresValues: z.object({
- schema: z.string(),
- functionName: z.string(),
- }),
- })
- .superRefine((data, ctx) => {
- if (data.selectedType === 'https') {
- if (!data.httpsValues.url.startsWith('https://')) {
- ctx.addIssue({
- path: ['httpsValues', 'url'],
- code: z.ZodIssueCode.custom,
- message: 'The URL must start with https://',
- })
- }
- if (!data.httpsValues.secret) {
- ctx.addIssue({
- path: ['httpsValues', 'secret'],
- code: z.ZodIssueCode.custom,
- message: 'Missing secret value',
- })
- }
- }
- if (data.selectedType === 'postgres') {
- if (!data.postgresValues.schema) {
- ctx.addIssue({
- path: ['postgresValues', 'schema'],
- code: z.ZodIssueCode.custom,
- message: 'You must select a schema',
- })
- }
- if (!data.postgresValues.functionName) {
- ctx.addIssue({
- path: ['postgresValues', 'functionName'],
- code: z.ZodIssueCode.custom,
- message: 'You must select a Postgres function',
- })
- }
- }
- return true
- })
- export const CreateHookSheet = ({
- visible,
- title,
- authConfig,
- onClose,
- onDelete,
- }: CreateHookSheetProps) => {
- const { ref: projectRef } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const definition = useMemo(
- () => HOOKS_DEFINITIONS.find((d) => d.title === title) || HOOKS_DEFINITIONS[0],
- [title]
- )
- const supportedReturnTypes =
- definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED'
- ? ['json', 'jsonb', 'void']
- : ['json', 'jsonb']
- const hook: Hook = useMemo(() => {
- return {
- ...definition,
- enabled: authConfig?.[definition.enabledKey] || false,
- method: extractMethod(
- authConfig?.[definition.uriKey] || '',
- authConfig?.[definition.secretsKey] || ''
- ),
- }
- }, [definition, authConfig])
- // if the hook has all parameters, then it is not being created.
- const isCreating = !isValidHook(hook)
- const form = useForm<z.infer<typeof FormSchema>>({
- resolver: zodResolver(FormSchema as any),
- defaultValues: {
- hookType: title || '',
- enabled: true,
- selectedType: 'postgres',
- httpsValues: {
- url: '',
- secret: '',
- },
- postgresValues: {
- schema: 'public',
- functionName: '',
- },
- },
- })
- const isDirty = form.formState.isDirty
- const values = form.watch()
- const {
- confirmOnClose,
- handleOpenChange,
- modalProps: discardChangesModalProps,
- } = useConfirmOnClose({
- checkIsDirty: () => isDirty,
- onClose,
- })
- const statements = useMemo(() => {
- let permissionChanges: Array<SafeSqlFragment> = []
- if (hook.method.type === 'postgres') {
- if (
- hook.method.schema !== '' &&
- hook.method.functionName !== '' &&
- hook.method.functionName !== values.postgresValues.functionName
- ) {
- permissionChanges = getRevokePermissionStatements(
- hook.method.schema,
- hook.method.functionName
- )
- }
- }
- if (values.postgresValues.functionName !== '') {
- const schema = values.postgresValues.schema
- const functionName = values.postgresValues.functionName
- permissionChanges = [
- ...permissionChanges,
- safeSql`-- Grant access to function to briven_auth_admin
- grant execute on function ${ident(schema)}.${ident(functionName)} to briven_auth_admin;`,
- safeSql`-- Grant access to schema to briven_auth_admin
- grant usage on schema ${ident(schema)} to briven_auth_admin;`,
- safeSql`-- Revoke function permissions from authenticated, anon and public
- revoke execute on function ${ident(schema)}.${ident(functionName)} from authenticated, anon, public;`,
- ]
- }
- return permissionChanges
- }, [hook, values.postgresValues.schema, values.postgresValues.functionName])
- const { mutate: updateAuthHooks, isPending: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({
- onSuccess: () => {
- toast.success(`Successfully created ${values.hookType}.`)
- if (statements.length > 0) {
- executeSql({
- projectRef,
- connectionString: project!.connectionString,
- sql: joinSqlFragments(statements, '\n'),
- })
- }
- onClose()
- },
- onError: (error) => {
- toast.error(`Failed to create hook: ${error.message}`)
- },
- })
- const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
- if (!project) return console.error('Project is required')
- const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title)
- if (!definition) {
- return
- }
- const enabledLabel = definition.enabledKey
- const uriLabel = definition.uriKey
- const secretsLabel = definition.secretsKey
- let url = ''
- if (values.selectedType === 'postgres') {
- url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}`
- } else {
- url = values.httpsValues.url
- }
- const payload = {
- [enabledLabel]: values.enabled,
- [uriLabel]: url,
- [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null,
- }
- updateAuthHooks({ projectRef: projectRef!, config: payload })
- }
- useEffect(() => {
- if (visible) {
- if (definition) {
- const values = extractMethod(
- authConfig?.[definition.uriKey] || '',
- authConfig?.[definition.secretsKey] || ''
- )
- form.reset({
- hookType: definition.title,
- enabled: isCreating ? true : authConfig?.[definition.enabledKey],
- selectedType: values.type,
- httpsValues: {
- url: (values.type === 'https' && values.url) || '',
- secret: (values.type === 'https' && values.secret) || '',
- },
- postgresValues: {
- schema: (values.type === 'postgres' && values.schema) || 'public',
- functionName: (values.type === 'postgres' && values.functionName) || '',
- },
- })
- } else {
- form.reset({
- hookType: title || '',
- enabled: true,
- selectedType: 'postgres',
- httpsValues: {
- url: '',
- secret: '',
- },
- postgresValues: {
- schema: 'public',
- functionName: '',
- },
- })
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [authConfig, title, visible, definition])
- return (
- <Sheet open={visible} onOpenChange={handleOpenChange}>
- <SheetContent
- aria-describedby={undefined}
- size="lg"
- showClose={false}
- className="flex flex-col gap-0"
- >
- <SheetHeader className="py-3 flex flex-row justify-between items-center border-b-0">
- <SheetTitle className="truncate">
- {isCreating ? `Add ${title}` : `Update ${title}`}
- </SheetTitle>
- <DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} />
- </SheetHeader>
- <Separator />
- <SheetSection className="overflow-auto grow px-0">
- <Form {...form}>
- <form
- id={FORM_ID}
- className="space-y-6 w-full py-5 flex-1"
- onSubmit={form.handleSubmit(onSubmit)}
- >
- <FormField
- key="enabled"
- name="enabled"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout
- layout="flex"
- className="px-5"
- label={`Enable ${values.hookType}`}
- description={
- values.hookType === 'Send SMS hook'
- ? 'SMS Provider settings will be disabled in favor of SMS hooks'
- : undefined
- }
- >
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- disabled={field.disabled}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <Separator />
- <FormField
- control={form.control}
- name="selectedType"
- render={({ field }) => (
- <FormItemLayout label="Hook type" className="px-5">
- <FormControl>
- <RadioGroupStacked
- value={field.value}
- onValueChange={(value) => field.onChange(value)}
- >
- <RadioGroupStackedItem
- value="postgres"
- id="postgres"
- key="postgres"
- label="Postgres"
- description="Used to call a Postgres function."
- />
- <RadioGroupStackedItem
- value="https"
- id="https"
- key="https"
- label="HTTPS"
- description="Used to call any HTTPS endpoint."
- />
- </RadioGroupStacked>
- </FormControl>
- </FormItemLayout>
- )}
- />
- {values.selectedType === 'postgres' ? (
- <>
- <div className="grid grid-cols-2 gap-8 px-5">
- <FormField
- key="postgresValues.schema"
- control={form.control}
- name="postgresValues.schema"
- render={({ field }) => (
- <FormItemLayout
- label="Postgres Schema"
- description="Postgres schema where the function is defined"
- >
- <FormControl>
- <SchemaSelector
- size="small"
- showError={false}
- stopScrollPropagation
- selectedSchemaName={field.value}
- onSelectSchema={(name) => field.onChange(name)}
- disabled={field.disabled}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- key="postgresValues.functionName"
- control={form.control}
- name="postgresValues.functionName"
- render={({ field }) => (
- <FormItemLayout
- label="Postgres function"
- description="This function will be called by Briven Auth each time the hook is triggered"
- >
- <FormControl>
- <FunctionSelector
- size="small"
- schema={values.postgresValues.schema}
- value={field.value}
- stopScrollPropagation
- onChange={field.onChange}
- disabled={field.disabled}
- filterFunction={(func) => {
- if (supportedReturnTypes.includes(func.return_type)) {
- const { value } = convertArgumentTypes(func.argument_types)
- if (value.length !== 1) return false
- return value[0].type === 'json' || value[0].type === 'jsonb'
- }
- return false
- }}
- noResultsLabel={
- <span>
- No function with a single JSON/B argument
- <br />
- and JSON/B
- {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED'
- ? ' or void'
- : ''}{' '}
- return type found in this schema.
- </span>
- }
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </div>
- {statements.length > 0 && (
- <div className="h-72 w-full gap-3 flex flex-col">
- <p className="text-sm text-foreground-light px-5">
- The following statements will be executed on the selected function:
- </p>
- <CodeEditor
- isReadOnly
- id="postgres-hook-editor"
- language="pgsql"
- value={statements.join('\n\n')}
- />
- </div>
- )}
- </>
- ) : (
- <div className="flex flex-col gap-4 px-5">
- <FormField
- key="httpsValues.url"
- control={form.control}
- name="httpsValues.url"
- render={({ field }) => (
- <FormItemLayout
- label="URL"
- description="Briven Auth will send a HTTPS POST request to this URL each time the hook is triggered."
- >
- <FormControl>
- <Input {...field} />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- key="httpsValues.secret"
- control={form.control}
- name="httpsValues.secret"
- render={({ field }) => (
- <FormItemLayout
- label="Secret"
- description={
- <div className="flex items-center gap-x-2">
- <p>
- Should be a base64 encoded hook secret with a prefix{' '}
- <code className="text-code-inline">v1,whsec_</code>.
- </p>
- <InfoTooltip side="bottom" className="w-60 text-center">
- <code className="text-code-inline">v1</code> denotes the signature
- version and <code className="text-code-inline">whsec_</code> signifies
- a symmetric secret.
- </InfoTooltip>
- </div>
- }
- >
- <FormControl>
- <div className="flex flex-row">
- <Input {...field} className="rounded-r-none border-r-0" />
- <Button
- type="default"
- size="small"
- className="rounded-l-none text-xs"
- onClick={() => {
- const authHookSecret = generateAuthHookSecret()
- form.setValue('httpsValues.secret', authHookSecret, {
- shouldDirty: true,
- })
- }}
- >
- Generate secret
- </Button>
- </div>
- </FormControl>
- </FormItemLayout>
- )}
- />
- </div>
- )}
- </form>
- </Form>
- </SheetSection>
- <SheetFooter>
- {!isCreating && (
- <div className="flex-1">
- <Button type="danger" onClick={() => onDelete()}>
- Delete hook
- </Button>
- </div>
- )}
- <Button disabled={isUpdatingAuthHooks} type="default" onClick={confirmOnClose}>
- Cancel
- </Button>
- <Button
- form={FORM_ID}
- htmlType="submit"
- disabled={isUpdatingAuthHooks}
- loading={isUpdatingAuthHooks}
- >
- {isCreating ? 'Create hook' : 'Update hook'}
- </Button>
- </SheetFooter>
- </SheetContent>
- <DiscardChangesConfirmationDialog {...discardChangesModalProps} />
- </Sheet>
- )
- }
|