diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 2e9fcca862f..2818e55edbc 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -197,12 +197,14 @@ export function saveTableEditorStateToLocalStorage({ gridColumns, sorts, filters, + sensitiveDataColumns, }: { projectRef: string tableId: number gridColumns?: CalculatedColumn[] sorts?: string[] filters?: string[] + sensitiveDataColumns?: string[] }) { const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef) const savedStr = safeSessionStorage.getItem(storageKey) ?? safeLocalStorage.getItem(storageKey) @@ -211,6 +213,7 @@ export function saveTableEditorStateToLocalStorage({ ...(gridColumns !== undefined && { gridColumns }), ...(sorts !== undefined && { sorts: sorts.filter((sort) => sort !== '') }), ...(filters !== undefined && { filters: filters.filter((filter) => filter !== '') }), + ...(sensitiveDataColumns !== undefined && { sensitiveDataColumns }), } let savedJson @@ -287,6 +290,7 @@ export const handleCellKeyDown = ( rows: TRow[] columns: SupaColumn[] onRowsChange: (rows: TRow[], data: RowsChangeData) => void + sensitiveDataColumns?: Set } ) => { const { mode, column, row, rowIdx } = args @@ -296,11 +300,16 @@ export const handleCellKeyDown = ( 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, () => { - toast.success('Copied cell value to clipboard') + if (isSensitive) { + toast.warning('Copied sensitive data to clipboard') + } else { + toast.success('Copied cell value to clipboard') + } }) return } diff --git a/apps/studio/components/grid/components/formatter/BinaryFormatter.tsx b/apps/studio/components/grid/components/formatter/BinaryFormatter.tsx index 5c3a9b3786b..5921891785e 100644 --- a/apps/studio/components/grid/components/formatter/BinaryFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/BinaryFormatter.tsx @@ -1,13 +1,23 @@ import { PropsWithChildren } from 'react' import type { RenderCellProps } from 'react-data-grid' +import { isColumnMasked } from '../../utils/sensitive-data' import { NullValue } from '../common/NullValue' import { SupaRow } from '@/components/grid/types' import { convertByteaToHex } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' +import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' export const BinaryFormatter = (p: PropsWithChildren>) => { + const snap = useTableEditorTableStateSnapshot() const value = p.row[p.column.key] + const isMasked = isColumnMasked( + p.column.key as string, + snap.sensitiveDataColumns, + snap.temporarilyRevealedColumns + ) + if (!value) return + if (isMasked) return <>•••••••• const binaryValue = convertByteaToHex(value) return <>{binaryValue} } diff --git a/apps/studio/components/grid/components/formatter/BooleanFormatter.tsx b/apps/studio/components/grid/components/formatter/BooleanFormatter.tsx index a6d58a6bcdc..cb38e72a70c 100644 --- a/apps/studio/components/grid/components/formatter/BooleanFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/BooleanFormatter.tsx @@ -2,10 +2,20 @@ import type { PropsWithChildren } from 'react' import type { RenderCellProps } from 'react-data-grid' import type { SupaRow } from '../../types' +import { isColumnMasked } from '../../utils/sensitive-data' import { NullValue } from '../common/NullValue' +import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' export const BooleanFormatter = (p: PropsWithChildren>) => { + const snap = useTableEditorTableStateSnapshot() const value = p.row[p.column.key] as boolean | null + const isMasked = isColumnMasked( + p.column.key as string, + snap.sensitiveDataColumns, + snap.temporarilyRevealedColumns + ) + if (value === null) return + if (isMasked) return <>•••••••• return <>{value ? 'TRUE' : 'FALSE'} } diff --git a/apps/studio/components/grid/components/formatter/DefaultFormatter.tsx b/apps/studio/components/grid/components/formatter/DefaultFormatter.tsx index edc12b34bcf..aa47d7b5ddd 100644 --- a/apps/studio/components/grid/components/formatter/DefaultFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/DefaultFormatter.tsx @@ -1,14 +1,26 @@ import { PropsWithChildren } from 'react' import type { RenderCellProps } from 'react-data-grid' +import { isColumnMasked } from '../../utils/sensitive-data' import { EmptyValue } from '../common/EmptyValue' import { NullValue } from '../common/NullValue' import { SupaRow } from '@/components/grid/types' +import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' export const DefaultFormatter = (p: PropsWithChildren>) => { + const snap = useTableEditorTableStateSnapshot() let value = p.row[p.column.key] + + // Check if column should be masked: marked sensitive AND not temporarily revealed + const isMasked = isColumnMasked( + p.column.key as string, + snap.sensitiveDataColumns, + snap.temporarilyRevealedColumns + ) + if (value === null) return if (value === '') return + if (isMasked) return <>•••••••• if (typeof value == 'object' || Array.isArray(value)) { value = JSON.stringify(value) } diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index afced4818d5..11c1c61a308 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import type { SupaRow } from '../../types' +import { isColumnMasked } from '../../utils/sensitive-data' import { NullValue } from '../common/NullValue' import { ReferenceRecordPeek } from './ReferenceRecordPeek' import { convertByteaToHex } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils' @@ -14,6 +15,7 @@ import { useTableEditorQuery } from '@/data/table-editor/table-editor-query' import { isTableLike } from '@/data/table-editor/table-editor-types' import { useTableQuery } from '@/data/tables/table-retrieve-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' interface Props extends PropsWithChildren> { tableId?: number @@ -21,7 +23,13 @@ interface Props extends PropsWithChildren> { export const ForeignKeyFormatter = (props: Props) => { const { tableId, row, column } = props + const snap = useTableEditorTableStateSnapshot() const { data: project } = useSelectedProjectQuery() + const isMasked = isColumnMasked( + column.key as string, + snap.sensitiveDataColumns, + snap.temporarilyRevealedColumns + ) const { data, isPending: isLoading } = useTableEditorQuery({ projectRef: project?.ref, @@ -58,7 +66,7 @@ export const ForeignKeyFormatter = (props: Props) => { return (
- {formattedValue === null ? : formattedValue} + {formattedValue === null ? : isMasked ? '••••••••' : formattedValue} {isLoading && formattedValue !== null && (
diff --git a/apps/studio/components/grid/components/formatter/JsonFormatter.tsx b/apps/studio/components/grid/components/formatter/JsonFormatter.tsx index f54c207efbe..985e7e7d94a 100644 --- a/apps/studio/components/grid/components/formatter/JsonFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/JsonFormatter.tsx @@ -2,14 +2,23 @@ import { PropsWithChildren } from 'react' import type { RenderCellProps } from 'react-data-grid' import type { SupaRow } from '../../types' +import { isColumnMasked } from '../../utils/sensitive-data' import { EmptyValue } from '../common/EmptyValue' import { NullValue } from '../common/NullValue' +import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' export const JsonFormatter = (p: PropsWithChildren>) => { + const snap = useTableEditorTableStateSnapshot() let value = p.row[p.column.key] + const isMasked = isColumnMasked( + p.column.key as string, + snap.sensitiveDataColumns, + snap.temporarilyRevealedColumns + ) if (value === null) return if (value === '') return + if (isMasked) return <>•••••••• // [Joshen] With reference to table-rows-query, we're only pulling max n characters on text/jsonb columns // If column value is longer, value will be concatenated with ..., and we just want to make sure the JSON diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 9544be6d7ef..37253f80afd 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -429,6 +429,7 @@ export const Grid = memo( rows: rows ?? [], columns: snap.table.columns, onRowsChange, + sensitiveDataColumns: snap.sensitiveDataColumns, }) } /> diff --git a/apps/studio/components/grid/components/menu/ColumnMenu.tsx b/apps/studio/components/grid/components/menu/ColumnMenu.tsx index 968cc98afc7..ba3e891602d 100644 --- a/apps/studio/components/grid/components/menu/ColumnMenu.tsx +++ b/apps/studio/components/grid/components/menu/ColumnMenu.tsx @@ -1,5 +1,7 @@ -import { ArrowDown, ArrowUp, ChevronDown, Copy, Edit, Lock, Trash, Unlock } from 'lucide-react' +import { ArrowDown, ArrowUp, ChevronDown, Copy, Edit, Eye, Lock, Trash, Unlock } from 'lucide-react' +import { useContext, useEffect, useState } from 'react' import type { CalculatedColumn } from 'react-data-grid' +import { toast } from 'sonner' import { Button, cn, @@ -17,7 +19,10 @@ import { import { useTableSort } from '@/components/grid/hooks/useTableSort' import type { Sort } from '@/components/grid/types' import { useTableEditorStateSnapshot } from '@/state/table-editor' -import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' +import { + TableEditorTableStateContext, + useTableEditorTableStateSnapshot, +} from '@/state/table-editor-table' interface ColumnMenuProps { column: CalculatedColumn @@ -27,7 +32,11 @@ interface ColumnMenuProps { export const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => { const tableEditorSnap = useTableEditorStateSnapshot() const snap = useTableEditorTableStateSnapshot() + const state = useContext(TableEditorTableStateContext) const { sorts, addOrUpdateSort, removeSort } = useTableSort() + const [tempRevealTimeouts, setTempRevealTimeouts] = useState>( + new Map() + ) const columnKey = column.key const columnName = column.name as string @@ -54,6 +63,50 @@ export const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => { } } + useEffect(() => { + return () => { + tempRevealTimeouts.forEach((timeout) => clearTimeout(timeout)) + } + }, [tempRevealTimeouts]) + + function onToggleSensitiveData() { + const isMasked = snap.sensitiveDataColumns.has(columnKey) + const isTemporarilyRevealed = snap.temporarilyRevealedColumns.has(columnKey) + + if (isMasked && !isTemporarilyRevealed) { + // Temporarily reveal for 5 seconds (don't persist) + const existingTimeout = tempRevealTimeouts.get(columnKey) + if (existingTimeout) clearTimeout(existingTimeout) + + state.temporarilyRevealedColumns.add(columnKey) + + const timeout = setTimeout(() => { + state.temporarilyRevealedColumns.delete(columnKey) + setTempRevealTimeouts((prev) => { + const next = new Map(prev) + next.delete(columnKey) + return next + }) + }, 5000) + + setTempRevealTimeouts((prev) => new Map(prev).set(columnKey, timeout)) + toast.info('Data will be hidden again in 5 seconds') + } else { + // Column is being revealed (either temp or persistent), toggle persistent mask + const existingTimeout = tempRevealTimeouts.get(columnKey) + if (existingTimeout) clearTimeout(existingTimeout) + + state.toggleSensitiveDataColumn(columnKey) + + state.temporarilyRevealedColumns.delete(columnKey) + setTempRevealTimeouts((prev) => { + const next = new Map(prev) + next.delete(columnKey) + return next + }) + } + } + function onSortColumn(ascending: boolean) { if (!columnKey) return const currentSort = sorts.find((s) => s.column === columnKey) @@ -140,6 +193,18 @@ export const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => { )} + {snap.sensitiveDataColumns.has(columnKey) && ( + + + + {snap.temporarilyRevealedColumns.has(columnKey) ? 'Data revealed (5s)' : 'Show data'} + + + )} {snap.editable && ( <> diff --git a/apps/studio/components/grid/components/menu/RowContextMenu.tsx b/apps/studio/components/grid/components/menu/RowContextMenu.tsx index 068e7205836..7122eb5b529 100644 --- a/apps/studio/components/grid/components/menu/RowContextMenu.tsx +++ b/apps/studio/components/grid/components/menu/RowContextMenu.tsx @@ -44,17 +44,30 @@ export const RowContextMenuContent = ({ const value = row[column.key] const text = formatClipboardValue(value) + const isSensitive = snap.sensitiveDataColumns.has(column.key as string) void copyToClipboard(text, () => { - toast.success('Copied cell value to clipboard') + if (isSensitive) { + toast.warning('Copied sensitive data to clipboard') + } else { + toast.success('Copied cell value to clipboard') + } }) - }, [activeCellPosition, row, snap.gridColumns]) + }, [activeCellPosition, row, snap.gridColumns, snap.sensitiveDataColumns]) const onCopyRowContent = useCallback(() => { + const hasSensitiveColumns = snap.gridColumns.some((col) => + snap.sensitiveDataColumns.has(col.key as string) + ) + void copyToClipboard(JSON.stringify(row), () => { - toast.success('Copied row to clipboard') + if (hasSensitiveColumns) { + toast.warning('Copied row containing sensitive data to clipboard') + } else { + toast.success('Copied row to clipboard') + } }) - }, [row]) + }, [row, snap.gridColumns, snap.sensitiveDataColumns]) const getRowAndColumn = useCallback(() => { if (!activeCellPosition) return null diff --git a/apps/studio/components/grid/types/base.ts b/apps/studio/components/grid/types/base.ts index b24d53fedd1..7f9c5bb0c38 100644 --- a/apps/studio/components/grid/types/base.ts +++ b/apps/studio/components/grid/types/base.ts @@ -4,6 +4,7 @@ export interface SavedState { filters?: string[] sorts?: string[] gridColumns: CalculatedColumn[] + sensitiveDataColumns?: string[] } export interface DragItem { diff --git a/apps/studio/components/grid/utils/sensitive-data.ts b/apps/studio/components/grid/utils/sensitive-data.ts new file mode 100644 index 00000000000..0656d945882 --- /dev/null +++ b/apps/studio/components/grid/utils/sensitive-data.ts @@ -0,0 +1,7 @@ +export function isColumnMasked( + columnKey: string, + sensitiveDataColumns: Set, + temporarilyRevealedColumns: Set +): boolean { + return sensitiveDataColumns.has(columnKey) && !temporarilyRevealedColumns.has(columnKey) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index 9bf945ef974..4b646a22274 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -515,6 +515,30 @@ export const ColumnEditor = ({ + + + + Data Privacy} + > + + + { + onUpdateField({ isSensitiveData: !columnFields.isSensitiveData }) + }} + /> + + + diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts index 40a5d968cc4..fc70b3f1f89 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts @@ -25,6 +25,31 @@ const isImplicitTypeSchema = (schema: string | undefined) => export const normalizeFormatSchema = (schema: string | undefined): string | undefined => isImplicitTypeSchema(schema) ? undefined : schema +// Helper functions to encode/decode sensitivity flag in column comments +const SENSITIVE_DATA_MARKER = '[SENSITIVE]' + +const isSensitiveDataInComment = (comment: string | null | undefined): boolean => { + return comment ? comment.includes(SENSITIVE_DATA_MARKER) : false +} + +const encodeCommentWithSensitivityFlag = ( + comment: string | null | undefined, + isSensitive: boolean +): string | null => { + let cleanComment = comment?.replace(SENSITIVE_DATA_MARKER, '').trim() ?? '' + if (isSensitive && cleanComment) { + return `${SENSITIVE_DATA_MARKER} ${cleanComment}` + } else if (isSensitive) { + return SENSITIVE_DATA_MARKER + } + return cleanComment || null +} + +const decodeCommentWithoutSensitivityFlag = (comment: string | null | undefined): string | null => { + if (!comment) return null + return comment.replace(SENSITIVE_DATA_MARKER, '').trim() || null +} + export const displayColumnType = ( format: string, formatSchema: string | undefined, @@ -82,6 +107,7 @@ export const generateColumnField = ( isIdentity: false, isNewColumn: true, isEncrypted: false, + isSensitiveData: false, } } @@ -101,6 +127,7 @@ export const generateColumnFieldFromPGColumn = ( const primaryKeyColumns = primary_keys.map((key) => key.name) const foreignKey = getColumnForeignKey(column, table, foreignKeys) const isArray = column?.data_type === 'ARRAY' + const isSensitiveData = isSensitiveDataInComment(column?.comment) return { foreignKey, @@ -108,7 +135,7 @@ export const generateColumnFieldFromPGColumn = ( table: column.table, schema: column.schema, name: column.name, - comment: column?.comment, + comment: decodeCommentWithoutSensitivityFlag(column?.comment), format: isArray ? column.format.slice(1) : column.format, formatSchema: normalizeFormatSchema(lookupFormatSchema(column, table)), defaultValue: column?.default_value as string | null, @@ -121,6 +148,7 @@ export const generateColumnFieldFromPGColumn = ( isNewColumn: false, isEncrypted: false, isPrimaryKey: primaryKeyColumns.includes(column.name), + isSensitiveData, } } @@ -135,7 +163,10 @@ export const generateCreateColumnPayload = ( table: table.name, isIdentity, name: field.name.trim(), - comment: field.comment?.trim(), + comment: encodeCommentWithSensitivityFlag( + field.comment?.trim(), + field.isSensitiveData ?? false + ) as string | undefined, type: { schema: field.formatSchema, name: field.format, isArray: field.isArray }, check: trimSafeSqlFragment(field.check) ?? undefined, isUnique: field.isUnique, @@ -164,7 +195,10 @@ export const generateUpdateColumnPayload = ( // Only append the properties which are getting updated const name = field.name.trim() - const comment = field.comment?.trim() + const comment = encodeCommentWithSensitivityFlag( + field.comment?.trim(), + field.isSensitiveData ?? false + ) as string | undefined const check = trimSafeSqlFragment(field.check) ?? undefined const payload: Partial = {} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.types.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.types.ts index 3e05f0f50f0..73e0a5fb9b3 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.types.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.types.ts @@ -59,6 +59,7 @@ export interface ColumnField { isPrimaryKey: boolean isNewColumn: boolean isEncrypted: boolean + isSensitiveData?: boolean } export interface PostgresDataTypeOption { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx index d4c2ef0de94..c347ed55900 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx @@ -1,6 +1,6 @@ import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { GripVertical, Link, Plus, Settings, X } from 'lucide-react' +import { Eye, EyeOff, GripVertical, Link, Plus, Settings, X } from 'lucide-react' import { useState } from 'react' import { Badge, @@ -16,6 +16,9 @@ import { Popover, PopoverContent, PopoverTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -129,8 +132,9 @@ export const Column = ({
-
+
+
onUpdateColumn({ name: event.target.value })} /> + {relations.filter((r) => !r.toRemove).length === 0 ? ( - +
+ +
+ + + + + + + + {column.isSensitiveData + ? 'Data is masked in grid display. Actual data unchanged in database.' + : 'Mark as sensitive to mask in grid display'} + + +
) : ( diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index 99b49e1e40b..010e6c67259 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -17,6 +17,12 @@ import { Entity } from '@/data/table-editor/table-editor-types' const FALLBACK_TABLE_STATE = proxy({}) as TableEditorTableState +// Helper to extract sensitive data marker from column comment +const SENSITIVE_DATA_MARKER = '[SENSITIVE]' +const isSensitiveDataColumn = (comment: string | null | undefined): boolean => { + return comment ? comment.includes(SENSITIVE_DATA_MARKER) : false +} + export const createTableEditorTableState = ({ projectRef, table: originalTable, @@ -49,6 +55,9 @@ export const createTableEditorTableState = ({ savedState ) + // userToggledColumns: columns user has explicitly toggled OFF (persisted) + const userToggledColumns = new Set((savedState as any)?.sensitiveDataColumns ?? []) + const state = proxy({ /* Table */ table, @@ -74,6 +83,14 @@ export const createTableEditorTableState = ({ { gridColumns: state.gridColumns } ) + // Preserve user's toggle choices across table updates + // Only clear toggles for columns that no longer exist + const currentColumnNames = new Set(table.columns.map((col) => col.name)) + const preservedToggles = new Set( + Array.from(state.userToggledSensitiveColumns).filter((col) => currentColumnNames.has(col)) + ) + state.userToggledSensitiveColumns = proxySet(preservedToggles) + state.table = supaTable state.gridColumns = gridColumns state.originalTable = table @@ -94,6 +111,29 @@ export const createTableEditorTableState = ({ /* Columns */ gridColumns, + userToggledSensitiveColumns: proxySet(userToggledColumns), + temporarilyRevealedColumns: proxySet(), + toggleSensitiveDataColumn: (columnKey: string) => { + // Track which columns user has toggled OFF from their default masked state + if (state.userToggledSensitiveColumns.has(columnKey)) { + state.userToggledSensitiveColumns.delete(columnKey) + } else { + state.userToggledSensitiveColumns.add(columnKey) + } + }, + get sensitiveDataColumns() { + // Single source of truth: columns marked sensitive = defaults minus user toggles (persistent only) + const defaultSensitiveColumns = new Set( + state.table.columns + .filter((col) => isSensitiveDataColumn(col.comment)) + .map((col) => col.name) + ) + return new Set( + Array.from(defaultSensitiveColumns).filter( + (col) => !state.userToggledSensitiveColumns.has(col) + ) + ) + }, moveColumn: (fromKey: string, toKey: string) => { const fromIdx = state.gridColumns.findIndex((x) => x.key === fromKey) const toIdx = state.gridColumns.findIndex((x) => x.key === toKey) @@ -250,7 +290,8 @@ export const TableEditorTableStateContextProvider = ({ gridColumns: state.gridColumns, projectRef, tableId: state.table.id, - }) + sensitiveDataColumns: Array.from(state.userToggledSensitiveColumns), + } as any) }) } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/e2e/studio/features/table-editor-sensitive-data.spec.ts b/e2e/studio/features/table-editor-sensitive-data.spec.ts new file mode 100644 index 00000000000..936df77e62d --- /dev/null +++ b/e2e/studio/features/table-editor-sensitive-data.spec.ts @@ -0,0 +1,189 @@ +import crypto from 'node:crypto' +import { expect } from '@playwright/test' + +import { createTable, dropTable, query } from '../utils/db/index.js' +import { test } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' +import { waitForGridDataToLoad } from '../utils/wait-for-response.js' + +const uniqueSuffix = () => crypto.randomBytes(4).toString('hex') + +const SENSITIVE_MARKER = '[SENSITIVE]' +const MASKED_VALUE = '••••••••' +const SECRET_VALUE = 'super_secret_value' +const PUBLIC_VALUE = 'public_value' + +/** + * Creates a table with two columns: + * - `public_col` (plain text) + * - `secret_col` (text, comment contains [SENSITIVE] so it is masked by default) + * Inserts one row so the grid has data to assert against. + */ +const setupSensitiveFixture = async () => { + const suffix = uniqueSuffix() + const tableName = `pw_sensitive_${suffix}` + + await createTable(tableName, 'public_col', [{ public_col: PUBLIC_VALUE }]) + await query(`ALTER TABLE public.${tableName} ADD COLUMN secret_col text`, []) + await query(`COMMENT ON COLUMN public.${tableName}.secret_col IS '${SENSITIVE_MARKER}'`, []) + await query(`UPDATE public.${tableName} SET secret_col = $1`, [SECRET_VALUE]) + + return { + tableName, + async [Symbol.asyncDispose]() { + await dropTable(tableName) + }, + } +} + +const goToTable = async ( + page: Parameters[0], + ref: string, + tableName: string +) => { + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + await waitForGridDataToLoad(page, ref) +} + +const openColumnMenu = async ( + page: Parameters[0], + colName: string +) => { + await page + .getByRole('columnheader', { name: colName }) + .getByRole('button', { name: `Column ${colName} actions` }) + .click() +} + +test.describe('table editor — sensitive data masking', () => { + test('sensitive column shows masked value in grid by default', async ({ page, ref }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + await expect( + page.getByRole('gridcell', { name: PUBLIC_VALUE }), + 'public column value should be visible' + ).toBeVisible() + + await expect( + page.getByRole('gridcell', { name: MASKED_VALUE }), + 'sensitive column should show masked value' + ).toBeVisible() + + await expect( + page.getByRole('gridcell', { name: SECRET_VALUE }), + 'actual sensitive value should not be visible' + ).not.toBeVisible() + }) + + test('column menu shows "Show data" only for sensitive columns', async ({ page, ref }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + await openColumnMenu(page, 'secret_col') + await expect( + page.getByRole('menuitem', { name: 'Show data' }), + '"Show data" should appear in the menu for a sensitive column' + ).toBeVisible() + await page.keyboard.press('Escape') + + await openColumnMenu(page, 'public_col') + await expect( + page.getByRole('menuitem', { name: 'Show data' }), + '"Show data" should not appear for a non-sensitive column' + ).not.toBeVisible() + await page.keyboard.press('Escape') + }) + + test('"Show data" menu item is disabled while column is temporarily revealed', async ({ + page, + ref, + }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + // Reveal the sensitive data + await openColumnMenu(page, 'secret_col') + await page.getByRole('menuitem', { name: 'Show data' }).click() + + // Wait for the dropdown to fully unmount before reopening it — a trigger + // click that lands while the previous menu is still closing is dropped + await page.getByRole('menu').waitFor({ state: 'detached' }) + + await openColumnMenu(page, 'secret_col') + + // ColumnMenu.tsx relabels the item to "Data revealed (5s)" while the + // column is temporarily revealed, so it must be located by that name here + await expect( + page.getByRole('menuitem', { name: 'Data revealed (5s)' }), + '"Show data" menu item should be disabled while column is temporarily revealed' + ).toBeDisabled({ timeout: 2000 }) + + await page.keyboard.press('Escape') + }) + + test('sensitive data is automatically masked again after 5 seconds', async ({ page, ref }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + // Reveal the sensitive data + await openColumnMenu(page, 'secret_col') + await page.getByRole('menuitem', { name: 'Show data' }).click() + + await expect( + page.getByRole('gridcell', { name: SECRET_VALUE }), + 'actual value should be visible after reveal' + ).toBeVisible({ timeout: 5000 }) + + // Wait for the temporary reveal to expire + await page.waitForTimeout(5500) + + await expect( + page.getByRole('gridcell', { name: MASKED_VALUE }), + 'sensitive value should be masked again after 5 seconds' + ).toBeVisible() + + await expect( + page.getByRole('gridcell', { name: SECRET_VALUE }), + 'actual sensitive value should no longer be visible' + ).not.toBeVisible() + }) + + test('copying a cell from a sensitive column shows a warning toast', async ({ page, ref }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + // Reveal first so there is actual text in the cell to right-click + await openColumnMenu(page, 'secret_col') + await page.getByRole('menuitem', { name: 'Show data' }).click() + await expect(page.getByRole('gridcell', { name: SECRET_VALUE })).toBeVisible({ timeout: 5000 }) + + const cell = page.getByRole('gridcell', { name: SECRET_VALUE }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy cell' }).click() + + await expect( + page.getByText('Copied sensitive data to clipboard'), + 'warning toast should appear when copying a sensitive cell' + ).toBeVisible({ timeout: 10000 }) + }) + + test('copying a row that contains a sensitive column shows a warning toast', async ({ + page, + ref, + }) => { + await using fixture = await setupSensitiveFixture() + await goToTable(page, ref, fixture.tableName) + + const cell = page.getByRole('gridcell', { name: PUBLIC_VALUE }) + await cell.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy row' }).click() + + await expect( + page.getByText('Copied row containing sensitive data to clipboard'), + 'warning toast should appear when copying a row with a sensitive column' + ).toBeVisible({ timeout: 10000 }) + }) +})