Skip to content

Commit 7f4cc38

Browse files
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.
1 parent 0dcbc56 commit 7f4cc38

6 files changed

Lines changed: 86 additions & 29 deletions

File tree

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@/lib/table'
1919
import { getWorkspaceTableLimits } from '@/lib/table/billing'
2020
import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types'
21+
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
2122
import {
2223
accessError,
2324
checkAccess,
@@ -154,8 +155,20 @@ export const PATCH = withRouteHandler(
154155
const flag = TABLE_LOCK_FLAGS[kind]
155156
return validated.locks?.[flag] === true && !table.locks[flag]
156157
})
157-
if (enablesALock && !(await isFeatureEnabled('table-locks'))) {
158-
return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 })
158+
if (enablesALock) {
159+
// Resolve with the same context the page uses to decide whether to
160+
// show the panel — keyed on the workspace's host organization, not
161+
// the viewer's active one. Without it an org- or user-targeted
162+
// rollout would open the panel and then 403 on save. Looked up only
163+
// on the enabling path, so an unlock never pays for it.
164+
const workspace = await getWorkspaceWithOwner(table.workspaceId)
165+
const enabled = await isFeatureEnabled('table-locks', {
166+
userId: authResult.userId,
167+
orgId: workspace?.organizationId ?? undefined,
168+
})
169+
if (!enabled) {
170+
return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 })
171+
}
159172
}
160173
const adminResult = await checkAccess(tableId, authResult.userId, 'admin')
161174
if (!adminResult.ok) {
@@ -164,7 +177,7 @@ export const PATCH = withRouteHandler(
164177
{ status: 403 }
165178
)
166179
}
167-
await updateTableLocks(tableId, validated.locks, authResult.userId, requestId)
180+
await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request)
168181
}
169182

170183
if (validated.name !== undefined) {

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,16 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string
6060
if (locked.length === LOCK_FIELDS.length) {
6161
return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' }
6262
}
63-
// Only the exact three-lock shape is Append-only — with the schema locked too
64-
// the chip would claim columns are mutable when they aren't.
65-
if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked && !locks.schemaLocked) {
66-
return { name: 'Append-only', detail: 'rows can be added, but not edited or deleted.' }
63+
// Append-only describes the row semantics — adding is the only thing left.
64+
// A schema lock on top doesn't change that, so it keeps the name and is
65+
// called out in the detail rather than demoted to the generic case.
66+
if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked) {
67+
return {
68+
name: 'Append-only',
69+
detail: locks.schemaLocked
70+
? 'rows can be added, but not edited or deleted, and columns are locked.'
71+
: 'rows can be added, but not edited or deleted.',
72+
}
6773
}
6874
return { name: 'Locked', detail: `${locked.join(', ')} locked.` }
6975
}
Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,44 @@
11
import { Suspense } from 'react'
22
import type { Metadata } from 'next'
3+
import { getSession } from '@/lib/auth'
4+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
5+
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
36
import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading'
47
import { Table } from './table'
58

69
export const metadata: Metadata = {
710
title: 'Table',
811
}
912

13+
interface TablePageProps {
14+
params: Promise<{ workspaceId: string }>
15+
}
16+
1017
/**
1118
* Table-detail page entry. `Table` reads URL query params via nuqs (which uses
1219
* `useSearchParams` internally), so it must sit under a Suspense boundary. The
1320
* fallback renders the real chrome so a suspend never shows a blank frame.
21+
*
22+
* The lock flag is resolved here rather than mirrored into a `NEXT_PUBLIC_` var:
23+
* gating lives in AppConfig, which has no client counterpart, so resolving it
24+
* server-side is the only way its org/user clauses reach the UI. The org is the
25+
* workspace's host organization, not the viewer's active one — the same key the
26+
* PATCH gate uses, so the panel can't open onto a Save that 403s. Both
27+
* `getSession` and the host context are request-memoized, so this reuses the
28+
* layout's reads.
1429
*/
15-
export default function TablePage() {
30+
export default async function TablePage({ params }: TablePageProps) {
31+
const [{ workspaceId }, session] = await Promise.all([params, getSession()])
32+
const userId = session?.user?.id
33+
const host = userId ? await getWorkspaceHostContextForViewer(workspaceId, userId) : null
34+
const tableLocksEnabled = await isFeatureEnabled('table-locks', {
35+
userId,
36+
orgId: host?.hostOrganizationId ?? undefined,
37+
})
38+
1639
return (
1740
<Suspense fallback={<TableLoading />}>
18-
<Table />
41+
<Table tableLocksEnabled={tableLocksEnabled} />
1942
</Suspense>
2043
)
2144
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { useParams, useRouter } from 'next/navigation'
88
import { useQueryStates } from 'nuqs'
99
import { usePostHog } from 'posthog-js/react'
1010
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
11-
import { getEnv, isTruthy } from '@/lib/core/config/env'
1211
import { captureEvent } from '@/lib/posthog/client'
1312
import type {
1413
ColumnDefinition,
@@ -78,14 +77,6 @@ import { generateColumnName } from './utils'
7877

7978
const logger = createLogger('Table')
8079

81-
/**
82-
* Client mirror of the server's `table-locks` flag. Hides the settings entry
83-
* point when locks can't be set, so the panel never opens onto a Save that
84-
* 403s. An already-locked table still shows its state regardless, so the
85-
* gating stays legible if the flag is turned off after locks were applied.
86-
*/
87-
const tableLocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_TABLE_LOCKS'))
88-
8980
/** Blocked-action toasts carry a button, so they linger past the 5s default. */
9081
const BLOCKED_TOAST_MS = 8000
9182

@@ -96,6 +87,13 @@ interface TableProps {
9687
/** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */
9788
workspaceId?: string
9889
tableId?: string
90+
/**
91+
* Whether an admin may CHANGE locks, resolved server-side by the page (the
92+
* flag's gating lives in AppConfig and has no client counterpart). Defaults
93+
* to false so embedded renders, which have no server resolution, fail closed
94+
* — enforcement of stored locks is unaffected either way.
95+
*/
96+
tableLocksEnabled?: boolean
9997
}
10098

10199
/**
@@ -152,6 +150,7 @@ export function Table({
152150
embedded,
153151
workspaceId: propWorkspaceId,
154152
tableId: propTableId,
153+
tableLocksEnabled = false,
155154
}: TableProps = {}) {
156155
const params = useParams()
157156
const router = useRouter()
@@ -644,16 +643,15 @@ export function Table({
644643

645644
const headerActions = useMemo(() => {
646645
if (!tableData) return undefined
646+
// Header space is for state, not for settings: the chip appears only once
647+
// something is actually locked, and names the mode so it reads at a glance.
648+
// Reaching the panel on an unlocked table is the dropdown's job.
647649
const anyLocked = lockedNouns(tableData.locks).length > 0
648-
// Name the mode when locked so the state is legible on open; admins get the
649-
// entry point on an unlocked table only where the feature is enabled —
650-
// otherwise the panel opens and Save 403s against the server-side gate.
651-
const lockLabel = anyLocked ? describeLocks(tableData.locks).name : 'Lock settings'
652650
return [
653-
...(anyLocked || (userPermissions.canAdmin && tableLocksEnabled)
651+
...(anyLocked
654652
? [
655653
{
656-
label: lockLabel,
654+
label: describeLocks(tableData.locks).name,
657655
icon: Lock,
658656
onClick: () =>
659657
userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'),

apps/sim/lib/core/config/env.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,6 @@ export const env = createEnv({
572572
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
573573
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
574574
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks (deploy-as-block) settings on self-hosted
575-
NEXT_PUBLIC_TABLE_LOCKS: z.boolean().optional(), // Surface the per-table mutation-lock settings UI (client mirror of the server-side TABLE_LOCKS gate)
576575
NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
577576
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
578577
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({
614613
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
615614
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
616615
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED,
617-
NEXT_PUBLIC_TABLE_LOCKS: process.env.NEXT_PUBLIC_TABLE_LOCKS,
618616
NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED,
619617
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
620618
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,

apps/sim/lib/table/service.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import type { DbTransaction } from '@/lib/table/planner'
2727
import { setTableTxTimeouts } from '@/lib/table/tx'
2828
import {
2929
type CreateTableData,
30+
TABLE_LOCK_FLAGS,
31+
TABLE_LOCK_KINDS,
3032
type TableDefinition,
3133
type TableLocks,
3234
type TableMetadata,
@@ -627,9 +629,13 @@ export async function updateTableLocks(
627629
tableId: string,
628630
partial: Partial<TableLocks>,
629631
actingUserId: string,
630-
requestId: string
632+
requestId: string,
633+
/** Forwarded to the audit record for IP / user-agent capture. */
634+
request?: { headers: { get(name: string): string | null } }
631635
): Promise<TableDefinition> {
636+
let previousLocks: TableLocks = UNLOCKED_TABLE_LOCKS
632637
const updated = await withLockedTable(tableId, async (table, trx) => {
638+
previousLocks = table.locks
633639
const nextLocks: TableLocks = { ...table.locks, ...partial }
634640
const now = new Date()
635641
await trx
@@ -639,15 +645,28 @@ export async function updateTableLocks(
639645
return { ...table, locks: nextLocks, updatedAt: now }
640646
})
641647

648+
// Name the transitions in the description so the audit list is readable
649+
// without expanding metadata — "who locked my production table" is the
650+
// question this feature exists to answer.
651+
const flipped = TABLE_LOCK_KINDS.filter(
652+
(kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== updated.locks[TABLE_LOCK_FLAGS[kind]]
653+
)
654+
const description = flipped.length
655+
? `Table locks changed: ${flipped
656+
.map((kind) => `${kind} ${updated.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`)
657+
.join(', ')}`
658+
: 'Updated table locks (no change)'
659+
642660
recordAudit({
643661
workspaceId: updated.workspaceId,
644662
actorId: actingUserId,
645663
action: AuditAction.TABLE_UPDATED,
646664
resourceType: AuditResourceType.TABLE,
647665
resourceId: tableId,
648666
resourceName: updated.name,
649-
description: 'Updated table locks',
650-
metadata: { op: 'update_locks', locks: updated.locks },
667+
description,
668+
metadata: { op: 'update_locks', before: previousLocks, after: updated.locks },
669+
...(request ? { request } : {}),
651670
})
652671

653672
await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => {

0 commit comments

Comments
 (0)