mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 11:30:17 +08:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? UI / design-system consistency (accessibility). ## What is the current behavior? Keyboard focus rings are inconsistent across Studio and `packages/ui`: - Custom Button uses thick `outline` with per-variant colours (brand / grey / destructive / warning) - Form controls use muted grey rings (`ring-background-control`) - Tabs / NavMenu / Radio use soft brand `ring-ring` - Studio `.inset-focus` uses dark green `outline-brand-600` Related: [DEPR-354](https://linear.app/supabase/issue/DEPR-354). ## What is the new behavior? One shared focus recipe, exposed as Tailwind `@utility` classes in `packages/config/css/utilities.css`: | Utility | Use when | | --- | --- | | `focus-ring` | Buttons, inputs, most controls (offset ring) | | `focus-inset` | Dense/flush surfaces such as interactive table rows (renamed from `inset-focus`) | ```txt # focus-ring outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ``` Applied on Button, shadcn form controls, Menu/NavMenu, Command palette trigger, Studio table rows, and related call sites. Documented in the design-system accessibility docs. Variants do not change focus ring colour. When the ring must appear on a different element than the focused one (e.g. Menu + ProductMenu `Link` via `group-focus-visible`, or InputGroup via `:has()`), keep an explicit ring stack. The utilities bake in `:focus-visible` on the same element. ## Additional context **Out of scope** - Full `packages/ui` / Studio / www sweep - Legacy Studio form-group green box-shadow cleanup - ESLint rule for bare `outline-none` ## Test plan Prefer Safari (“hard mode” for `tabIndex`). Expect one soft brand ring everywhere: not grey, not solid green outline. ### Design system - [ ] [Accessibility](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/accessibility): recipe docs match what you see - [ ] [Button](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/components/button): Tab primary / default / danger; same ring colour - [ ] [Table → Row-level navigation](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/components/table#row-level-navigation): Tab an interactive row; inset outline (`focus-inset`) sits inside the row ### Studio - [ ] **Org home → table view** (`/organizations/_` or org projects): switch to the table layout, Tab onto a project row; inset outline sits inside the row (list/card view uses CardButton, not `focus-inset`) - [ ] **Project sidebar** (Database, Auth, Storage, …): Tab the main product nav links; ring follows the focused item (not the nested section menus like Tables / Roles) - [ ] **Storage → Files**: Tab a bucket row; same inset outline as org table rows - [ ] **Project Settings → General** (or Compute and Disk): Tab through inputs, checkboxes, switches, selects; same offset ring, no ring on mouse click - [ ] **Header ⌘K** (desktop width): Tab to the search control after Feedback; same soft brand `focus-ring` (was a thicker `ring-border-strong` before) - [ ] **Table Editor or SQL Editor tabs**: focus a tab, Tab to × if active; close shows a ring - [ ] **Light + dark**: ring stays visible against both backgrounds
240 lines
8.3 KiB
TypeScript
240 lines
8.3 KiB
TypeScript
import { ArrowRight, Check, ExternalLink, Lightbulb, X } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import { useMemo } from 'react'
|
|
import {
|
|
Button,
|
|
Button_Shadcn_,
|
|
Card,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from 'ui'
|
|
import { PageContainer } from 'ui-patterns/PageContainer'
|
|
import {
|
|
PageHeader,
|
|
PageHeaderAside,
|
|
PageHeaderDescription,
|
|
PageHeaderIcon,
|
|
PageHeaderMeta,
|
|
PageHeaderSummary,
|
|
PageHeaderTitle,
|
|
} from 'ui-patterns/PageHeader'
|
|
import {
|
|
PageSection,
|
|
PageSectionAside,
|
|
PageSectionContent,
|
|
PageSectionMeta,
|
|
PageSectionSummary,
|
|
PageSectionTitle,
|
|
} from 'ui-patterns/PageSection'
|
|
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import type {
|
|
ProjectSecurityActionDetails,
|
|
ProjectSecurityActionType,
|
|
ProjectSecurityTable,
|
|
} from './ProjectNeedsSecuring.types'
|
|
import {
|
|
buildSecurityPromptMarkdown,
|
|
formatRlsDescription,
|
|
getTableKey,
|
|
getTablePoliciesHref,
|
|
} from './ProjectNeedsSecuring.utils'
|
|
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
|
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
|
|
import { AlertError } from '@/components/ui/AlertError'
|
|
import { createNavigationHandler } from '@/lib/navigation'
|
|
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
|
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
|
|
|
const StatusCell = ({ enabled, label }: { enabled: boolean; label: string }) => (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
{enabled ? (
|
|
<Check size={14} className="text-brand" aria-hidden="true" />
|
|
) : (
|
|
<X size={14} className="text-destructive" aria-hidden="true" />
|
|
)}
|
|
<span>{label}</span>
|
|
</div>
|
|
)
|
|
|
|
export const ProjectNeedsSecuringView = ({
|
|
projectRef,
|
|
issueCount,
|
|
tables,
|
|
isLoading,
|
|
error,
|
|
onDismiss,
|
|
onTrackAction,
|
|
}: {
|
|
projectRef: string
|
|
issueCount: number
|
|
tables: ProjectSecurityTable[]
|
|
isLoading: boolean
|
|
error?: { message: string } | null
|
|
onDismiss: () => void
|
|
onTrackAction: (type: ProjectSecurityActionType, details?: ProjectSecurityActionDetails) => void
|
|
}) => {
|
|
const router = useRouter()
|
|
const aiSnap = useAiAssistantStateSnapshot()
|
|
const { openSidebar } = useSidebarManagerSnapshot()
|
|
|
|
const promptMarkdown = useMemo(
|
|
() => buildSecurityPromptMarkdown(issueCount, tables),
|
|
[issueCount, tables]
|
|
)
|
|
|
|
const handleOpenAssistant = () => {
|
|
onTrackAction('ask_assistant')
|
|
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
|
|
aiSnap.newChat({
|
|
name: 'Review project security',
|
|
initialInput: promptMarkdown,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
|
<PageHeader size="default">
|
|
<PageHeaderMeta>
|
|
<PageHeaderIcon>
|
|
<div className="shrink-0 w-14 h-14 relative bg-destructive-200 border border-destructive-400 rounded-md flex items-center justify-center">
|
|
<Lightbulb size={20} strokeWidth={1.5} className="text-destructive" />
|
|
</div>
|
|
</PageHeaderIcon>
|
|
<PageHeaderSummary>
|
|
<PageHeaderTitle>Your project needs securing</PageHeaderTitle>
|
|
<PageHeaderDescription>{formatRlsDescription(issueCount)}</PageHeaderDescription>
|
|
</PageHeaderSummary>
|
|
<PageHeaderAside>
|
|
<Button asChild variant="text" iconRight={<ArrowRight />}>
|
|
<Link
|
|
href={`/project/${projectRef}`}
|
|
onClick={() => {
|
|
onTrackAction('skip_to_home')
|
|
onDismiss()
|
|
}}
|
|
>
|
|
Skip to home
|
|
</Link>
|
|
</Button>
|
|
</PageHeaderAside>
|
|
</PageHeaderMeta>
|
|
</PageHeader>
|
|
|
|
<PageContainer size="default" className="pb-12">
|
|
<PageSection>
|
|
<PageSectionMeta>
|
|
<PageSectionSummary>
|
|
<PageSectionTitle>Review and fix</PageSectionTitle>
|
|
</PageSectionSummary>
|
|
<PageSectionAside>
|
|
<AiAssistantDropdown
|
|
label="Ask Assistant"
|
|
size="tiny"
|
|
buildPrompt={() => promptMarkdown}
|
|
onOpenAssistant={handleOpenAssistant}
|
|
onCopyPrompt={() => onTrackAction('copy_prompt')}
|
|
copyLabel="Copy Markdown"
|
|
disabled={isLoading}
|
|
/>
|
|
</PageSectionAside>
|
|
</PageSectionMeta>
|
|
<PageSectionContent>
|
|
{isLoading ? (
|
|
<GenericSkeletonLoader />
|
|
) : error ? (
|
|
<AlertError
|
|
projectRef={projectRef}
|
|
error={error}
|
|
subject="Failed to retrieve project tables"
|
|
/>
|
|
) : (
|
|
<Card>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Schema</TableHead>
|
|
<TableHead>
|
|
<div className="flex items-center gap-1.5">
|
|
<span>Accessible via Data API</span>
|
|
<Button_Shadcn_ asChild variant="ghost" size="icon" className="h-6 w-6">
|
|
<Link
|
|
href={`/project/${projectRef}/integrations/data_api/settings`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
aria-label="Open Data API settings"
|
|
>
|
|
<ExternalLink size={14} aria-hidden="true" />
|
|
</Link>
|
|
</Button_Shadcn_>
|
|
</div>
|
|
</TableHead>
|
|
<TableHead>RLS</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{tables.map((table) => {
|
|
const policiesHref = getTablePoliciesHref(
|
|
projectRef,
|
|
table.schema,
|
|
table.name
|
|
)
|
|
const handleNavigation = createNavigationHandler(policiesHref, router)
|
|
const trackViewPolicies = () =>
|
|
onTrackAction('view_policies', {
|
|
schema: table.schema,
|
|
tableName: table.name,
|
|
})
|
|
|
|
return (
|
|
<TableRow
|
|
key={getTableKey(table)}
|
|
className="relative cursor-pointer focus-inset"
|
|
onClick={(event) => {
|
|
trackViewPolicies()
|
|
handleNavigation(event)
|
|
}}
|
|
onAuxClick={(event) => {
|
|
if (event.button === 1) trackViewPolicies()
|
|
handleNavigation(event)
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') trackViewPolicies()
|
|
handleNavigation(event)
|
|
}}
|
|
tabIndex={0}
|
|
>
|
|
<TableCell className="font-medium">{table.name}</TableCell>
|
|
<TableCell>{table.schema}</TableCell>
|
|
<TableCell>
|
|
<StatusCell
|
|
enabled={table.dataApiAccessible}
|
|
label={table.dataApiAccessible ? 'Accessible' : 'Not accessible'}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<StatusCell
|
|
enabled={table.rlsEnabled}
|
|
label={table.rlsEnabled ? 'Enabled' : 'Disabled'}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</Card>
|
|
)}
|
|
</PageSectionContent>
|
|
</PageSection>
|
|
</PageContainer>
|
|
</div>
|
|
)
|
|
}
|