mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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 --> [](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:
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)
|
||||
}
|
||||
+24
@@ -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">
|
||||
|
||||
+37
-3
@@ -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> = {}
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ export interface ColumnField {
|
||||
isPrimaryKey: boolean
|
||||
isNewColumn: boolean
|
||||
isEncrypted: boolean
|
||||
isSensitiveData?: boolean
|
||||
}
|
||||
|
||||
export interface PostgresDataTypeOption {
|
||||
|
||||
+64
-9
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user