Skip to content

Commit f978389

Browse files
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
1 parent cfb4d2f commit f978389

5 files changed

Lines changed: 47 additions & 17 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export const PATCH = withRouteHandler(
164164
{ status: 403 }
165165
)
166166
}
167-
await updateTableLocks(tableId, validated.locks, authResult.userId, requestId)
167+
await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request)
168168
}
169169

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

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Suspense } from 'react'
22
import type { Metadata } from 'next'
3+
import { getSession } from '@/lib/auth'
4+
import { getActiveOrganizationId } from '@/lib/auth/session-response'
5+
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
36
import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading'
47
import { Table } from './table'
58

@@ -11,11 +14,22 @@ export const metadata: Metadata = {
1114
* Table-detail page entry. `Table` reads URL query params via nuqs (which uses
1215
* `useSearchParams` internally), so it must sit under a Suspense boundary. The
1316
* fallback renders the real chrome so a suspend never shows a blank frame.
17+
*
18+
* The lock flag is resolved here rather than mirrored into a `NEXT_PUBLIC_` var:
19+
* gating lives in AppConfig, which has no client counterpart, so resolving it
20+
* server-side is the only way its org/user clauses reach the UI. `getSession`
21+
* is request-cached, so this reuses the layout's read.
1422
*/
15-
export default function TablePage() {
23+
export default async function TablePage() {
24+
const session = await getSession()
25+
const tableLocksEnabled = await isFeatureEnabled('table-locks', {
26+
userId: session?.user?.id,
27+
orgId: getActiveOrganizationId(session) ?? undefined,
28+
})
29+
1630
return (
1731
<Suspense fallback={<TableLoading />}>
18-
<Table />
32+
<Table tableLocksEnabled={tableLocksEnabled} />
1933
</Suspense>
2034
)
2135
}

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

Lines changed: 8 additions & 9 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()

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)