Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions apps/sim/app/api/table/[tableId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}

if (validated.name !== undefined) {
Expand Down
14 changes: 10 additions & 4 deletions apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.` }
}
Expand Down
27 changes: 25 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,44 @@
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'

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>
)
}
28 changes: 13 additions & 15 deletions apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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
}

/**
Expand Down Expand Up @@ -152,6 +150,7 @@ export function Table({
embedded,
workspaceId: propWorkspaceId,
tableId: propTableId,
tableLocksEnabled = false,
}: TableProps = {}) {
const params = useParams()
const router = useRouter()
Expand Down Expand Up @@ -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'),
Expand Down
2 changes: 0 additions & 2 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 22 additions & 3 deletions apps/sim/lib/table/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -639,15 +645,28 @@ 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,
action: AuditAction.TABLE_UPDATED,
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) => {
Expand Down
Loading