mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 02:20:52 +08:00
## Fixes FE-2619 ## What is the new behavior? This PR adds support for marking table columns as sensitive and masking their values in the grid view. Sensitive columns: - Display an 8-dot mask instead of the underlying value - Remain masked across page refreshes - Can be temporarily revealed for 5 seconds via the **Show data** action - Display a warning when copying rows containing sensitive data This helps prevent accidental exposure of sensitive information when sharing screens, recording demos, or taking screenshots. ## Testing - [x] Toggle sensitivity ON → save → refresh → remains masked - [x] Toggle sensitivity OFF → save → refresh → remains unmasked - [x] Toggle sensitivity multiple times → state remains consistent - [x] Copy row with sensitive columns → warning shown - [x] Click **Show data** → value revealed for 5 seconds then re-masked - [x] Text, Boolean, Binary, JSON, and Foreign Key columns all display a consistent 8-dot mask ### Test data SQL fixture covering multiple PostgreSQL data types: https://gist.github.com/monicakh/2485e9054bf21045912359871e9a1cb4. ### UI <img width="1284" height="554" alt="CleanShot 2026-06-09 at 12 01 33@2x" src="https://github.com/user-attachments/assets/4aec0ba7-c874-42d7-9442-d2c704b319cc" /> <img width="1200" height="560" alt="CleanShot 2026-06-07 at 10 43 40@2x" src="https://github.com/user-attachments/assets/b9569484-6fcc-47de-bc3d-881d0edc4060" /> The **Show data** action is only available for sensitive columns. <img width="450" height="400" alt="CleanShot 2026-06-07 at 10 42 18@2x" src="https://github.com/user-attachments/assets/d48849a2-ec0b-4522-a787-561a1d204ec9" /> Warnings on Copy command <img width="450" height="80" alt="CleanShot 2026-06-09 at 11 58 42@2x" src="https://github.com/user-attachments/assets/374e7d6b-b82a-4923-b035-2ec9b2f7bb7d" /> <img width="450" height="80" alt="CleanShot 2026-06-09 at 11 58 58@2x" src="https://github.com/user-attachments/assets/ecd951bb-e9e2-47ae-9ddd-d32969e01c12" /> <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46180?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 --> --------- Co-authored-by: supabase-autofix-bot <noreply@supabase.com> Co-authored-by: Ali Waseem <waseema393@gmail.com>
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
|
import { safeLocalStorage, safeSessionStorage } from 'common'
|
|
import { compact } from 'lodash'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs'
|
|
import { useEffect, useMemo } from 'react'
|
|
import {
|
|
CalculatedColumn,
|
|
CellKeyboardEvent,
|
|
CellKeyDownArgs,
|
|
RowsChangeData,
|
|
} from 'react-data-grid'
|
|
import { toast } from 'sonner'
|
|
import { copyToClipboard } from 'ui'
|
|
|
|
import { FilterOperatorOptions } from './components/header/filter/Filter.constants'
|
|
import { STORAGE_KEY_PREFIX } from './constants'
|
|
import type { Sort, SupaColumn, SupaRow, SupaTable } from './types'
|
|
import { formatClipboardValue } from './utils/common'
|
|
import { isBoolColumn } from './utils/types'
|
|
import type { Filter, SavedState } from '@/components/grid/types'
|
|
import { Entity, isTableLike } from '@/data/table-editor/table-editor-types'
|
|
import { BASE_PATH } from '@/lib/constants'
|
|
import { eventMatchesAnyShortcut } from '@/state/shortcuts/matchEvent'
|
|
import { tableEditorRegistry } from '@/state/shortcuts/registry/table-editor'
|
|
|
|
export function formatSortURLParams(
|
|
// Should match the Entity type.
|
|
table: { name: string; columns: { name: string }[] },
|
|
sort?: string[]
|
|
): Sort[] {
|
|
if (Array.isArray(sort)) {
|
|
return compact(
|
|
sort.map((s) => {
|
|
const [column, order] = s.split(':')
|
|
// Reject any possible malformed sort param
|
|
if (!column || !order) return undefined
|
|
// if the sort column name doesn't exist in the table, reject it as well to avoid confusion
|
|
if (table.columns.find((c) => c.name === column) === undefined) return undefined
|
|
else return { table: table.name, column, ascending: order === 'asc' }
|
|
})
|
|
)
|
|
}
|
|
return []
|
|
}
|
|
|
|
export function sortsToUrlParams(sorts: { column: string; ascending?: boolean }[]) {
|
|
return sorts.map((sort) => `${sort.column}:${sort.ascending ? 'asc' : 'desc'}`)
|
|
}
|
|
|
|
export function formatFilterURLParams(filter?: string[]): Filter[] {
|
|
return (
|
|
Array.isArray(filter)
|
|
? filter
|
|
.map((f) => {
|
|
const [column, operatorAbbrev, ...value] = f.split(':')
|
|
|
|
// Allow usage of : in value, so join them back after spliting
|
|
const formattedValue = value.join(':')
|
|
const operator = FilterOperatorOptions.find(
|
|
(option) => option.abbrev === operatorAbbrev
|
|
)
|
|
// Reject any possible malformed filter param
|
|
if (!column || !operatorAbbrev || !operator) return undefined
|
|
else return { column, operator: operator.value, value: formattedValue || '' }
|
|
})
|
|
.filter((f) => f !== undefined)
|
|
: []
|
|
) as Filter[]
|
|
}
|
|
|
|
export function filtersToUrlParams(
|
|
filters: { column: string | Array<string>; operator: string; value: string }[]
|
|
) {
|
|
return filters.map((filter) => {
|
|
const selectedOperator = FilterOperatorOptions.find(
|
|
(option) => option.value === filter.operator
|
|
)
|
|
|
|
return `${filter.column}:${selectedOperator?.abbrev}:${filter.value}`
|
|
})
|
|
}
|
|
|
|
export function parseSupaTable(table: Entity): SupaTable {
|
|
const columns = table.columns
|
|
const primaryKeys = isTableLike(table) ? table.primary_keys : []
|
|
const uniqueIndexes = isTableLike(table) ? table.unique_indexes : []
|
|
const relationships = isTableLike(table) ? table.relationships : []
|
|
|
|
const supaColumns: SupaColumn[] = columns.map((column) => {
|
|
const temp = {
|
|
position: column.ordinal_position,
|
|
name: column.name,
|
|
defaultValue: column.default_value as string | null | undefined,
|
|
dataType: column.data_type,
|
|
format: column.format,
|
|
isPrimaryKey: false,
|
|
isIdentity: column.is_identity,
|
|
isGeneratable: column.identity_generation == 'BY DEFAULT',
|
|
isNullable: column.is_nullable,
|
|
isUpdatable: column.is_updatable,
|
|
enum: column.enums,
|
|
comment: column.comment,
|
|
foreignKey: {
|
|
targetTableSchema: null as string | null,
|
|
targetTableName: null as string | null,
|
|
targetColumnName: null as string | null,
|
|
deletionAction: undefined as string | undefined,
|
|
updateAction: undefined as string | undefined,
|
|
},
|
|
}
|
|
const primaryKey = primaryKeys.find((pk) => pk.name == column.name)
|
|
temp.isPrimaryKey = !!primaryKey
|
|
|
|
const relationship = relationships.find((relation) => {
|
|
return (
|
|
relation.source_schema === column.schema &&
|
|
relation.source_table_name === column.table &&
|
|
relation.source_column_name === column.name
|
|
)
|
|
})
|
|
if (relationship) {
|
|
temp.foreignKey.targetTableSchema = relationship.target_table_schema
|
|
temp.foreignKey.targetTableName = relationship.target_table_name
|
|
temp.foreignKey.targetColumnName = relationship.target_column_name
|
|
temp.foreignKey.deletionAction = relationship.deletion_action
|
|
temp.foreignKey.updateAction = relationship.update_action
|
|
}
|
|
return temp
|
|
})
|
|
|
|
return {
|
|
id: table.id,
|
|
name: table.name,
|
|
comment: table.comment,
|
|
schema: table.schema,
|
|
type: table.entity_type,
|
|
columns: supaColumns,
|
|
estimateRowCount: isTableLike(table) ? table.live_rows_estimate : 0,
|
|
primaryKey: primaryKeys?.length > 0 ? primaryKeys.map((col) => col.name) : undefined,
|
|
uniqueIndexes:
|
|
!!uniqueIndexes && uniqueIndexes.length > 0
|
|
? uniqueIndexes.map(({ columns }) => columns)
|
|
: undefined,
|
|
}
|
|
}
|
|
|
|
export function getStorageKey(prefix: string, ref: string) {
|
|
return `${prefix}_${ref}`
|
|
}
|
|
|
|
export function loadTableEditorStateFromLocalStorage(
|
|
projectRef: string,
|
|
tableId: number
|
|
): SavedState | undefined {
|
|
const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
|
|
// Prefer sessionStorage (scoped to current tab) over localStorage
|
|
const jsonStr = safeSessionStorage.getItem(storageKey) ?? safeLocalStorage.getItem(storageKey)
|
|
if (!jsonStr) return
|
|
const json = JSON.parse(jsonStr)
|
|
return json[tableId]
|
|
}
|
|
|
|
/**
|
|
* Builds a table editor URL with the given project reference, table ID. It will load the saved state from local storage
|
|
* and add the sort and filter parameters to the URL.
|
|
*/
|
|
export function buildTableEditorUrl({
|
|
projectRef = 'default',
|
|
tableId,
|
|
schema,
|
|
}: {
|
|
projectRef?: string
|
|
tableId: number
|
|
schema?: string
|
|
}) {
|
|
const url = new URL(`${BASE_PATH}/project/${projectRef}/editor/${tableId}`, location.origin)
|
|
|
|
// If the schema is provided, add it to the URL so that the left sidebar is opened to the correct schema
|
|
if (schema) {
|
|
url.searchParams.set('schema', schema)
|
|
}
|
|
|
|
const savedState = loadTableEditorStateFromLocalStorage(projectRef, tableId)
|
|
if (savedState?.sorts && savedState.sorts.length > 0) {
|
|
savedState.sorts?.forEach((sort) => url.searchParams.append('sort', sort))
|
|
}
|
|
if (savedState?.filters && savedState.filters.length > 0) {
|
|
savedState.filters?.forEach((filter) => url.searchParams.append('filter', filter))
|
|
}
|
|
return url.toString()
|
|
}
|
|
|
|
export function saveTableEditorStateToLocalStorage({
|
|
projectRef,
|
|
tableId,
|
|
gridColumns,
|
|
sorts,
|
|
filters,
|
|
sensitiveDataColumns,
|
|
}: {
|
|
projectRef: string
|
|
tableId: number
|
|
gridColumns?: CalculatedColumn<any, any>[]
|
|
sorts?: string[]
|
|
filters?: string[]
|
|
sensitiveDataColumns?: string[]
|
|
}) {
|
|
const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
|
|
const savedStr = safeSessionStorage.getItem(storageKey) ?? safeLocalStorage.getItem(storageKey)
|
|
|
|
const config = {
|
|
...(gridColumns !== undefined && { gridColumns }),
|
|
...(sorts !== undefined && { sorts: sorts.filter((sort) => sort !== '') }),
|
|
...(filters !== undefined && { filters: filters.filter((filter) => filter !== '') }),
|
|
...(sensitiveDataColumns !== undefined && { sensitiveDataColumns }),
|
|
}
|
|
|
|
let savedJson
|
|
if (savedStr) {
|
|
savedJson = JSON.parse(savedStr)
|
|
const previousConfig = savedJson[tableId]
|
|
savedJson = { ...savedJson, [tableId]: { ...previousConfig, ...config } }
|
|
} else {
|
|
savedJson = { [tableId]: config }
|
|
}
|
|
// Save to both localStorage and sessionStorage so it's consistent to current tab
|
|
safeLocalStorage.setItem(storageKey, JSON.stringify(savedJson))
|
|
safeSessionStorage.setItem(storageKey, JSON.stringify(savedJson))
|
|
}
|
|
|
|
export const saveTableEditorStateToLocalStorageDebounced = AwesomeDebouncePromise(
|
|
saveTableEditorStateToLocalStorage,
|
|
500
|
|
)
|
|
|
|
function getLatestParams() {
|
|
const queryParams = new URLSearchParams(window.location.search)
|
|
const sort = queryParams.getAll('sort')
|
|
const filter = queryParams.getAll('filter')
|
|
return { sort, filter }
|
|
}
|
|
|
|
export function useSyncTableEditorStateFromLocalStorageWithUrl({
|
|
projectRef,
|
|
table,
|
|
}: {
|
|
projectRef: string | undefined
|
|
table: Entity | undefined
|
|
}) {
|
|
// Warning: nuxt url state often fails to update to changes to URL
|
|
useQueryStates(
|
|
{
|
|
sort: parseAsNativeArrayOf(parseAsString),
|
|
filter: parseAsNativeArrayOf(parseAsString),
|
|
},
|
|
{
|
|
history: 'replace',
|
|
}
|
|
)
|
|
// Use nextjs useSearchParams to get the latest URL params
|
|
const searchParams = useSearchParams()
|
|
const urlParams = useMemo(() => {
|
|
const sort = searchParams?.getAll('sort') ?? []
|
|
const filter = searchParams?.getAll('filter') ?? []
|
|
return { sort, filter }
|
|
}, [searchParams])
|
|
|
|
useEffect(() => {
|
|
if (!projectRef || !table) {
|
|
return
|
|
}
|
|
|
|
// `urlParams` from `useQueryStates` can be stale so always get the latest from the URL
|
|
const latestUrlParams = getLatestParams()
|
|
|
|
saveTableEditorStateToLocalStorage({
|
|
projectRef,
|
|
tableId: table.id,
|
|
sorts: latestUrlParams.sort,
|
|
filters: latestUrlParams.filter,
|
|
})
|
|
}, [urlParams, table, projectRef])
|
|
}
|
|
|
|
export const handleCellKeyDown = <TRow extends SupaRow = SupaRow>(
|
|
args: CellKeyDownArgs<TRow, unknown>,
|
|
event: CellKeyboardEvent,
|
|
context?: {
|
|
rows: TRow[]
|
|
columns: SupaColumn[]
|
|
onRowsChange: (rows: TRow[], data: RowsChangeData<TRow, unknown>) => void
|
|
sensitiveDataColumns?: Set<string>
|
|
}
|
|
) => {
|
|
const { mode, column, row, rowIdx } = args
|
|
if (mode !== 'SELECT') return
|
|
const key = event.key.toLowerCase()
|
|
|
|
if (key === 'c' && (event.metaKey || event.ctrlKey)) {
|
|
if (window.getSelection()?.isCollapsed === false) return
|
|
|
|
const isSensitive = context?.sensitiveDataColumns?.has(column.key as string)
|
|
const value = formatClipboardValue(row[column.key] ?? '')
|
|
event.preventDefault()
|
|
event.preventGridDefault()
|
|
void copyToClipboard(value, () => {
|
|
if (isSensitive) {
|
|
toast.warning('Copied sensitive data to clipboard')
|
|
} else {
|
|
toast.success('Copied cell value to clipboard')
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
// Let registered shortcuts win over rdg's "type a key to enter edit mode" default,
|
|
// unless a printable key enters edit mode.
|
|
if (eventMatchesAnyShortcut(event.nativeEvent, tableEditorRegistry)) {
|
|
if (
|
|
event.key.length === 1 &&
|
|
event.key !== ' ' &&
|
|
!event.altKey &&
|
|
!event.ctrlKey &&
|
|
!event.metaKey &&
|
|
!event.shiftKey &&
|
|
column.renderEditCell != null
|
|
) {
|
|
event.stopPropagation()
|
|
} else {
|
|
event.preventGridDefault()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Toggle boolean cells with T/F when no modifier keys are pressed.
|
|
if (context === undefined) return
|
|
|
|
if (event.altKey || event.ctrlKey || event.metaKey || (key !== 't' && key !== 'f')) return
|
|
|
|
const supaColumn = context.columns.find((c) => c.name === column.key)
|
|
if (
|
|
supaColumn === undefined ||
|
|
!isBoolColumn(supaColumn.dataType) ||
|
|
column.renderEditCell == null
|
|
) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.preventGridDefault()
|
|
|
|
const nextValue = key === 't'
|
|
if (row[column.key] === nextValue) return
|
|
|
|
const updatedRows = [...context.rows]
|
|
updatedRows[rowIdx] = { ...row, [column.key]: nextValue }
|
|
context.onRowsChange(updatedRows, { indexes: [rowIdx], column })
|
|
}
|