ProjectNeedsSecuringView.tsx239 lines · main
| 1 | import { ArrowRight, Check, ExternalLink, Lightbulb, X } from 'lucide-react' |
| 2 | import Link from 'next/link' |
| 3 | import { useRouter } from 'next/router' |
| 4 | import { useMemo } from 'react' |
| 5 | import { |
| 6 | Button, |
| 7 | Button_Shadcn_, |
| 8 | Card, |
| 9 | Table, |
| 10 | TableBody, |
| 11 | TableCell, |
| 12 | TableHead, |
| 13 | TableHeader, |
| 14 | TableRow, |
| 15 | } from 'ui' |
| 16 | import { PageContainer } from 'ui-patterns/PageContainer' |
| 17 | import { |
| 18 | PageHeader, |
| 19 | PageHeaderAside, |
| 20 | PageHeaderDescription, |
| 21 | PageHeaderIcon, |
| 22 | PageHeaderMeta, |
| 23 | PageHeaderSummary, |
| 24 | PageHeaderTitle, |
| 25 | } from 'ui-patterns/PageHeader' |
| 26 | import { |
| 27 | PageSection, |
| 28 | PageSectionAside, |
| 29 | PageSectionContent, |
| 30 | PageSectionMeta, |
| 31 | PageSectionSummary, |
| 32 | PageSectionTitle, |
| 33 | } from 'ui-patterns/PageSection' |
| 34 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 35 | |
| 36 | import type { |
| 37 | ProjectSecurityActionDetails, |
| 38 | ProjectSecurityActionType, |
| 39 | ProjectSecurityTable, |
| 40 | } from './ProjectNeedsSecuring.types' |
| 41 | import { |
| 42 | buildSecurityPromptMarkdown, |
| 43 | formatRlsDescription, |
| 44 | getTableKey, |
| 45 | getTablePoliciesHref, |
| 46 | } from './ProjectNeedsSecuring.utils' |
| 47 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 48 | import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' |
| 49 | import AlertError from '@/components/ui/AlertError' |
| 50 | import { createNavigationHandler } from '@/lib/navigation' |
| 51 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 52 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 53 | |
| 54 | const StatusCell = ({ enabled, label }: { enabled: boolean; label: string }) => ( |
| 55 | <div className="flex items-center gap-2 text-sm"> |
| 56 | {enabled ? ( |
| 57 | <Check size={14} className="text-brand" aria-hidden="true" /> |
| 58 | ) : ( |
| 59 | <X size={14} className="text-destructive" aria-hidden="true" /> |
| 60 | )} |
| 61 | <span>{label}</span> |
| 62 | </div> |
| 63 | ) |
| 64 | |
| 65 | export const ProjectNeedsSecuringView = ({ |
| 66 | projectRef, |
| 67 | issueCount, |
| 68 | tables, |
| 69 | isLoading, |
| 70 | error, |
| 71 | onDismiss, |
| 72 | onTrackAction, |
| 73 | }: { |
| 74 | projectRef: string |
| 75 | issueCount: number |
| 76 | tables: ProjectSecurityTable[] |
| 77 | isLoading: boolean |
| 78 | error?: { message: string } | null |
| 79 | onDismiss: () => void |
| 80 | onTrackAction: (type: ProjectSecurityActionType, details?: ProjectSecurityActionDetails) => void |
| 81 | }) => { |
| 82 | const router = useRouter() |
| 83 | const aiSnap = useAiAssistantStateSnapshot() |
| 84 | const { openSidebar } = useSidebarManagerSnapshot() |
| 85 | |
| 86 | const promptMarkdown = useMemo( |
| 87 | () => buildSecurityPromptMarkdown(issueCount, tables), |
| 88 | [issueCount, tables] |
| 89 | ) |
| 90 | |
| 91 | const handleOpenAssistant = () => { |
| 92 | onTrackAction('ask_assistant') |
| 93 | openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) |
| 94 | aiSnap.newChat({ |
| 95 | name: 'Review project security', |
| 96 | initialInput: promptMarkdown, |
| 97 | }) |
| 98 | } |
| 99 | |
| 100 | return ( |
| 101 | <div className="flex flex-1 flex-col overflow-y-auto"> |
| 102 | <PageHeader size="default"> |
| 103 | <PageHeaderMeta> |
| 104 | <PageHeaderIcon> |
| 105 | <div className="shrink-0 w-14 h-14 relative bg-destructive-200 border border-destructive-400 rounded-md flex items-center justify-center"> |
| 106 | <Lightbulb size={20} strokeWidth={1.5} className="text-destructive" /> |
| 107 | </div> |
| 108 | </PageHeaderIcon> |
| 109 | <PageHeaderSummary> |
| 110 | <PageHeaderTitle>Your project needs securing</PageHeaderTitle> |
| 111 | <PageHeaderDescription>{formatRlsDescription(issueCount)}</PageHeaderDescription> |
| 112 | </PageHeaderSummary> |
| 113 | <PageHeaderAside> |
| 114 | <Button asChild type="text" iconRight={<ArrowRight />}> |
| 115 | <Link |
| 116 | href={`/project/${projectRef}`} |
| 117 | onClick={() => { |
| 118 | onTrackAction('skip_to_home') |
| 119 | onDismiss() |
| 120 | }} |
| 121 | > |
| 122 | Skip to home |
| 123 | </Link> |
| 124 | </Button> |
| 125 | </PageHeaderAside> |
| 126 | </PageHeaderMeta> |
| 127 | </PageHeader> |
| 128 | |
| 129 | <PageContainer size="default" className="pb-12"> |
| 130 | <PageSection> |
| 131 | <PageSectionMeta> |
| 132 | <PageSectionSummary> |
| 133 | <PageSectionTitle>Review and fix</PageSectionTitle> |
| 134 | </PageSectionSummary> |
| 135 | <PageSectionAside> |
| 136 | <AiAssistantDropdown |
| 137 | label="Ask Assistant" |
| 138 | size="tiny" |
| 139 | buildPrompt={() => promptMarkdown} |
| 140 | onOpenAssistant={handleOpenAssistant} |
| 141 | onCopyPrompt={() => onTrackAction('copy_prompt')} |
| 142 | copyLabel="Copy Markdown" |
| 143 | disabled={isLoading} |
| 144 | /> |
| 145 | </PageSectionAside> |
| 146 | </PageSectionMeta> |
| 147 | <PageSectionContent> |
| 148 | {isLoading ? ( |
| 149 | <GenericSkeletonLoader /> |
| 150 | ) : error ? ( |
| 151 | <AlertError |
| 152 | projectRef={projectRef} |
| 153 | error={error} |
| 154 | subject="Failed to retrieve project tables" |
| 155 | /> |
| 156 | ) : ( |
| 157 | <Card> |
| 158 | <Table> |
| 159 | <TableHeader> |
| 160 | <TableRow> |
| 161 | <TableHead>Name</TableHead> |
| 162 | <TableHead>Schema</TableHead> |
| 163 | <TableHead> |
| 164 | <div className="flex items-center gap-1.5"> |
| 165 | <span>Accessible via Data API</span> |
| 166 | <Button_Shadcn_ asChild variant="ghost" size="icon" className="h-6 w-6"> |
| 167 | <Link |
| 168 | href={`/project/${projectRef}/integrations/data_api/settings`} |
| 169 | target="_blank" |
| 170 | rel="noreferrer" |
| 171 | aria-label="Open Data API settings" |
| 172 | > |
| 173 | <ExternalLink size={14} aria-hidden="true" /> |
| 174 | </Link> |
| 175 | </Button_Shadcn_> |
| 176 | </div> |
| 177 | </TableHead> |
| 178 | <TableHead>RLS</TableHead> |
| 179 | </TableRow> |
| 180 | </TableHeader> |
| 181 | <TableBody> |
| 182 | {tables.map((table) => { |
| 183 | const policiesHref = getTablePoliciesHref( |
| 184 | projectRef, |
| 185 | table.schema, |
| 186 | table.name |
| 187 | ) |
| 188 | const handleNavigation = createNavigationHandler(policiesHref, router) |
| 189 | const trackViewPolicies = () => |
| 190 | onTrackAction('view_policies', { |
| 191 | schema: table.schema, |
| 192 | tableName: table.name, |
| 193 | }) |
| 194 | |
| 195 | return ( |
| 196 | <TableRow |
| 197 | key={getTableKey(table)} |
| 198 | className="relative cursor-pointer inset-focus" |
| 199 | onClick={(event) => { |
| 200 | trackViewPolicies() |
| 201 | handleNavigation(event) |
| 202 | }} |
| 203 | onAuxClick={(event) => { |
| 204 | if (event.button === 1) trackViewPolicies() |
| 205 | handleNavigation(event) |
| 206 | }} |
| 207 | onKeyDown={(event) => { |
| 208 | if (event.key === 'Enter' || event.key === ' ') trackViewPolicies() |
| 209 | handleNavigation(event) |
| 210 | }} |
| 211 | tabIndex={0} |
| 212 | > |
| 213 | <TableCell className="font-medium">{table.name}</TableCell> |
| 214 | <TableCell>{table.schema}</TableCell> |
| 215 | <TableCell> |
| 216 | <StatusCell |
| 217 | enabled={table.dataApiAccessible} |
| 218 | label={table.dataApiAccessible ? 'Accessible' : 'Not accessible'} |
| 219 | /> |
| 220 | </TableCell> |
| 221 | <TableCell> |
| 222 | <StatusCell |
| 223 | enabled={table.rlsEnabled} |
| 224 | label={table.rlsEnabled ? 'Enabled' : 'Disabled'} |
| 225 | /> |
| 226 | </TableCell> |
| 227 | </TableRow> |
| 228 | ) |
| 229 | })} |
| 230 | </TableBody> |
| 231 | </Table> |
| 232 | </Card> |
| 233 | )} |
| 234 | </PageSectionContent> |
| 235 | </PageSection> |
| 236 | </PageContainer> |
| 237 | </div> |
| 238 | ) |
| 239 | } |