UnsafeEntitiesConfirmModal.tsx173 lines · main
1import { ChevronRight, ChevronUp } from 'lucide-react'
2import { useMemo, useState } from 'react'
3import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
4import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
5
6import { type ExposedEntity } from './DataApiEnableSwitch.utils'
7
8interface UnsafeEntitiesConfirmModalProps {
9 visible: boolean
10 loading: boolean
11 unsafeEntities: Array<ExposedEntity>
12 onCancel: () => void
13 onConfirm: () => void
14}
15
16const ENTITY_TYPE_META: Record<
17 ExposedEntity['type'],
18 { heading: string; recommendation: string; docsUrl: string }
19> = {
20 table: {
21 heading: 'Tables without Row Level Security',
22 recommendation: 'Enable RLS on these tables to control access per-row.',
23 docsUrl:
24 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public',
25 },
26 'foreign table': {
27 heading: 'Foreign tables',
28 recommendation:
29 'Foreign tables do not support RLS. Revoke access from the anon and authenticated roles.',
30 docsUrl:
31 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api',
32 },
33 'materialized view': {
34 heading: 'Materialized views',
35 recommendation:
36 'Materialized views do not support RLS. Revoke access from the anon and authenticated roles.',
37 docsUrl:
38 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api',
39 },
40 view: {
41 heading: 'Views without SECURITY INVOKER',
42 recommendation:
43 'These views run with the permissions of the view creator, not the querying user. Set SECURITY INVOKER to enforce caller permissions.',
44 docsUrl:
45 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view',
46 },
47}
48
49const ENTITY_TYPE_ORDER: Array<ExposedEntity['type']> = [
50 'table',
51 'foreign table',
52 'materialized view',
53 'view',
54]
55
56const COLLAPSE_THRESHOLD = 3
57
58export const UnsafeEntitiesConfirmModal = ({
59 visible,
60 loading,
61 unsafeEntities,
62 onCancel,
63 onConfirm,
64}: UnsafeEntitiesConfirmModalProps) => {
65 const groupedEntities = useMemo(() => {
66 const groups = new Map<ExposedEntity['type'], Array<ExposedEntity>>()
67 for (const entity of unsafeEntities) {
68 const group = groups.get(entity.type)
69 if (group) {
70 group.push(entity)
71 } else {
72 groups.set(entity.type, [entity])
73 }
74 }
75 return ENTITY_TYPE_ORDER.filter((type) => groups.has(type)).map((type) => ({
76 type,
77 ...ENTITY_TYPE_META[type],
78 entities: groups.get(type) ?? [],
79 }))
80 }, [unsafeEntities])
81
82 return (
83 <ConfirmationModal
84 variant="warning"
85 visible={visible}
86 loading={loading}
87 title="Insecure objects detected"
88 confirmLabel="Enable Data API"
89 confirmLabelLoading="Enabling"
90 onCancel={onCancel}
91 onConfirm={onConfirm}
92 className="max-h-[50vh] overflow-y-auto"
93 >
94 <div className="text-sm text-foreground-light space-y-4">
95 <p>
96 The following objects will be publicly accessible through the Data API and are insecure.
97 </p>
98 {groupedEntities.map(({ type, heading, recommendation, docsUrl, entities }) => (
99 <div key={type} className="space-y-1">
100 <h4 className="text-foreground font-medium">{heading}</h4>
101 <EntityList entities={entities} />
102 <p className="text-foreground-lighter text-xs">
103 {recommendation}{' '}
104 <a
105 href={docsUrl}
106 target="_blank"
107 rel="noopener noreferrer"
108 className="underline hover:text-foreground"
109 >
110 Learn more
111 </a>
112 </p>
113 </div>
114 ))}
115 </div>
116 </ConfirmationModal>
117 )
118}
119
120const EntityListItem = ({ entity }: { entity: ExposedEntity }) => (
121 <li>
122 <code className="text-xs">
123 {entity.schema}.{entity.name}
124 </code>
125 </li>
126)
127
128const EntityList = ({ entities }: { entities: Array<ExposedEntity> }) => {
129 const [open, setOpen] = useState(false)
130
131 const shouldCollapse = entities.length > COLLAPSE_THRESHOLD
132 const visibleEntities = entities.slice(0, COLLAPSE_THRESHOLD)
133 const hiddenEntities = entities.slice(COLLAPSE_THRESHOLD)
134
135 if (!shouldCollapse) {
136 return (
137 <ul className="list-disc pl-5 space-y-0.5">
138 {entities.map((entity) => (
139 <EntityListItem key={`${entity.schema}.${entity.name}`} entity={entity} />
140 ))}
141 </ul>
142 )
143 }
144
145 return (
146 <Collapsible open={open} onOpenChange={setOpen}>
147 <ul className="list-disc pl-5 space-y-0.5">
148 {visibleEntities.map((entity) => (
149 <EntityListItem key={`${entity.schema}.${entity.name}`} entity={entity} />
150 ))}
151 </ul>
152 <CollapsibleContent className="transition-all data-closed:animate-collapsible-up data-open:animate-collapsible-down">
153 <ul className="list-disc pl-5 space-y-0.5">
154 {hiddenEntities.map((entity) => (
155 <EntityListItem key={`${entity.schema}.${entity.name}`} entity={entity} />
156 ))}
157 </ul>
158 </CollapsibleContent>
159 <CollapsibleTrigger asChild>
160 <Button
161 type="text"
162 size="tiny"
163 className="px-0 h-auto text-xs text-foreground-lighter hover:text-foreground"
164 >
165 <div className="flex items-center gap-1">
166 {open ? <ChevronUp size={12} /> : <ChevronRight size={12} />}
167 <span>{open ? 'Show less' : `Show ${hiddenEntities.length} more`}</span>
168 </div>
169 </Button>
170 </CollapsibleTrigger>
171 </Collapsible>
172 )
173}