HooksListing.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import { joinSqlFragments } from '@supabase/pg-meta/src/pg-format'
  2. import { useParams } from 'common'
  3. import { parseAsString, useQueryState } from 'nuqs'
  4. import { useEffect, useState } from 'react'
  5. import { toast } from 'sonner'
  6. import { cn } from 'ui'
  7. import { EmptyStatePresentational, GenericSkeletonLoader } from 'ui-patterns'
  8. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  9. import {
  10. PageSection,
  11. PageSectionAside,
  12. PageSectionContent,
  13. PageSectionMeta,
  14. PageSectionSummary,
  15. PageSectionTitle,
  16. } from 'ui-patterns/PageSection'
  17. import { AddHookDropdown } from './AddHookDropdown'
  18. import { CreateHookSheet } from './CreateHookSheet'
  19. import { HookCard } from './HookCard'
  20. import { Hook, HOOKS_DEFINITIONS } from './hooks.constants'
  21. import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils'
  22. import AlertError from '@/components/ui/AlertError'
  23. import CodeEditor from '@/components/ui/CodeEditor/CodeEditor'
  24. import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
  25. import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation'
  26. import { executeSql } from '@/data/sql/execute-sql-query'
  27. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  28. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  29. import { useShortcut } from '@/state/shortcuts/useShortcut'
  30. export const HooksListing = () => {
  31. const { ref: projectRef } = useParams()
  32. const { data: project } = useSelectedProjectQuery()
  33. const {
  34. data: authConfig,
  35. error: authConfigError,
  36. isError,
  37. isPending: isLoading,
  38. } = useAuthConfigQuery({ projectRef })
  39. const [hook, setHook] = useQueryState('hook', parseAsString)
  40. const [selectedHookForDeletion, setSelectedHookForDeletion] = useState<Hook | null>(null)
  41. const [addHookAsideOpen, setAddHookAsideOpen] = useState(false)
  42. const [addHookEmptyOpen, setAddHookEmptyOpen] = useState(false)
  43. const { mutate: updateAuthHooks, isPending: isDeletingAuthHook } = useAuthHooksUpdateMutation({
  44. onSuccess: async () => {
  45. if (!selectedHookForDeletion) return
  46. const { method } = selectedHookForDeletion
  47. if (method.type === 'postgres') {
  48. const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName)
  49. await executeSql({
  50. projectRef,
  51. connectionString: project!.connectionString,
  52. sql: joinSqlFragments(revokeStatements, '\n'),
  53. })
  54. }
  55. toast.success(`${selectedHookForDeletion.title} has been deleted.`)
  56. setSelectedHookForDeletion(null)
  57. setHook(null)
  58. },
  59. onError: (error) => {
  60. toast.error(`Failed to delete hook: ${error.message}`)
  61. },
  62. })
  63. const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => {
  64. return {
  65. ...definition,
  66. enabled: authConfig?.[definition.enabledKey] || false,
  67. method: extractMethod(
  68. authConfig?.[definition.uriKey] || '',
  69. authConfig?.[definition.secretsKey] || ''
  70. ),
  71. }
  72. })
  73. const validHooks = hooks.filter((h) => isValidHook(h))
  74. const hasValidHooks = validHooks.length > 0
  75. useShortcut(
  76. SHORTCUT_IDS.LIST_PAGE_NEW_ITEM,
  77. () => (hasValidHooks ? setAddHookAsideOpen(true) : setAddHookEmptyOpen(true)),
  78. { label: 'Add hook' }
  79. )
  80. const selectedHook = hooks.find((h) => h.id === hook)
  81. useEffect(() => {
  82. if (!!hook && !selectedHook) {
  83. toast('Hook not found')
  84. setHook(null)
  85. }
  86. }, [hook, selectedHook, setHook])
  87. if (isError) {
  88. return (
  89. <PageSection>
  90. <PageSectionContent>
  91. <AlertError
  92. error={authConfigError}
  93. subject="Failed to retrieve auth configuration for hooks"
  94. />
  95. </PageSectionContent>
  96. </PageSection>
  97. )
  98. }
  99. if (isLoading) {
  100. return (
  101. <PageSection>
  102. <PageSectionContent>
  103. <GenericSkeletonLoader />
  104. </PageSectionContent>
  105. </PageSection>
  106. )
  107. }
  108. return (
  109. <PageSection>
  110. <PageSectionMeta>
  111. <PageSectionSummary>
  112. <PageSectionTitle>Hooks</PageSectionTitle>
  113. </PageSectionSummary>
  114. <PageSectionAside>
  115. <AddHookDropdown
  116. open={addHookAsideOpen}
  117. onOpenChange={setAddHookAsideOpen}
  118. onSelectHook={(title) => {
  119. const hook = hooks.find((h) => h.title === title)
  120. if (hook) setHook(hook.id)
  121. }}
  122. />
  123. </PageSectionAside>
  124. </PageSectionMeta>
  125. <PageSectionContent>
  126. {!hasValidHooks && (
  127. <EmptyStatePresentational
  128. title="Create an auth hook"
  129. description="Use Postgres functions or HTTP endpoints to customize your authentication flow."
  130. >
  131. <AddHookDropdown
  132. type="default"
  133. align="center"
  134. buttonText="Add a new hook"
  135. open={addHookEmptyOpen}
  136. onOpenChange={setAddHookEmptyOpen}
  137. onSelectHook={(title) => {
  138. const hook = hooks.find((h) => h.title === title)
  139. if (hook) setHook(hook.id)
  140. }}
  141. />
  142. </EmptyStatePresentational>
  143. )}
  144. <div className="-space-y-px">
  145. {validHooks.map((hook) => {
  146. return <HookCard key={hook.enabledKey} hook={hook} onSelect={() => setHook(hook.id)} />
  147. })}
  148. </div>
  149. <CreateHookSheet
  150. title={selectedHook?.title ?? null}
  151. visible={!!selectedHook}
  152. onDelete={() => {
  153. const hook = hooks.find((h) => h.title === selectedHook?.title)
  154. if (hook) setSelectedHookForDeletion(hook)
  155. }}
  156. onClose={() => setHook(null)}
  157. authConfig={authConfig!}
  158. />
  159. <ConfirmationModal
  160. visible={!!selectedHookForDeletion}
  161. size="large"
  162. variant="destructive"
  163. loading={isDeletingAuthHook}
  164. title={`Confirm to delete ${selectedHookForDeletion?.title}`}
  165. className={cn('md:px-0', selectedHookForDeletion?.method.type === 'postgres' && 'pb-0')}
  166. confirmLabel="Delete"
  167. confirmLabelLoading="Deleting"
  168. onCancel={() => setSelectedHookForDeletion(null)}
  169. onConfirm={() => {
  170. if (!selectedHookForDeletion) return
  171. updateAuthHooks({
  172. projectRef: projectRef!,
  173. config: {
  174. [selectedHookForDeletion.enabledKey]: false,
  175. [selectedHookForDeletion.uriKey]: null,
  176. [selectedHookForDeletion.secretsKey]: null,
  177. },
  178. })
  179. }}
  180. >
  181. <div>
  182. <p className="md:px-5 text-sm text-foreground-light">
  183. Are you sure you want to delete the {selectedHookForDeletion?.title}?
  184. </p>
  185. {selectedHookForDeletion?.method.type === 'postgres' && (
  186. <>
  187. <p className="md:px-5 text-sm text-foreground-light">
  188. The following statements will be executed on the{' '}
  189. {selectedHookForDeletion?.method.schema}.
  190. {selectedHookForDeletion?.method.functionName} function:
  191. </p>
  192. <div className="mt-4 h-72">
  193. <CodeEditor
  194. isReadOnly
  195. id="deletion-hook-editor"
  196. language="pgsql"
  197. value={getRevokePermissionStatements(
  198. selectedHookForDeletion?.method.schema,
  199. selectedHookForDeletion?.method.functionName
  200. ).join('\n\n')}
  201. />
  202. </div>
  203. </>
  204. )}
  205. </div>
  206. </ConfirmationModal>
  207. </PageSectionContent>
  208. </PageSection>
  209. )
  210. }