Skip to content

Commit 6ab3c6f

Browse files
committed
fix(resources): dismiss the upload overlay on a folder drop and re-check permission per item
The drag hook stops propagation on a drop it handles, so the page-level handler that cleared the upload overlay never ran and the chrome stayed up over the finished upload. Both consuming paths now share one dismissal. Drop the batch permission memo: each item in a bulk move or delete commits independently, so reusing one allow verdict let a revocation part-way through a batch go unseen by the remaining items. The workspace context is still resolved once per batch, which was the larger saving.
1 parent b02c146 commit 6ab3c6f

8 files changed

Lines changed: 63 additions & 214 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,18 @@ export function Files() {
323323
const uploading = uploadProgress.total > 0
324324
const [isDraggingOver, setIsDraggingOver] = useState(false)
325325
const dragCounterRef = useRef(0)
326+
/**
327+
* Takes down the "Drop to upload" overlay.
328+
*
329+
* Every path that consumes an OS file drag has to call this, including the one that never
330+
* reaches the page-level handler: a drop on a folder row is handled by the drag hook, which
331+
* stops propagation, so `handleDrop` below never runs and the counter it would have zeroed
332+
* keeps the overlay on screen over the finished upload.
333+
*/
334+
const dismissUploadOverlay = useCallback(() => {
335+
dragCounterRef.current = 0
336+
setIsDraggingOver(false)
337+
}, [])
326338
const [
327339
{ search: urlSearchTerm, type: typeFilter, size: sizeFilter, uploadedBy: uploadedByFilter },
328340
setFileFilters,
@@ -810,6 +822,7 @@ export function Files() {
810822
externalDrop: {
811823
matches: hasExternalFiles,
812824
onDropIntoFolder: (dataTransfer, targetFolderId) => {
825+
dismissUploadOverlay()
813826
const dropped = Array.from(dataTransfer.files ?? [])
814827
if (dropped.length > 0) void uploadFiles(dropped, targetFolderId)
815828
},
@@ -851,8 +864,7 @@ export function Files() {
851864
* began in — pulling the user out of the folder they just spring-opened to receive it.
852865
*/
853866
rowDragDropConfig.externalDropHandled()
854-
dragCounterRef.current = 0
855-
setIsDraggingOver(false)
867+
dismissUploadOverlay()
856868
const dropped = Array.from(e.dataTransfer.files)
857869
if (dropped.length > 0) await uploadFiles(dropped)
858870
}

apps/sim/lib/core/application/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@ export type {
2121
WorkspaceAuthorizationContext,
2222
WorkspaceAuthorizationOptions,
2323
WorkspaceDelegationPolicy,
24-
WorkspacePermissionCache,
2524
} from '@/lib/core/application/workspace-authorization'
2625
export {
2726
authorizeWorkspaceOperation,
28-
createWorkspacePermissionCache,
2927
DelegatedServiceAuthorizationError,
3028
DelegatedWorkspaceAuthorizationError,
3129
InsufficientWorkspacePermissionsError,

apps/sim/lib/core/application/workspace-authorization.ts

Lines changed: 7 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -27,65 +27,6 @@ export interface WorkspaceAuthorizationOptions<C extends WorkspaceAuthorizationC
2727
executor?: Pick<typeof db, 'select'>
2828
forUpdate?: boolean
2929
delegation?: WorkspaceDelegationPolicy<C>
30-
/**
31-
* Memo for the human-permission lookup, supplied by a caller that authorizes many items in one
32-
* operation. Ignored alongside `executor` or `forUpdate` — see
33-
* {@link createWorkspacePermissionCache}.
34-
*/
35-
permissionCache?: WorkspacePermissionCache
36-
}
37-
38-
export interface WorkspacePermissionCache {
39-
resolve(
40-
userId: string,
41-
workspaceId: string,
42-
workspaceOrganizationId: string | null
43-
): Promise<PermissionType | null>
44-
}
45-
46-
/**
47-
* Memoizes the effective-permission lookup across the items of one bulk operation.
48-
*
49-
* A batch authorizes every item separately — delegation scope is per-resource, so the check
50-
* cannot simply be hoisted out of the loop — but the human-permission half of it reads the same
51-
* `(user, workspace, organization)` triple every time, two queries deep. On a hundred-item
52-
* request that is two hundred round trips for a value that cannot change within the batch.
53-
*
54-
* Caller-owned and request-scoped on purpose: nothing here outlives the operation that created
55-
* it, so a permission changed between requests is always seen by the next one. Skipped entirely
56-
* when the caller passes its own `executor` (a transaction has its own snapshot to honour) or
57-
* `forUpdate` (that lookup takes a row lock, which is a side effect, not a read).
58-
*
59-
* Neither of the repo's two existing memo idioms fits. `coalesceLocally` evicts on settle, so a
60-
* sequential per-item loop would re-query every item. React `cache()` cannot be skipped per call
61-
* for the `executor`/`forUpdate` paths and has no request scope in the worker runtime. An
62-
* implicit process-wide memo on an authorization read is a lifetime worth refusing outright.
63-
*/
64-
export function createWorkspacePermissionCache(): WorkspacePermissionCache {
65-
const entries = new Map<string, Promise<PermissionType | null>>()
66-
return {
67-
resolve(userId, workspaceId, workspaceOrganizationId) {
68-
/** Structural, so no id can run into the next and answer another workspace's question. */
69-
const key = JSON.stringify([userId, workspaceId, workspaceOrganizationId])
70-
const cached = entries.get(key)
71-
if (cached) return cached
72-
/**
73-
* The in-flight promise is what gets stored, so concurrent items share one query rather
74-
* than racing to start their own. Evicted if it rejects: a transient database failure must
75-
* not become the permanent answer for the rest of the batch.
76-
*/
77-
const pending = resolveEffectiveWorkspacePermission(
78-
userId,
79-
workspaceId,
80-
workspaceOrganizationId
81-
).catch((error) => {
82-
entries.delete(key)
83-
throw error
84-
})
85-
entries.set(key, pending)
86-
return pending
87-
},
88-
}
8930
}
9031

9132
export class InsufficientWorkspacePermissionsError extends ForbiddenOperationError {
@@ -210,16 +151,13 @@ async function requireCurrentHumanPermission<C extends WorkspaceAuthorizationCon
210151
required: PermissionType,
211152
options?: WorkspaceAuthorizationOptions<C>
212153
): Promise<void> {
213-
const memo = options?.executor || options?.forUpdate ? undefined : options?.permissionCache
214-
const permission = memo
215-
? await memo.resolve(userId, context.workspaceId, context.workspaceOrganizationId)
216-
: await resolveEffectiveWorkspacePermission(
217-
userId,
218-
context.workspaceId,
219-
context.workspaceOrganizationId,
220-
options?.executor,
221-
{ forUpdate: options?.forUpdate }
222-
)
154+
const permission = await resolveEffectiveWorkspacePermission(
155+
userId,
156+
context.workspaceId,
157+
context.workspaceOrganizationId,
158+
options?.executor,
159+
{ forUpdate: options?.forUpdate }
160+
)
223161
requirePermission(permission, required)
224162
}
225163

apps/sim/lib/core/application/workspace-permission-cache.test.ts

Lines changed: 0 additions & 80 deletions
This file was deleted.

apps/sim/lib/knowledge/application/bulk.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AuditAction, AuditResourceType } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
3-
import { authorizeWorkspaceOperation, createWorkspacePermissionCache } from '@/lib/core/application'
3+
import { authorizeWorkspaceOperation } from '@/lib/core/application'
44
import { classifyBulkItemError } from '@/lib/core/application/bulk-items'
55
import { OrchestrationError } from '@/lib/core/orchestration/types'
66
import { PlatformEvents } from '@/lib/core/telemetry'
@@ -12,10 +12,7 @@ import {
1212
planFolderSelection,
1313
} from '@/lib/folders/bulk'
1414
import { findActiveFolder } from '@/lib/folders/queries'
15-
import {
16-
type KnowledgeAuthorizationOptions,
17-
knowledgeDelegationPolicy,
18-
} from '@/lib/knowledge/application/authorization'
15+
import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization'
1916
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
2017
import {
2118
type BoundedKnowledgeSelection,
@@ -124,21 +121,11 @@ async function runKnowledgeItems(
124121
knowledgeBaseIds: readonly string[],
125122
workspace: KnowledgeWorkspaceContext,
126123
covered: ReadonlySet<string>,
127-
authorize: (
128-
canonical: ActiveKnowledgeBaseContext,
129-
options: KnowledgeAuthorizationOptions
130-
) => Promise<void>,
124+
authorize: (canonical: ActiveKnowledgeBaseContext) => Promise<void>,
131125
apply: (canonical: ActiveKnowledgeBaseContext) => Promise<string>,
132126
succeeded: BulkKnowledgeItem[],
133127
outcome: BulkKnowledgeOutcome
134128
): Promise<unknown | undefined> {
135-
/**
136-
* Built here rather than by each caller so a bulk loop cannot forget it: every item authorizes
137-
* against the same `(user, workspace, organization)` triple, and without the memo that is two
138-
* identical queries per item.
139-
*/
140-
const permissionCache = createWorkspacePermissionCache()
141-
142129
for (const knowledgeBaseId of knowledgeBaseIds) {
143130
let knowledgeBaseName = knowledgeBaseId
144131
try {
@@ -153,7 +140,7 @@ async function runKnowledgeItems(
153140
})
154141
continue
155142
}
156-
await authorize(canonical, { permissionCache })
143+
await authorize(canonical)
157144
succeeded.push({
158145
kind: 'knowledgeBase',
159146
id: canonical.knowledgeBaseId,
@@ -233,9 +220,8 @@ export const bulkMoveKnowledgeItems = defineAuthorizedKnowledgeUseCase({
233220
context.knowledgeBaseIds,
234221
context,
235222
plan.covered,
236-
(canonical, options) =>
223+
(canonical) =>
237224
authorizeWorkspaceOperation(principal, knowledgeOperations.bulkMoveItems, canonical, {
238-
...options,
239225
delegation: knowledgeDelegationPolicy,
240226
}),
241227
async (canonical) =>
@@ -336,9 +322,8 @@ export const bulkDeleteKnowledgeItems = defineAuthorizedKnowledgeUseCase({
336322
context.knowledgeBaseIds,
337323
context,
338324
plan.covered,
339-
(canonical, options) =>
325+
(canonical) =>
340326
authorizeWorkspaceOperation(principal, knowledgeOperations.bulkDeleteItems, canonical, {
341-
...options,
342327
delegation: knowledgeDelegationPolicy,
343328
}),
344329
async (canonical) => {

apps/sim/lib/table/application/authorization.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { Principal } from '@sim/auth/principal'
22
import {
33
authorizeWorkspaceOperation,
44
type WorkspaceAuthorizationContext,
5-
type WorkspaceAuthorizationOptions,
65
type WorkspaceDelegationPolicy,
76
} from '@/lib/core/application'
87
import type { TableOperation } from '@/lib/table/application/operations'
@@ -31,21 +30,12 @@ export const tableDelegationPolicy: WorkspaceDelegationPolicy<TableAuthorization
3130
},
3231
}
3332

34-
/** Everything {@link authorizeWorkspaceOperation} takes except the delegation policy, which is
35-
* fixed for this domain. */
36-
export type TableAuthorizationOptions = Omit<
37-
WorkspaceAuthorizationOptions<TableAuthorizationContext>,
38-
'delegation'
39-
>
40-
4133
export function authorizeTableOperation(
4234
principal: Principal,
4335
operation: TableOperation,
44-
context: TableAuthorizationContext,
45-
options?: TableAuthorizationOptions
36+
context: TableAuthorizationContext
4637
) {
4738
return authorizeWorkspaceOperation(principal, operation, context, {
48-
...options,
4939
delegation: tableDelegationPolicy,
5040
})
5141
}

apps/sim/lib/table/application/bulk.test.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -292,15 +292,15 @@ describe('table bulk application use cases', () => {
292292
})
293293

294294
/**
295-
* The batch authorizes every table separately — delegation scope is per-resource — but the
296-
* human-permission half of that check reads the same row every time. Without the shared memo a
297-
* hundred-table request is a hundred identical lookups, two queries deep.
295+
* The canonical workspace context is what bounded and authorized the request; it cannot differ
296+
* per item, so the batch resolves it once and composes each table onto it. Resolving it per
297+
* item was a whole extra load each.
298298
*
299-
* Two calls, not one: the use case authorizes the operation itself before the loop starts, and
300-
* that check is outside the batch memo. What matters is that the count does not grow with the
301-
* selection.
299+
* Note this deliberately does NOT memoize the per-item permission check: each item commits
300+
* independently, so every one of them re-reads the caller's current permission and a
301+
* revocation part-way through a batch stops the rest.
302302
*/
303-
it('resolves the caller permission once for the whole batch, however many items it carries', async () => {
303+
it('loads the workspace context once however many items the batch carries', async () => {
304304
const move = (tableIds: string[]) =>
305305
bulkMoveTables.execute({
306306
principal,
@@ -314,14 +314,33 @@ describe('table bulk application use cases', () => {
314314

315315
const small = await move(['table-1', 'table-2', 'table-3'])
316316
expect(small.moved).toHaveLength(3)
317-
const afterSmall = mocks.resolvePermission.mock.calls.length
317+
expect(mocks.resolveWorkspaceContext).toHaveBeenCalledTimes(1)
318318

319-
mocks.resolvePermission.mockClear()
319+
mocks.resolveWorkspaceContext.mockClear()
320320
const large = await move(Array.from({ length: 25 }, (_, index) => `table-${index}`))
321321
expect(large.moved).toHaveLength(25)
322+
expect(mocks.resolveWorkspaceContext).toHaveBeenCalledTimes(1)
323+
})
324+
325+
/** A revocation part-way through a batch must stop the items that have not run yet. */
326+
it('re-checks the caller permission for every item', async () => {
327+
mocks.resolvePermission.mockResolvedValueOnce('write').mockResolvedValueOnce('write')
328+
mocks.resolvePermission.mockResolvedValue(null)
329+
330+
const result = await bulkMoveTables.execute({
331+
principal,
332+
input: {
333+
assertedWorkspaceId: 'workspace-1',
334+
tableIds: ['table-1', 'table-2', 'table-3'],
335+
folderIds: [],
336+
targetFolderId: 'folder-1',
337+
},
338+
})
322339

323-
expect(mocks.resolvePermission).toHaveBeenCalledTimes(afterSmall)
324-
expect(afterSmall).toBe(2)
340+
expect(result.moved).toHaveLength(1)
341+
expect(result.failed.concat(result.notFound as never[])).toHaveLength(2)
342+
/** One for the operation itself, then one per item — no memo may collapse these. */
343+
expect(mocks.resolvePermission).toHaveBeenCalledTimes(4)
325344
})
326345

327346
it('moves tables and folders in one operation', async () => {

0 commit comments

Comments
 (0)