UnsafeEntitiesConfirmModal.tsx173 lines · main
| 1 | import { ChevronRight, ChevronUp } from 'lucide-react' |
| 2 | import { useMemo, useState } from 'react' |
| 3 | import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' |
| 4 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 5 | |
| 6 | import { type ExposedEntity } from './DataApiEnableSwitch.utils' |
| 7 | |
| 8 | interface UnsafeEntitiesConfirmModalProps { |
| 9 | visible: boolean |
| 10 | loading: boolean |
| 11 | unsafeEntities: Array<ExposedEntity> |
| 12 | onCancel: () => void |
| 13 | onConfirm: () => void |
| 14 | } |
| 15 | |
| 16 | const 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 | |
| 49 | const ENTITY_TYPE_ORDER: Array<ExposedEntity['type']> = [ |
| 50 | 'table', |
| 51 | 'foreign table', |
| 52 | 'materialized view', |
| 53 | 'view', |
| 54 | ] |
| 55 | |
| 56 | const COLLAPSE_THRESHOLD = 3 |
| 57 | |
| 58 | export 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 | |
| 120 | const EntityListItem = ({ entity }: { entity: ExposedEntity }) => ( |
| 121 | <li> |
| 122 | <code className="text-xs"> |
| 123 | {entity.schema}.{entity.name} |
| 124 | </code> |
| 125 | </li> |
| 126 | ) |
| 127 | |
| 128 | const 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 | } |