mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
## Summary
I migrated every `useSendEventMutation` call site in `apps/studio` to
`useTrack`, deleted the legacy hook, and added a lint guardrail so it
can't return. `useTrack` is the type-safe replacement: it auto-injects
`groups: { project, organization }` from the selected project/org and
types `action` + `properties` against `TelemetryEvent`. Existing call
sites built groups manually and were not type-checked at the action
level. The migration covers 81 files (60 trivial swaps, 9 org-only, 3
pre-auth, 5 bespoke, 4 test mocks).
## Changes
- Migrated trivial call sites across `pages/project/[ref]`,
`components/interfaces/*` (Reports, Storage, Realtime/Inspector,
SQLEditor, Functions, EdgeFunctions, Integrations, ProjectAPIDocs,
Branching/BranchManagement, TableGridEditor, Connect, Docs, Auth,
Support, Home, ProjectHome, App), `components/layouts/*`, and
`components/ui/*`.
- Migrated org-only sites (`Organization/Documents/*`,
`Organization/BillingSettings/Subscription/*`,
`Organization/SecuritySettings.tsx`,
`Account/Preferences/DashboardSettingsToggles.tsx`) by dropping the
manual `groups: { organization: ... }` and letting `useTrack`
auto-inject. Verified `useSelectedProjectQuery` is disabled on org
routes (gates on URL `[ref]`).
- Migrated pre-auth sites (`SignInForm.tsx`, `sign-in-mfa.tsx`,
`profile.tsx`) where neither project nor org is resolved.
- Bespoke handling:
- `execute-sql-mutation.ts` and `table-row-create-mutation.ts`: pass `{
project: projectRef }` via `groupOverrides` since the mutation can
target a non-selected project ref.
- `useStudioCommandMenuTelemetry.ts`: kept a direct `sendTelemetryEvent`
call because studio groups must override pre-built event groups
(opposite of `useTrack`'s override direction).
- `AIAssistantOption.tsx`: passes sentinel-aware `groupOverrides` so
`NO_PROJECT_MARKER`/`NO_ORG_MARKER` continue to suppress group emission.
- `SidePanelEditor.utils.tsx`: utility functions `createTable` and
`updateTable` now take a `track: Track` parameter (threaded from
`SidePanelEditor.tsx`); dropped the `organizationSlug` arg since groups
are no longer assembled manually.
- Branch-event attribution: preserved `parentProjectRef` overrides on
`branch_updated`, `branch_merge_completed`, `branch_merge_failed`,
`branch_merge_submitted`, `branch_delete_button_clicked`,
`branch_review_with_assistant_clicked`, and
`branch_*_merge_request_button_clicked`. Original code grouped these
under the parent (production) project, not the branch ref;
auto-injection would have shifted them onto the branch.
- Switched 4 test mocks from `@/data/telemetry/send-event-mutation` to
`@/lib/telemetry/track`. Removed obsolete tests around manual groups and
`try/catch` on telemetry rejection.
- Deleted `apps/studio/data/telemetry/send-event-mutation.ts`. The
deleted module is its own guardrail: any reintroduction of the import
fails at TypeScript module resolution before lint runs.
## Testing
Tested on preview deploy:
- [x] SQL editor `CREATE TABLE` fires `table_created` with method
`sql_editor` and `groups.project` set to the mutation's `projectRef`.
- [x] Table editor creates a table from the side panel; `table_created`
fires from `SidePanelEditor.utils` via threaded `track`.
- [x] Help button (`/project/[ref]/...`) fires `help_button_clicked`
with auto-injected project + org groups.
- [x] Sign-in form fires `sign_in` with empty groups (pre-auth,
expected).
- [x] Org documents page (`/org/[slug]/documents`) fires
`document_view_button_clicked` with org group only, no stale project
ref.
- [x] Command menu (`Cmd+K`) inside a project still fires
`command_menu_opened` with studio's project/org overriding any
event-supplied groups.
- [x] Support form "Ask the Assistant" without selected org fires
`ai_assistant_in_support_form_clicked` with no project/org groups
(sentinels suppress).
- [x] On a branch, "Update branch" / "Merge branch" / "Close merge
request" events fire with `groups.project` set to the parent project
ref, not the branch ref.
Local checks:
- [x] 22/22 tests pass across the 4 updated test files
(`SidePanelEditor.utils.createTable`, `EdgeFunctionRenderer`,
`LayoutSidebar`, `PlanUpdateSidePanel`).
- [x] `rg useSendEventMutation apps/studio` returns 0 hits.
## Linear
- fixes GROWTH-860
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Chores**
* Standardized telemetry across the Studio to a unified tracking system;
events now send simplified payloads with less contextual/grouping data.
* No user-facing flows changed; UI behavior, permissions, and
interactions remain the same.
* **Tests**
* Updated telemetry mocks and tests to align with the new tracking
approach.
<!-- review_stack_entry_start -->
[](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46140?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
400 lines
15 KiB
TypeScript
400 lines
15 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { Realtime } from 'icons'
|
|
import { BookOpenText, Lightbulb, Lock, MoreVertical, PlusCircle, Unlock } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { parseAsBoolean, useQueryState } from 'nuqs'
|
|
import { useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
cn,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from 'ui'
|
|
|
|
import { EnableIndexAdvisorDialog } from '../QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton'
|
|
import { RoleImpersonationPopover } from '../RoleImpersonationSelector/RoleImpersonationPopover'
|
|
import { InsertButton } from './InsertButton'
|
|
import { RealtimeToggleDialog } from './RealtimeToggleDialog'
|
|
import { SecurityDefinerViewPopover } from './SecurityDefinerViewPopover'
|
|
import { ViewEntityAutofixSecurityModal } from './ViewEntityAutofixSecurityModal'
|
|
import { RefreshButton } from '@/components/grid/components/header/RefreshButton'
|
|
import { useTableIndexAdvisor } from '@/components/grid/context/TableIndexAdvisorContext'
|
|
import { RLSToggleDialog } from '@/components/interfaces/Database/RLSToggleDialog'
|
|
import {
|
|
getEntityLintDetails,
|
|
getTablePoliciesUrl,
|
|
} from '@/components/interfaces/TableGridEditor/TableEntity.utils'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
|
|
import { useIsTableRealtimeEnabled } from '@/data/database-publications/database-publications-query'
|
|
import { useProjectLintsQuery } from '@/data/lint/lint-query'
|
|
import {
|
|
Entity,
|
|
isTableLike,
|
|
isForeignTable as isTableLikeForeignTable,
|
|
isMaterializedView as isTableLikeMaterializedView,
|
|
isView as isTableLikeView,
|
|
} from '@/data/table-editor/table-editor-types'
|
|
import { useTableUpdateMutation } from '@/data/tables/table-update-mutation'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import { useAppStateSnapshot } from '@/state/app-state'
|
|
import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
|
|
|
|
export interface GridHeaderActionsProps {
|
|
table: Entity
|
|
isRefetching: boolean
|
|
}
|
|
export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProps) => {
|
|
const track = useTrack()
|
|
const appSnap = useAppStateSnapshot()
|
|
const snap = useTableEditorTableStateSnapshot()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
|
|
const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false)
|
|
const [realtimeDialogOpen, setRealtimeDialogOpen] = useState(false)
|
|
const [indexAdvisorDialogOpen, setIndexAdvisorDialogOpen] = useState(false)
|
|
const [isAutofixViewSecurityModalOpen, setIsAutofixViewSecurityModalOpen] = useState(false)
|
|
|
|
const [showWarning, setShowWarning] = useQueryState(
|
|
'showWarning',
|
|
parseAsBoolean.withDefault(false)
|
|
)
|
|
|
|
// need project lints to get security status for views
|
|
const { data: lints = [] } = useProjectLintsQuery({ projectRef: project?.ref })
|
|
|
|
// Use table-specific index advisor context
|
|
const { isAvailable: isIndexAdvisorAvailable, isEnabled: isIndexAdvisorEnabled } =
|
|
useTableIndexAdvisor()
|
|
|
|
const isTable = isTableLike(table)
|
|
const isForeignTable = isTableLikeForeignTable(table)
|
|
const isView = isTableLikeView(table)
|
|
const isMaterializedView = isTableLikeMaterializedView(table)
|
|
|
|
const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all'])
|
|
const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema })
|
|
|
|
const isRealtimeEnabled = useIsTableRealtimeEnabled({ id: table.id })
|
|
|
|
const { mutateAsync: updateTable, isPending: isUpdatingTable } = useTableUpdateMutation({
|
|
onError: (error) => {
|
|
toast.error(`Failed to toggle RLS: ${error.message}`)
|
|
},
|
|
})
|
|
|
|
const showHeaderActions = snap.selectedRows.size === 0
|
|
|
|
const projectRef = project?.ref
|
|
const { data } = useDatabasePoliciesQuery({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
const policies = (data ?? []).filter(
|
|
(policy) => policy.schema === table.schema && policy.table === table.name
|
|
)
|
|
|
|
const { can: canSqlWriteTables, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
|
|
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
|
'tables'
|
|
)
|
|
const { can: canSqlWriteColumns } = useAsyncCheckPermissions(
|
|
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
|
'columns'
|
|
)
|
|
const isReadOnly = !isLoadingPermissions && !canSqlWriteTables && !canSqlWriteColumns
|
|
// This will change when we allow autogenerated API docs for schemas other than `public`
|
|
const doesHaveAutoGeneratedAPIDocs = table.schema === 'public'
|
|
|
|
const { hasLint: tableHasLints } = getEntityLintDetails(
|
|
table.name,
|
|
'rls_disabled_in_public',
|
|
['ERROR'],
|
|
lints,
|
|
table.schema
|
|
)
|
|
|
|
const { hasLint: viewHasLints, matchingLint: matchingViewLint } = getEntityLintDetails(
|
|
table.name,
|
|
'security_definer_view',
|
|
['ERROR', 'WARN'],
|
|
lints,
|
|
table.schema
|
|
)
|
|
|
|
const { hasLint: materializedViewHasLints, matchingLint: matchingMaterializedViewLint } =
|
|
getEntityLintDetails(
|
|
table.name,
|
|
'materialized_view_in_api',
|
|
['ERROR', 'WARN'],
|
|
lints,
|
|
table.schema
|
|
)
|
|
|
|
const onViewAPIDocs = () => {
|
|
appSnap.setActiveDocsSection(['entities', table.name])
|
|
appSnap.setShowProjectApiDocs(true)
|
|
|
|
track('api_docs_opened', { source: 'table_editor' })
|
|
}
|
|
|
|
const onToggleRLS = async () => {
|
|
const payload = {
|
|
id: table.id,
|
|
rls_enabled: !(isTable && table.rls_enabled),
|
|
}
|
|
|
|
const updateTablePromise = updateTable({
|
|
projectRef: project?.ref!,
|
|
connectionString: project?.connectionString,
|
|
id: table.id,
|
|
name: table.name,
|
|
schema: table.schema,
|
|
payload: payload,
|
|
})
|
|
|
|
track('table_rls_enabled', {
|
|
method: 'table_editor',
|
|
schema_name: table.schema,
|
|
table_name: table.name,
|
|
})
|
|
|
|
return updateTablePromise
|
|
}
|
|
|
|
return (
|
|
<div className="sb-grid-header__inner">
|
|
{showHeaderActions && (
|
|
<div className="flex items-center gap-x-2">
|
|
{isReadOnly && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="border border-strong rounded-sm bg-overlay-hover px-3 py-1 text-xs">
|
|
Viewing as read-only
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
You need additional permissions to manage your project's data
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{isTable && !isSchemaLocked ? (
|
|
table.rls_enabled ? (
|
|
<>
|
|
{policies.length < 1 && !isSchemaLocked ? (
|
|
<ButtonTooltip
|
|
asChild
|
|
type="default"
|
|
className="group"
|
|
icon={<PlusCircle strokeWidth={1.5} className="text-foreground-muted" />}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
className: 'w-[280px]',
|
|
text: 'RLS is enabled for this table, but no policies are set. Select queries may return 0 results.',
|
|
},
|
|
}}
|
|
>
|
|
<Link passHref href={getTablePoliciesUrl(projectRef, table.schema, table.name)}>
|
|
Add RLS policy
|
|
</Link>
|
|
</ButtonTooltip>
|
|
) : (
|
|
<Button
|
|
asChild
|
|
type={policies.length < 1 && !isSchemaLocked ? 'warning' : 'default'}
|
|
className="group"
|
|
icon={
|
|
isSchemaLocked || policies.length > 0 ? (
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-center rounded-full bg-border-stronger h-[16px]',
|
|
policies.length > 9 ? ' px-1' : 'w-[16px]',
|
|
''
|
|
)}
|
|
>
|
|
<span className="text-[11px] text-foreground font-mono text-center">
|
|
{policies.length}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<PlusCircle strokeWidth={1.5} />
|
|
)
|
|
}
|
|
>
|
|
<Link passHref href={getTablePoliciesUrl(projectRef, table.schema, table.name)}>
|
|
RLS {policies.length > 1 ? 'policies' : 'policy'}
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</>
|
|
) : tableHasLints ? (
|
|
<Popover modal={false} open={showWarning} onOpenChange={setShowWarning}>
|
|
<PopoverTrigger asChild>
|
|
<Button type="danger" icon={<Lock strokeWidth={1.5} />}>
|
|
RLS disabled
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80 text-sm" align="end">
|
|
<h4 className="flex items-center gap-2">
|
|
<Lock size={16} /> Row Level Security (RLS)
|
|
</h4>
|
|
<div className="grid gap-2 mt-4 text-foreground-light text-xs">
|
|
<p>
|
|
You can restrict and control who can read, write and update data in this table
|
|
using Row Level Security.
|
|
</p>
|
|
<p>
|
|
With RLS enabled, anonymous users will not be able to read/write data in the
|
|
table.
|
|
</p>
|
|
{!isSchemaLocked && (
|
|
<Button
|
|
type="default"
|
|
className="mt-2 w-min"
|
|
onClick={() => setRlsConfirmModalOpen(!rlsConfirmModalOpen)}
|
|
>
|
|
Enable RLS for this table
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : null
|
|
) : null}
|
|
|
|
{isView && viewHasLints && (
|
|
<SecurityDefinerViewPopover
|
|
lint={matchingViewLint}
|
|
onAutofix={() => {
|
|
setIsAutofixViewSecurityModalOpen(true)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isMaterializedView && materializedViewHasLints && (
|
|
<SecurityDefinerViewPopover lint={matchingMaterializedViewLint} />
|
|
)}
|
|
|
|
{isForeignTable && table.schema === 'public' && (
|
|
<Popover modal={false} open={showWarning} onOpenChange={setShowWarning}>
|
|
<PopoverTrigger asChild>
|
|
<Button type="warning" icon={<Unlock strokeWidth={1.5} />}>
|
|
Unprotected Data API access
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="min-w-[395px] text-sm" align="end">
|
|
<h3 className="flex items-center gap-2">
|
|
<Unlock size={16} /> Secure Foreign table
|
|
</h3>
|
|
<div className="grid gap-2 mt-4 text-foreground-light text-sm">
|
|
<p>
|
|
Foreign tables do not enforce RLS, which may allow unrestricted access. To
|
|
secure them, either move foreign tables to a private schema not exposed by
|
|
PostgREST, or <a href="">disable PostgREST access</a> entirely.
|
|
</p>
|
|
|
|
<div className="mt-2">
|
|
<Button type="default" asChild>
|
|
<Link
|
|
target="_blank"
|
|
href={`${DOCS_URL}/guides/database/extensions/wrappers/overview#security`}
|
|
>
|
|
Learn more
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
|
|
<RoleImpersonationPopover header="View data as a role" align="center" />
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="default"
|
|
icon={<MoreVertical />}
|
|
className="h-7 w-7"
|
|
aria-label="More actions"
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-48">
|
|
{isTable && realtimeEnabled && (
|
|
<DropdownMenuItem className="gap-x-2" onClick={() => setRealtimeDialogOpen(true)}>
|
|
<Realtime size={14} className={isRealtimeEnabled ? 'text-brand' : ''} />
|
|
<span>{isRealtimeEnabled ? 'Disable' : 'Enable'} Realtime</span>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{doesHaveAutoGeneratedAPIDocs && (
|
|
<DropdownMenuItem className="gap-x-2" onClick={() => onViewAPIDocs()}>
|
|
<BookOpenText size={14} />
|
|
<span>View API docs</span>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{isTable && isIndexAdvisorAvailable && !isIndexAdvisorEnabled && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="gap-x-2"
|
|
onClick={() => setIndexAdvisorDialogOpen(true)}
|
|
>
|
|
<Lightbulb size={14} />
|
|
<span>Enable Index Advisor</span>
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<RefreshButton tableId={table.id} isRefetching={isRefetching} />
|
|
|
|
{showHeaderActions && <InsertButton />}
|
|
</div>
|
|
)}
|
|
|
|
<ViewEntityAutofixSecurityModal
|
|
table={table}
|
|
isAutofixViewSecurityModalOpen={isAutofixViewSecurityModalOpen}
|
|
setIsAutofixViewSecurityModalOpen={setIsAutofixViewSecurityModalOpen}
|
|
/>
|
|
|
|
{isTable && (
|
|
<RLSToggleDialog
|
|
open={rlsConfirmModalOpen}
|
|
tableName={table.name}
|
|
isEnabled={table.rls_enabled}
|
|
isSubmitting={isUpdatingTable}
|
|
onOpenChange={setRlsConfirmModalOpen}
|
|
onConfirm={onToggleRLS}
|
|
/>
|
|
)}
|
|
|
|
<RealtimeToggleDialog
|
|
table={table}
|
|
open={realtimeDialogOpen}
|
|
setOpen={setRealtimeDialogOpen}
|
|
/>
|
|
|
|
<EnableIndexAdvisorDialog open={indexAdvisorDialogOpen} setOpen={setIndexAdvisorDialogOpen} />
|
|
</div>
|
|
)
|
|
}
|