From cfb4d2f2b66e42b4f06274f74d0e42047694e4fa Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 14:08:51 -0700 Subject: [PATCH 1/3] 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 --- .../[workspaceId]/tables/[tableId]/lock-copy.ts | 14 ++++++++++---- .../[workspaceId]/tables/[tableId]/table.tsx | 11 +++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts index 359bcd25178..f87a5bdbcd1 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -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.` } } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 006c74c8307..e79e49f094f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -644,16 +644,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'), From f978389dfc9a1de9802b6ce88d088f04180d0f9e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 14:16:05 -0700 Subject: [PATCH 2/3] improvement(tables): resolve the lock flag server-side and record lock changes fully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/sim/app/api/table/[tableId]/route.ts | 2 +- .../[workspaceId]/tables/[tableId]/page.tsx | 18 +++++++++++-- .../[workspaceId]/tables/[tableId]/table.tsx | 17 ++++++------- apps/sim/lib/core/config/env.ts | 2 -- apps/sim/lib/table/service.ts | 25 ++++++++++++++++--- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 5fe0f4add09..2bee278eec8 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -164,7 +164,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) { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 9f3c382c3ff..9ae53c91cfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,5 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { getSession } from '@/lib/auth' +import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading' import { Table } from './table' @@ -11,11 +14,22 @@ export const metadata: Metadata = { * 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. `getSession` + * is request-cached, so this reuses the layout's read. */ -export default function TablePage() { +export default async function TablePage() { + const session = await getSession() + const tableLocksEnabled = await isFeatureEnabled('table-locks', { + userId: session?.user?.id, + orgId: getActiveOrganizationId(session) ?? undefined, + }) + return ( }> - +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index e79e49f094f..1c0daa5fed7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -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() diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5c135e7d12e..76b87384171 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -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, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 3a76842f2ed..ef55b56ca55 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -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, actingUserId: string, - requestId: string + requestId: string, + /** Forwarded to the audit record for IP / user-agent capture. */ + request?: { headers: { get(name: string): string | null } } ): Promise { + 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) => { From 0b7222724918a98bf2b8704ab556b6302b49ead2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 14:39:17 -0700 Subject: [PATCH 3/3] fix(tables): resolve the lock flag with the same context on both gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/app/api/table/[tableId]/route.ts | 17 ++++++++++++-- .../[workspaceId]/tables/[tableId]/page.tsx | 23 +++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 2bee278eec8..ec78330932a 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -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) { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 9ae53c91cfe..c35d081c954 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,8 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { getSession } from '@/lib/auth' -import { getActiveOrganizationId } from '@/lib/auth/session-response' 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' @@ -10,6 +10,10 @@ 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 @@ -17,14 +21,19 @@ export const metadata: Metadata = { * * 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. `getSession` - * is request-cached, so this reuses the layout's read. + * 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 async function TablePage() { - const session = await getSession() +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: session?.user?.id, - orgId: getActiveOrganizationId(session) ?? undefined, + userId, + orgId: host?.hostOrganizationId ?? undefined, }) return (