Skip to content

Commit 32992f3

Browse files
committed
refactor(tables): take capability from grants, not the permission context
Step 4, and the step where a mistake would actually change who can do what. 45 permission reads converted: - 38 `userPermissions.canEdit` -> `grants.write`. Mechanically, with no exceptions: `grants.run` is `canEdit || canRead`, so a single slip on one of the run/stop controls would have handed a read-only member the ability to trigger workflow runs from the panel. Verified zero `grants.run` references survive in the tables tree outside one TSDoc that explains the hazard. - 4 `userPermissions.canAdmin` -> `grants.manage`, all of them lock settings. - 2 `userPermissions.isLoading` -> `!grants.settled`. This is the one that needed the axis extension: the lock notice is a one-shot latched on `announcedLockTableIdRef`, so firing it before capabilities resolve permanently yields a toast whose "Lock settings" action is missing. `page.tsx` is a Server Component and cannot read a React context, so the table route gains a ~40-line client shell (`table-route.tsx`) that resolves the axes and mounts the view — the same shape as `fullscreen-file-view.tsx`. Reading `useParams()` there is legitimate where it was not inside the table: a route shell exists exactly once per page by definition, which is precisely the property the table lost when the panel started mounting it too. The panel already computed `grants` for its file and log branches; the table branch now passes the same value.
1 parent fdb037f commit 32992f3

5 files changed

Lines changed: 112 additions & 69 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ export const ResourceContent = memo(function ResourceContent({
314314
<Table
315315
key={resource.id}
316316
host='panel'
317+
grants={grants}
317318
workspaceId={workspaceId}
318319
tableId={resource.id}
319320
viewsEnabled={tableViewsEnabled}

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

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ import type {
6363
import { getColumnId } from '@/lib/table/column-keys'
6464
import { columnTypeOf } from '@/lib/table/column-types'
6565
import { TABLE_LIMITS } from '@/lib/table/constants'
66-
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
6766
import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
6867
import { useTimezone } from '@/hooks/queries/general-settings'
6968
import {
@@ -83,6 +82,7 @@ import {
8382
import { useAddToChat } from '@/hooks/use-add-to-chat'
8483
import { useInlineRename } from '@/hooks/use-inline-rename'
8584
import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo'
85+
import type { ResourceGrants } from '@/resources'
8686
import type { ChatContext } from '@/stores/panel'
8787
import type { DeletedRowSnapshot } from '@/stores/table/types'
8888
import { useContextMenu, useTable } from '../../hooks'
@@ -171,6 +171,12 @@ interface TableGridProps {
171171
*/
172172
workspaceId: string
173173
tableId: string
174+
/**
175+
* What this viewer may do. `grants.write` replaces every `canEdit` read: it is
176+
* `canEdit` exactly, and deliberately NOT `grants.run`, which is
177+
* `canEdit || canRead` and would hand a read-only member the run controls.
178+
*/
179+
grants: ResourceGrants
174180
embedded?: boolean
175181
/** Remote collaborators' cell selections, rendered as presence overlays. */
176182
remoteSelections: RemoteTableSelection[]
@@ -425,6 +431,7 @@ async function chunkBatchUpdates(
425431
export function TableGrid({
426432
workspaceId,
427433
tableId,
434+
grants,
428435
embedded,
429436
remoteSelections,
430437
emitCellSelection,
@@ -651,27 +658,26 @@ export function TableGrid({
651658
if (measured > 0 && Math.abs(measured - rowHeight) >= 0.5) setRowHeight(measured)
652659
}, [isLoadingTable, isLoadingRows, rowHeight])
653660

654-
const userPermissions = useUserPermissionsContext()
655-
const canEditRef = useRef(userPermissions.canEdit)
656-
canEditRef.current = userPermissions.canEdit
661+
const canEditRef = useRef(grants.write)
662+
canEditRef.current = grants.write
657663

658-
const canEditCell = userPermissions.canEdit && !locks?.updateLocked
659-
const canDeleteRow = userPermissions.canEdit && !locks?.deleteLocked
660-
const canMutateSchema = userPermissions.canEdit && !locks?.schemaLocked
664+
const canEditCell = grants.write && !locks?.updateLocked
665+
const canDeleteRow = grants.write && !locks?.deleteLocked
666+
const canMutateSchema = grants.write && !locks?.schemaLocked
661667
// Dropping or retyping a column rewrites every row's data, so `assertColumnDestructive`
662668
// requires the delete lock clear too — mirror that here or the affordance
663669
// stays live on an append-only table and only fails on click.
664670
const canDestroyColumn = canMutateSchema && !locks?.deleteLocked
665671
// Duplicate inserts a full copied row in one shot, so unlike the blank-row
666672
// paths it needs the insert lock only — it is valid on an append-only table.
667-
const canInsertFullRow = userPermissions.canEdit && !locks?.insertLocked
673+
const canInsertFullRow = grants.write && !locks?.insertLocked
668674
// Manual grid entry is "add an empty row, then type into its cells" — the
669675
// typing is an update. So a *useful* manual add needs BOTH insert and update
670676
// unlocked; on an append-only table (update locked) it would leave a blank
671677
// row the user can't fill. The control stays visible and explains itself via
672678
// `onBlockedAction`. Full-row inserts still flow through CSV import / API /
673679
// blocks / Mothership, which the insert lock alone governs server-side.
674-
const canManualAddRow = userPermissions.canEdit && !locks?.insertLocked && !locks?.updateLocked
680+
const canManualAddRow = grants.write && !locks?.insertLocked && !locks?.updateLocked
675681
const canEditCellRef = useRef(canEditCell)
676682
canEditCellRef.current = canEditCell
677683
const canManualAddRowRef = useRef(canManualAddRow)
@@ -4292,7 +4298,7 @@ export function TableGrid({
42924298
groupName={workflowGroupById.get(g.groupId)?.name}
42934299
onSelectGroup={handleGroupSelect}
42944300
onOpenConfig={() => handleConfigureWorkflowGroup(g.groupId)}
4295-
onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined}
4301+
onRunColumn={grants.write ? handleRunColumn : undefined}
42964302
hasActiveFilter={Boolean(effectiveFilter)}
42974303
selectedRowIds={selectedRowIds}
42984304
// Every locked action passes its blocked handler rather
@@ -4301,28 +4307,28 @@ export function TableGrid({
43014307
// the whole menu — and each item should explain the lock
43024308
// rather than silently vanish or fail with a 423 toast.
43034309
onInsertLeft={
4304-
!userPermissions.canEdit
4310+
!grants.write
43054311
? undefined
43064312
: canMutateSchema
43074313
? handleInsertColumnLeft
43084314
: handleBlockedAddColumn
43094315
}
43104316
onInsertRight={
4311-
!userPermissions.canEdit
4317+
!grants.write
43124318
? undefined
43134319
: canMutateSchema
43144320
? handleInsertColumnRight
43154321
: handleBlockedAddColumn
43164322
}
43174323
onDeleteColumn={
4318-
!userPermissions.canEdit
4324+
!grants.write
43194325
? undefined
43204326
: canDestroyColumn
43214327
? handleDeleteColumn
43224328
: handleBlockedDeleteColumn
43234329
}
43244330
onDeleteGroup={
4325-
!userPermissions.canEdit
4331+
!grants.write
43264332
? undefined
43274333
: canDestroyColumn
43284334
? handleDeleteWorkflowGroup
@@ -4333,21 +4339,13 @@ export function TableGrid({
43334339
? undefined
43344340
: handleViewWorkflow
43354341
}
4336-
readOnly={!userPermissions.canEdit}
4337-
onDragStart={
4338-
userPermissions.canEdit ? handleColumnDragStart : undefined
4339-
}
4340-
onDragOver={
4341-
userPermissions.canEdit ? handleColumnDragOver : undefined
4342-
}
4343-
onDragEnd={
4344-
userPermissions.canEdit ? handleColumnDragEnd : undefined
4345-
}
4346-
onDragLeave={
4347-
userPermissions.canEdit ? handleColumnDragLeave : undefined
4348-
}
4342+
readOnly={!grants.write}
4343+
onDragStart={grants.write ? handleColumnDragStart : undefined}
4344+
onDragOver={grants.write ? handleColumnDragOver : undefined}
4345+
onDragEnd={grants.write ? handleColumnDragEnd : undefined}
4346+
onDragLeave={grants.write ? handleColumnDragLeave : undefined}
43494347
isPinned={firstCol ? pinnedColumnSet.has(firstCol.key) : false}
4350-
onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined}
4348+
onPinToggle={grants.write ? handlePinToggle : undefined}
43514349
stickyLeft={stickyLeft}
43524350
isLastPinned={lastCol?.key === lastPinnedColKey}
43534351
/>
@@ -4370,7 +4368,7 @@ export function TableGrid({
43704368
/>
43714369
)
43724370
})}
4373-
{userPermissions.canEdit && (
4371+
{grants.write && (
43744372
<th className='border-[var(--border)] border-b bg-[var(--bg)] px-2 py-[5px]' />
43754373
)}
43764374
</tr>
@@ -4394,7 +4392,7 @@ export function TableGrid({
43944392
// open-config — all metadata, not schema. The schema lock is
43954393
// enforced per-action instead (insert/delete below), and a
43964394
// rename attempt surfaces the server's 423 as a toast.
4397-
readOnly={!userPermissions.canEdit}
4395+
readOnly={!grants.write}
43984396
isRenaming={columnRename.editingId === column.key}
43994397
isColumnSelected={
44004398
isColumnSelection &&
@@ -4434,13 +4432,13 @@ export function TableGrid({
44344432
onOpenConfig={handleConfigureColumn}
44354433
onViewWorkflow={handleViewWorkflow}
44364434
isPinned={colIsPinned}
4437-
onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined}
4435+
onPinToggle={grants.write ? handlePinToggle : undefined}
44384436
stickyLeft={colStickyLeft}
44394437
isLastPinned={column.key === lastPinnedColKey}
44404438
/>
44414439
)
44424440
})}
4443-
{userPermissions.canEdit && (
4441+
{grants.write && (
44444442
<NewColumnDropdown
44454443
trigger='inline-header'
44464444
disabled={addColumnMutation.isPending}
@@ -4581,7 +4579,7 @@ export function TableGrid({
45814579
</>
45824580
)}
45834581
</div>
4584-
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && (
4582+
{!isLoadingTable && !isLoadingRows && grants.write && (
45854583
<AddRowButton onClick={handleAddRowClick} />
45864584
)}
45874585
</div>
@@ -4603,17 +4601,17 @@ export function TableGrid({
46034601
canEditCell={!contextMenuIsWorkflowColumn}
46044602
selectedRowCount={selectedRowCount}
46054603
onRunWorkflows={
4606-
userPermissions.canEdit && hasWorkflowColumns && contextMenuStats.hasIncompleteOrFailed
4604+
grants.write && hasWorkflowColumns && contextMenuStats.hasIncompleteOrFailed
46074605
? handleRunWorkflowsOnSelection
46084606
: undefined
46094607
}
46104608
onRefreshWorkflows={
4611-
userPermissions.canEdit && hasWorkflowColumns && contextMenuStats.hasCompleted
4609+
grants.write && hasWorkflowColumns && contextMenuStats.hasCompleted
46124610
? handleRefreshWorkflowsOnSelection
46134611
: undefined
46144612
}
46154613
onStopWorkflows={
4616-
userPermissions.canEdit && hasWorkflowColumns ? handleStopWorkflowsOnSelection : undefined
4614+
grants.write && hasWorkflowColumns ? handleStopWorkflowsOnSelection : undefined
46174615
}
46184616
runningInSelectionCount={runningInContextSelection}
46194617
hasWorkflowColumns={hasWorkflowColumns}

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

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getSession } from '@/lib/auth'
44
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
55
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
66
import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading'
7-
import { Table } from './table'
7+
import { TableRoute } from './table-route'
88

99
export const metadata: Metadata = {
1010
title: 'Table',
@@ -15,7 +15,7 @@ interface TablePageProps {
1515
}
1616

1717
/**
18-
* Table-detail page entry. `Table` reads URL query params via nuqs (which uses
18+
* Table-detail page entry. The table reads URL query params via nuqs (which uses
1919
* `useSearchParams` internally), so it must sit under a Suspense boundary. The
2020
* fallback renders the real chrome so a suspend never shows a blank frame.
2121
*
@@ -39,13 +39,7 @@ export default async function TablePage({ params }: TablePageProps) {
3939

4040
return (
4141
<Suspense fallback={<TableLoading />}>
42-
<Table
43-
host='page'
44-
workspaceId={workspaceId}
45-
tableId={tableId}
46-
tableLocksEnabled={tableLocksEnabled}
47-
viewsEnabled={viewsEnabled}
48-
/>
42+
<TableRoute tableLocksEnabled={tableLocksEnabled} viewsEnabled={viewsEnabled} />
4943
</Suspense>
5044
)
5145
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client'
2+
3+
import { useMemo } from 'react'
4+
import { useParams } from 'next/navigation'
5+
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
6+
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table'
7+
import { grantsFromPermissions } from '@/resources'
8+
9+
interface TableRouteProps {
10+
/** `table-locks` — resolved server-side; AppConfig has no client counterpart. */
11+
tableLocksEnabled: boolean
12+
/** `table-views` — same. */
13+
viewsEnabled: boolean
14+
}
15+
16+
/**
17+
* The table page's client shell: it resolves the axes the route can supply and
18+
* mounts the view.
19+
*
20+
* Exists because `page.tsx` is a Server Component and `grants` comes from a React
21+
* context. Reading `useParams()` here is legitimate where it was not inside the
22+
* table itself — a route shell exists exactly once per page by definition,
23+
* whereas the table is also mounted in a panel beside it.
24+
*
25+
* Mirrors `files/[fileId]/view/fullscreen-file-view.tsx`.
26+
*/
27+
export function TableRoute({ tableLocksEnabled, viewsEnabled }: TableRouteProps) {
28+
const params = useParams()
29+
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : ''
30+
const tableId = typeof params?.tableId === 'string' ? params.tableId : ''
31+
const permissions = useUserPermissionsContext()
32+
33+
const grants = useMemo(() => grantsFromPermissions(permissions), [permissions])
34+
35+
return (
36+
<Table
37+
host='page'
38+
grants={grants}
39+
workspaceId={workspaceId}
40+
tableId={tableId}
41+
tableLocksEnabled={tableLocksEnabled}
42+
viewsEnabled={viewsEnabled}
43+
/>
44+
)
45+
}

0 commit comments

Comments
 (0)