Files
supabase/e2e/studio/features/table-editor-sensitive-data.spec.ts
Monica Khoury fd8a37b1d0 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>
2026-07-09 10:44:42 +03:00

190 lines
6.6 KiB
TypeScript

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 })
})
})