useAdvisorSignals.ts106 lines · main
1import { useCallback, useEffect, useMemo } from 'react'
2
3import type { AdvisorSignalItem } from './AdvisorPanel.types'
4import { useBannedIPsQuery } from '@/data/banned-ips/banned-ips-query'
5import type { IPData } from '@/data/banned-ips/banned-ips-query'
6import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
7
8const createDismissalStorageKey = (projectRef: string) => `advisor-signal-dismissals:${projectRef}`
9
10const createBannedIPDismissalKey = (ip: string) => `signal:banned-ip:${ip}:v1`
11
12const createBannedIPSignalItems = ({
13 projectRef,
14 bannedIPsData,
15}: {
16 projectRef?: string
17 bannedIPsData?: IPData
18}): AdvisorSignalItem[] => {
19 if (!projectRef) return []
20
21 const bannedIPs = bannedIPsData?.banned_ipv4_addresses ?? []
22
23 return bannedIPs.map((ip) => ({
24 id: createBannedIPDismissalKey(ip),
25 dismissalKey: createBannedIPDismissalKey(ip),
26 source: 'signal' as const,
27 type: 'banned-ip' as const,
28 severity: 'warning' as const,
29 tab: 'security' as const,
30 title: 'Banned IP address',
31 summary: `The IP address \`${ip}\` is temporarily blocked because of suspicious traffic or repeated failed password attempts.`,
32 description:
33 'This IP address is temporarily blocked because of suspicious traffic or repeated failed password attempts. If this block is expected, you can dismiss this signal or remove the ban.',
34 docsUrl: 'https://supabase.com/docs/reference/cli/supabase-network-bans',
35 actions: [
36 {
37 label: 'Edit network bans',
38 href: `/project/${projectRef}/database/settings#banned-ips`,
39 },
40 ],
41 sourceData: { type: 'banned-ip' as const, ip },
42 }))
43}
44
45interface UseAdvisorSignalsOptions {
46 projectRef?: string
47 enabled?: boolean
48}
49
50export const useAdvisorSignals = ({ projectRef, enabled = true }: UseAdvisorSignalsOptions) => {
51 const { data, isPending, isError } = useBannedIPsQuery({ projectRef }, { enabled })
52
53 const storageKey = projectRef
54 ? createDismissalStorageKey(projectRef)
55 : 'advisor-signal-dismissals:unknown-project'
56
57 const [dismissedKeys, setDismissedKeys] = useLocalStorageQuery<string[]>(storageKey, [])
58
59 const dismissedKeySet = useMemo(() => new Set(dismissedKeys), [dismissedKeys])
60
61 const dismissSignal = useCallback(
62 (dismissalKey: string) => {
63 setDismissedKeys((current) =>
64 current.includes(dismissalKey) ? current : [...current, dismissalKey]
65 )
66 },
67 [setDismissedKeys]
68 )
69
70 const signalItems = useMemo(
71 () => createBannedIPSignalItems({ projectRef, bannedIPsData: data }),
72 [projectRef, data]
73 )
74
75 // Prune stale dismissals when the active signal list changes (e.g. an IP was unbanned).
76 // Only call the setter when pruning would actually change something — otherwise we
77 // churn subscribers unnecessarily, which can cause feedback loops when this hook is
78 // mounted in more than one place (AdvisorSection + AdvisorPanel).
79 useEffect(() => {
80 if (!data) return
81
82 const hasStaleBannedIPDismissal = dismissedKeys.some(
83 (key) =>
84 key.startsWith('signal:banned-ip:') &&
85 !signalItems.some((item) => item.dismissalKey === key)
86 )
87 if (!hasStaleBannedIPDismissal) return
88
89 const activeKeys = new Set(signalItems.map((item) => item.dismissalKey))
90 setDismissedKeys((current) =>
91 current.filter((key) => (key.startsWith('signal:banned-ip:') ? activeKeys.has(key) : true))
92 )
93 }, [data, signalItems, dismissedKeys, setDismissedKeys])
94
95 const formattedData = useMemo(
96 () => signalItems.filter((item) => !dismissedKeySet.has(item.dismissalKey)),
97 [signalItems, dismissedKeySet]
98 )
99
100 return {
101 data: formattedData,
102 dismissSignal,
103 isPending,
104 isError,
105 }
106}