feat: add toggle for sensitive data visibility in table columns (#46180)

## 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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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>
This commit is contained in:
authored and GitHub committed 2026-07-09 10:44:42 +03:00
1 parent 97713923f8
commit fd8a37b1d0
17 files changed
+510 -21

No files matched your search

@@ -197,12 +197,14 @@ export function saveTableEditorStateToLocalStorage({
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)
@@ -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 = <TRow extends SupaRow = SupaRow>(
rows: TRow[]
columns: SupaColumn[]
onRowsChange: (rows: TRow[], data: RowsChangeData<TRow, unknown>) => void
sensitiveDataColumns?: Set<string>
}
) => {
const { mode, column, row, rowIdx } = args
@@ -296,11 +300,16 @@ export const handleCellKeyDown = <TRow extends SupaRow = SupaRow>(
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
}
@@ -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<RenderCellProps<SupaRow, unknown>>) => {
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 <NullValue />
if (isMasked) return <>••••••••</>
const binaryValue = convertByteaToHex(value)
return <>{binaryValue}</>
}
@@ -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<RenderCellProps<SupaRow, unknown>>) => {
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 <NullValue />
if (isMasked) return <>••••••••</>
return <>{value ? 'TRUE' : 'FALSE'}</>
}
@@ -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<RenderCellProps<SupaRow, unknown>>) => {
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 <NullValue />
if (value === '') return <EmptyValue />
if (isMasked) return <>••••••••</>
if (typeof value == 'object' || Array.isArray(value)) {
value = JSON.stringify(value)
}
@@ -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<RenderCellProps<SupaRow, unknown>> {
tableId?: number
@@ -21,7 +23,13 @@ interface Props extends PropsWithChildren<RenderCellProps<SupaRow, unknown>> {
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 (
<div className="flex w-full items-center justify-between flex justify-between">
<span className="m-0 grow overflow-hidden text-ellipsis">
{formattedValue === null ? <NullValue /> : formattedValue}
{formattedValue === null ? <NullValue /> : isMasked ? '••••••••' : formattedValue}
</span>
{isLoading && formattedValue !== null && (
<div className="w-6 h-6 flex items-center justify-center">
@@ -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<RenderCellProps<SupaRow, unknown>>) => {
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 <NullValue />
if (value === '') return <EmptyValue />
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
@@ -429,6 +429,7 @@ export const Grid = memo(
rows: rows ?? [],
columns: snap.table.columns,
onRowsChange,
sensitiveDataColumns: snap.sensitiveDataColumns,
})
}
/>
@@ -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<any, unknown>
@@ -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<Map<string, NodeJS.Timeout>>(
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) => {
</>
)}
</DropdownMenuItem>
{snap.sensitiveDataColumns.has(columnKey) && (
<DropdownMenuItem
className="space-x-2"
disabled={snap.temporarilyRevealedColumns.has(columnKey)}
onClick={onToggleSensitiveData}
>
<Eye size={14} strokeWidth={1.5} />
<span>
{snap.temporarilyRevealedColumns.has(columnKey) ? 'Data revealed (5s)' : 'Show data'}
</span>
</DropdownMenuItem>
)}
{snap.editable && (
<>
<DropdownMenuSeparator />
@@ -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
@@ -4,6 +4,7 @@ export interface SavedState {
filters?: string[]
sorts?: string[]
gridColumns: CalculatedColumn<any, any>[]
sensitiveDataColumns?: string[]
}
export interface DragItem {
@@ -0,0 +1,7 @@
export function isColumnMasked(
columnKey: string,
sensitiveDataColumns: Set<string>,
temporarilyRevealedColumns: Set<string>
): boolean {
return sensitiveDataColumns.has(columnKey) && !temporarilyRevealedColumns.has(columnKey)
}
@@ -515,6 +515,30 @@ export const ColumnEditor = ({
</FormItemLayout>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={<FormSectionLabel className="lg:col-span-4!">Data Privacy</FormSectionLabel>}
>
<FormSectionContent loading={false} className="lg:col-span-8!">
<FormItemLayout
isReactForm={false}
layout="flex"
id="isSensitiveData"
label="Mark as sensitive data"
description="Column will be masked when viewing table data by default"
>
<Switch
id="isSensitiveData"
checked={columnFields.isSensitiveData ?? false}
onCheckedChange={() => {
onUpdateField({ isSensitiveData: !columnFields.isSensitiveData })
}}
/>
</FormItemLayout>
</FormSectionContent>
</FormSection>
</SheetSection>
<SheetFooter className="justify-between! [&>div]:p-0 [&>div]:border-t-0">
@@ -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<UpdateColumnPayload> = {}
@@ -59,6 +59,7 @@ export interface ColumnField {
isPrimaryKey: boolean
isNewColumn: boolean
isEncrypted: boolean
isSensitiveData?: boolean
}
export interface PostgresDataTypeOption {
@@ -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 = ({
<GripVertical size={16} strokeWidth={1.5} />
</button>
</div>
<div className="w-[25%]">
<div className="w-[30%]">
<div className="flex w-[95%] items-center justify-between">
<div className="h-4 w-px bg-border" />
<Input
aria-label="Column name"
size="small"
@@ -144,14 +148,65 @@ export const Column = ({
)}
onChange={(event) => onUpdateColumn({ name: event.target.value })}
/>
{relations.filter((r) => !r.toRemove).length === 0 ? (
<Button
variant="dashed"
className="rounded-l-none h-[30px] py-0 px-2"
onClick={() => onEditForeignKey()}
>
<Link size={12} />
</Button>
<div className="flex items-center gap-x-1">
<Button
variant="dashed"
className="rounded-l-none h-[30px] py-0 px-2"
onClick={() => onEditForeignKey()}
>
<Link size={12} />
</Button>
<div className="h-4 w-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
const SENSITIVE_DATA_MARKER = '[SENSITIVE]'
const isSensitive = !column.isSensitiveData
let updatedComment = column.comment || ''
if (isSensitive && !updatedComment.includes(SENSITIVE_DATA_MARKER)) {
updatedComment = updatedComment
? `${updatedComment} ${SENSITIVE_DATA_MARKER}`
: SENSITIVE_DATA_MARKER
} else if (!isSensitive) {
updatedComment = updatedComment.replace(SENSITIVE_DATA_MARKER, '').trim()
}
onUpdateColumn({ isSensitiveData: isSensitive, comment: updatedComment })
}}
className={cn(
'transition cursor-pointer p-1 hover:bg-surface-100 rounded',
column.isSensitiveData
? 'opacity-100 text-foreground'
: 'opacity-50 hover:opacity-100 text-foreground-light'
)}
type="button"
aria-label={
column.isSensitiveData ? 'Marked as sensitive' : 'Not marked as sensitive'
}
>
{column.isSensitiveData ? (
<EyeOff size={14} strokeWidth={1.5} />
) : (
<Eye size={14} strokeWidth={1.5} />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{column.isSensitiveData
? 'Data is masked in grid display. Actual data unchanged in database.'
: 'Mark as sensitive to mask in grid display'}
</TooltipContent>
</Tooltip>
</div>
) : (
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger asChild>
+42 -1
View File
@@ -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<string>((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<string>(userToggledColumns),
temporarilyRevealedColumns: proxySet<string>(),
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
@@ -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<typeof waitForGridDataToLoad>[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<typeof waitForGridDataToLoad>[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 })
})
})