mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03: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? Improving accessibility by adding aria-label. Adding Tooltip for icon only buttons. ## What is the current behavior? Some `aria-label` and `Tooltip` are missing. ## What is the new behavior? Icon-only buttons have now Tooltip and aria-label have been added. ## Additional context No visual changes have been made. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Accessibility Improvements** * Added descriptive tooltips and screen-reader labels to search-clear and close-panel controls. * Added accessible labels and tooltips for managing secrets, showing or hiding secret values, and editing or removing foreign tables. * Improved the lint details panel close control with a descriptive label. * Prevented duplicate tooltip announcements for assistive technologies. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useParams } from 'common'
|
|
import { sortBy } from 'lodash'
|
|
import { RefreshCw, Search, X } from 'lucide-react'
|
|
import { parseAsBoolean, useQueryState } from 'nuqs'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import DataGrid, { Row } from 'react-data-grid'
|
|
import {
|
|
Button,
|
|
cn,
|
|
LoadingLine,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from 'ui'
|
|
import { Input } from 'ui-patterns/DataInputs/Input'
|
|
|
|
import { AddNewSecretModal } from './AddNewSecretModal'
|
|
import { DeleteSecretModal } from './DeleteSecretModal'
|
|
import { EditSecretModal } from './EditSecretModal'
|
|
import { formatSecretColumns } from './Secrets.utils'
|
|
import { AlertError } from '@/components/ui/AlertError'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { DocsButton } from '@/components/ui/DocsButton'
|
|
import { useVaultSecretsQuery } from '@/data/vault/vault-secrets-query'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
import { onSearchInputEscape } from '@/lib/keyboard'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
|
import type { VaultSecret } from '@/types'
|
|
|
|
export const SecretsManagement = () => {
|
|
const { search } = useParams()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
|
|
const [searchValue, setSearchValue] = useState<string>('')
|
|
const [, setShowAddSecretModal] = useQueryState('new', parseAsBoolean.withDefault(false))
|
|
const [selectedSort, setSelectedSort] = useState<'updated_at' | 'name'>('updated_at')
|
|
|
|
const { can: canManageSecrets } = useAsyncCheckPermissions(
|
|
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
|
'tables'
|
|
)
|
|
|
|
const {
|
|
data,
|
|
error,
|
|
isError,
|
|
isPending: isLoading,
|
|
isRefetching,
|
|
refetch,
|
|
} = useVaultSecretsQuery({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
const allSecrets = useMemo(() => data || [], [data])
|
|
|
|
const secrets = useMemo(() => {
|
|
const filtered =
|
|
searchValue.length > 0
|
|
? allSecrets.filter(
|
|
(secret) =>
|
|
(secret?.name ?? '').toLowerCase().includes(searchValue.trim().toLowerCase()) ||
|
|
(secret?.id ?? '').toLowerCase().includes(searchValue.trim().toLowerCase())
|
|
)
|
|
: allSecrets
|
|
|
|
if (selectedSort === 'updated_at') {
|
|
return sortBy(filtered, (s) => Number(new Date(s.updated_at))).reverse()
|
|
}
|
|
return sortBy(filtered, (s) => (s.name || '').toLowerCase())
|
|
}, [allSecrets, searchValue, selectedSort])
|
|
|
|
const columns = useMemo(() => formatSecretColumns(), [])
|
|
|
|
useEffect(() => {
|
|
if (search !== undefined) setSearchValue(search)
|
|
}, [search])
|
|
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
|
() => {
|
|
searchInputRef.current?.focus()
|
|
searchInputRef.current?.select()
|
|
},
|
|
{ label: 'Search secrets' }
|
|
)
|
|
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setSearchValue(''))
|
|
useShortcut(SHORTCUT_IDS.LIST_PAGE_NEW_ITEM, () => setShowAddSecretModal(true), {
|
|
label: 'Add new secret',
|
|
enabled: canManageSecrets,
|
|
})
|
|
|
|
return (
|
|
<>
|
|
<div className="h-full w-full space-y-4">
|
|
<div className="h-full w-full flex flex-col relative">
|
|
<div className="bg-surface-200 py-3 px-10 flex items-center justify-between flex-wrap">
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
ref={searchInputRef}
|
|
size="tiny"
|
|
className="w-52"
|
|
placeholder="Search by name or key ID"
|
|
icon={<Search />}
|
|
value={searchValue ?? ''}
|
|
onChange={(e) => setSearchValue(e.target.value)}
|
|
onKeyDown={onSearchInputEscape(searchValue ?? '', setSearchValue)}
|
|
actions={[
|
|
searchValue && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
key="clear"
|
|
size="tiny"
|
|
variant="text"
|
|
icon={<X />}
|
|
onClick={() => setSearchValue('')}
|
|
className="p-0 h-5 w-5"
|
|
aria-label="Clear search"
|
|
// Tooltip repeats the label; screen readers would read it twice
|
|
aria-describedby={undefined}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Clear search</TooltipContent>
|
|
</Tooltip>
|
|
),
|
|
]}
|
|
/>
|
|
|
|
<Select value={selectedSort} onValueChange={(v) => setSelectedSort(v as any)}>
|
|
<SelectTrigger size="tiny" className="w-44">
|
|
<SelectValue asChild>
|
|
<>Sort by {selectedSort}</>
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="updated_at" className="text-xs">
|
|
Updated at
|
|
</SelectItem>
|
|
<SelectItem value="name" className="text-xs">
|
|
Name
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-x-2">
|
|
<Button icon={<RefreshCw />} loading={isRefetching} onClick={() => refetch()}>
|
|
Refresh
|
|
</Button>
|
|
<DocsButton href={`${DOCS_URL}/guides/database/vault`} />
|
|
<ButtonTooltip
|
|
variant="primary"
|
|
disabled={!canManageSecrets}
|
|
onClick={() => setShowAddSecretModal(true)}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
text: !canManageSecrets
|
|
? 'You need additional permissions to add secrets'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
Add new secret
|
|
</ButtonTooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<LoadingLine loading={isLoading || isRefetching} />
|
|
|
|
{isError ? (
|
|
<div className="grow p-4">
|
|
<AlertError error={error} subject="Failed to load secrets" />
|
|
</div>
|
|
) : (
|
|
<DataGrid
|
|
className="grow border-t-0! border-b-0!"
|
|
rowHeight={52}
|
|
headerRowHeight={36}
|
|
columns={columns}
|
|
rows={secrets}
|
|
rowKeyGetter={(row: VaultSecret) => row.id}
|
|
rowClass={() => {
|
|
return cn(
|
|
'cursor-pointer',
|
|
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-hidden [&>.rdg-cell]:shadow-none',
|
|
'[&>.rdg-cell:first-child>div]:pl-8'
|
|
)
|
|
}}
|
|
renderers={{
|
|
renderRow(_, props) {
|
|
return <Row key={(props.row as VaultSecret).id} {...props} />
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{secrets.length === 0 && !isLoading && !isError ? (
|
|
<div className="absolute top-32 px-6 w-full">
|
|
<div className="text-center text-sm flex flex-col gap-y-1">
|
|
<p className="text-foreground">
|
|
{searchValue ? 'No secrets found' : 'No secrets added yet'}
|
|
</p>
|
|
<p className="text-foreground-light">
|
|
{searchValue
|
|
? `There are currently no secrets based on the search "${searchValue}"`
|
|
: 'The Vault allows you to store sensitive information like API keys'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<AddNewSecretModal />
|
|
|
|
<EditSecretModal />
|
|
|
|
<DeleteSecretModal />
|
|
</>
|
|
)
|
|
}
|