improvement(tables): show the lock chip only when a table is actually locked (#5967)

* improvement(tables): show the lock chip only when a table is actually locked

- The header chip rendered whenever an admin had the flag on, so an unlocked
  table permanently carried a "Lock settings" entry. Header space is for
  state: the chip now appears only once something is locked and names the
  mode. The admin route to the panel on an unlocked table is the breadcrumb
  dropdown, which already had it
- Keep the Append-only name when the schema is locked too. Append-only
  describes the row semantics and a schema lock doesn't change them; the
  detail line calls out the locked columns instead

* improvement(tables): resolve the lock flag server-side and record lock changes fully

- Drop NEXT_PUBLIC_TABLE_LOCKS. A feature flag's gating lives in AppConfig,
  which has no client counterpart, so mirroring it into a public env var meant
  AppConfig couldn't control the UI at all and org/user clauses could never
  reach the client — only global on/off. The page now resolves the flag with
  session context and passes it down, per the add-feature-flag skill. Embedded
  renders default to false, failing closed; enforcement of stored locks is
  unaffected either way
- Record the previous locks alongside the new ones and name the transitions in
  the audit description, so the log answers who locked what without expanding
  metadata. Forward the request for IP / user-agent capture

* fix(tables): resolve the lock flag with the same context on both gates

The page passed userId/orgId while the PATCH gate resolved the flag with no
context, so an org- or user-targeted rollout would show the settings panel and
then 403 on save — the rollout path the previous commit exists to enable.

Both now key on the workspace's host organization rather than the viewer's
active one, matching the convention getWorkspaceHostContextForViewer
documents: active-org describes the account, not the workspace host. The
route's lookup runs only when a lock is actually being turned on.
This commit is contained in:
Theodore Li
2026-07-25 17:47:11 -04:00
committed by GitHub
parent 0dcbc56ef6
commit 7f4cc38fb7
6 changed files with 86 additions and 29 deletions
+16 -3
View File
@@ -18,6 +18,7 @@ import {
} from '@/lib/table'
import { getWorkspaceTableLimits } from '@/lib/table/billing'
import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import {
accessError,
checkAccess,
@@ -154,8 +155,20 @@ export const PATCH = withRouteHandler(
const flag = TABLE_LOCK_FLAGS[kind]
return validated.locks?.[flag] === true && !table.locks[flag]
})
if (enablesALock && !(await isFeatureEnabled('table-locks'))) {
return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 })
if (enablesALock) {
// Resolve with the same context the page uses to decide whether to
// show the panel — keyed on the workspace's host organization, not
// the viewer's active one. Without it an org- or user-targeted
// rollout would open the panel and then 403 on save. Looked up only
// on the enabling path, so an unlock never pays for it.
const workspace = await getWorkspaceWithOwner(table.workspaceId)
const enabled = await isFeatureEnabled('table-locks', {
userId: authResult.userId,
orgId: workspace?.organizationId ?? undefined,
})
if (!enabled) {
return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 })
}
}
const adminResult = await checkAccess(tableId, authResult.userId, 'admin')
if (!adminResult.ok) {
@@ -164,7 +177,7 @@ export const PATCH = withRouteHandler(
{ status: 403 }
)
}
await updateTableLocks(tableId, validated.locks, authResult.userId, requestId)
await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request)
}
if (validated.name !== undefined) {
@@ -60,10 +60,16 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string
if (locked.length === LOCK_FIELDS.length) {
return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' }
}
// Only the exact three-lock shape is Append-only — with the schema locked too
// the chip would claim columns are mutable when they aren't.
if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked && !locks.schemaLocked) {
return { name: 'Append-only', detail: 'rows can be added, but not edited or deleted.' }
// Append-only describes the row semantics — adding is the only thing left.
// A schema lock on top doesn't change that, so it keeps the name and is
// called out in the detail rather than demoted to the generic case.
if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked) {
return {
name: 'Append-only',
detail: locks.schemaLocked
? 'rows can be added, but not edited or deleted, and columns are locked.'
: 'rows can be added, but not edited or deleted.',
}
}
return { name: 'Locked', detail: `${locked.join(', ')} locked.` }
}
@@ -1,5 +1,8 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { getSession } from '@/lib/auth'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading'
import { Table } from './table'
@@ -7,15 +10,35 @@ export const metadata: Metadata = {
title: 'Table',
}
interface TablePageProps {
params: Promise<{ workspaceId: string }>
}
/**
* Table-detail page entry. `Table` reads URL query params via nuqs (which uses
* `useSearchParams` internally), so it must sit under a Suspense boundary. The
* fallback renders the real chrome so a suspend never shows a blank frame.
*
* The lock flag is resolved here rather than mirrored into a `NEXT_PUBLIC_` var:
* gating lives in AppConfig, which has no client counterpart, so resolving it
* server-side is the only way its org/user clauses reach the UI. The org is the
* workspace's host organization, not the viewer's active one — the same key the
* PATCH gate uses, so the panel can't open onto a Save that 403s. Both
* `getSession` and the host context are request-memoized, so this reuses the
* layout's reads.
*/
export default function TablePage() {
export default async function TablePage({ params }: TablePageProps) {
const [{ workspaceId }, session] = await Promise.all([params, getSession()])
const userId = session?.user?.id
const host = userId ? await getWorkspaceHostContextForViewer(workspaceId, userId) : null
const tableLocksEnabled = await isFeatureEnabled('table-locks', {
userId,
orgId: host?.hostOrganizationId ?? undefined,
})
return (
<Suspense fallback={<TableLoading />}>
<Table />
<Table tableLocksEnabled={tableLocksEnabled} />
</Suspense>
)
}
@@ -8,7 +8,6 @@ import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { captureEvent } from '@/lib/posthog/client'
import type {
ColumnDefinition,
@@ -78,14 +77,6 @@ import { generateColumnName } from './utils'
const logger = createLogger('Table')
/**
* Client mirror of the server's `table-locks` flag. Hides the settings entry
* point when locks can't be set, so the panel never opens onto a Save that
* 403s. An already-locked table still shows its state regardless, so the
* gating stays legible if the flag is turned off after locks were applied.
*/
const tableLocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_TABLE_LOCKS'))
/** Blocked-action toasts carry a button, so they linger past the 5s default. */
const BLOCKED_TOAST_MS = 8000
@@ -96,6 +87,13 @@ interface TableProps {
/** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */
workspaceId?: string
tableId?: string
/**
* Whether an admin may CHANGE locks, resolved server-side by the page (the
* flag's gating lives in AppConfig and has no client counterpart). Defaults
* to false so embedded renders, which have no server resolution, fail closed
* — enforcement of stored locks is unaffected either way.
*/
tableLocksEnabled?: boolean
}
/**
@@ -152,6 +150,7 @@ export function Table({
embedded,
workspaceId: propWorkspaceId,
tableId: propTableId,
tableLocksEnabled = false,
}: TableProps = {}) {
const params = useParams()
const router = useRouter()
@@ -644,16 +643,15 @@ export function Table({
const headerActions = useMemo(() => {
if (!tableData) return undefined
// Header space is for state, not for settings: the chip appears only once
// something is actually locked, and names the mode so it reads at a glance.
// Reaching the panel on an unlocked table is the dropdown's job.
const anyLocked = lockedNouns(tableData.locks).length > 0
// Name the mode when locked so the state is legible on open; admins get the
// entry point on an unlocked table only where the feature is enabled —
// otherwise the panel opens and Save 403s against the server-side gate.
const lockLabel = anyLocked ? describeLocks(tableData.locks).name : 'Lock settings'
return [
...(anyLocked || (userPermissions.canAdmin && tableLocksEnabled)
...(anyLocked
? [
{
label: lockLabel,
label: describeLocks(tableData.locks).name,
icon: Lock,
onClick: () =>
userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'),
-2
View File
@@ -572,7 +572,6 @@ export const env = createEnv({
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks (deploy-as-block) settings on self-hosted
NEXT_PUBLIC_TABLE_LOCKS: z.boolean().optional(), // Surface the per-table mutation-lock settings UI (client mirror of the server-side TABLE_LOCKS gate)
NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
@@ -614,7 +613,6 @@ export const env = createEnv({
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED,
NEXT_PUBLIC_TABLE_LOCKS: process.env.NEXT_PUBLIC_TABLE_LOCKS,
NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED,
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,
+22 -3
View File
@@ -27,6 +27,8 @@ import type { DbTransaction } from '@/lib/table/planner'
import { setTableTxTimeouts } from '@/lib/table/tx'
import {
type CreateTableData,
TABLE_LOCK_FLAGS,
TABLE_LOCK_KINDS,
type TableDefinition,
type TableLocks,
type TableMetadata,
@@ -627,9 +629,13 @@ export async function updateTableLocks(
tableId: string,
partial: Partial<TableLocks>,
actingUserId: string,
requestId: string
requestId: string,
/** Forwarded to the audit record for IP / user-agent capture. */
request?: { headers: { get(name: string): string | null } }
): Promise<TableDefinition> {
let previousLocks: TableLocks = UNLOCKED_TABLE_LOCKS
const updated = await withLockedTable(tableId, async (table, trx) => {
previousLocks = table.locks
const nextLocks: TableLocks = { ...table.locks, ...partial }
const now = new Date()
await trx
@@ -639,6 +645,18 @@ export async function updateTableLocks(
return { ...table, locks: nextLocks, updatedAt: now }
})
// Name the transitions in the description so the audit list is readable
// without expanding metadata — "who locked my production table" is the
// question this feature exists to answer.
const flipped = TABLE_LOCK_KINDS.filter(
(kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== updated.locks[TABLE_LOCK_FLAGS[kind]]
)
const description = flipped.length
? `Table locks changed: ${flipped
.map((kind) => `${kind} ${updated.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`)
.join(', ')}`
: 'Updated table locks (no change)'
recordAudit({
workspaceId: updated.workspaceId,
actorId: actingUserId,
@@ -646,8 +664,9 @@ export async function updateTableLocks(
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: updated.name,
description: 'Updated table locks',
metadata: { op: 'update_locks', locks: updated.locks },
description,
metadata: { op: 'update_locks', before: previousLocks, after: updated.locks },
...(request ? { request } : {}),
})
await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => {