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