diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index 5576c035050..cc754e1d8f3 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -11,11 +11,7 @@ import { cn, Label, Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + type TableColumn, Tooltip, } from '@sim/emcn' import { RefreshCw } from '@sim/emcn/icons' @@ -153,6 +149,16 @@ function renderStructuredValuePreview(value: unknown) { ) } +const RESPONSE_STRUCTURE_COLUMNS: TableColumn[] = [ + { key: 'field', header: 'Field', cell: (row) => row.name }, + { key: 'type', header: 'Type', cell: (row) => row.type }, + { key: 'value', header: 'Value', cell: (row) => renderStructuredValuePreview(row.value) }, +] + +function getResponseStructureRowId(row: ResponseStructureRow): string { + return row.id +} + export default function ResumeExecutionPage({ params, initialExecutionDetail, @@ -873,44 +879,23 @@ export default function ResumeExecutionPage({ ) : ( <> {/* Display Data */} - {responseStructureRows.length > 0 ? ( -
-
- -
-
- - - - Field - Type - Value - - - - {responseStructureRows.map((row) => ( - - {row.name} - {row.type} - {renderStructuredValuePreview(row.value)} - - ))} - -
-
-
- ) : ( -
-
- -
-
+
+ + {responseStructureRows.length > 0 ? ( + + ) : ( +

No display data configured

- - )} + )} + {/* Resume Form */} {isHumanMode && hasInputFormat ? ( diff --git a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts index 53b0f298bfb..f686f010ae9 100644 --- a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts +++ b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts @@ -113,8 +113,8 @@ describe('desktop title-bar surface audit', () => { it('defines the content-pane lane once, defaulting to zero', () => { // A `:root` default keeps the variable defined for bars that render outside - // `.workspace-content-shell` (the standalone settings shell at /account, - // /organization/[id], /selfhost; the landing tables preview). An undefined var() + // `.workspace-content-shell` (the standalone settings shell at /account and + // /selfhost; the landing tables preview). An undefined var() // inside calc() is invalid at computed-value time and drops padding-top entirely. expect(globalStyles).toMatch(/:root\s*\{[^}]*--workspace-content-title-bar-inset:\s*0px/s) // The pane owns the lane in both arrangements where the sidebar is not there to @@ -197,7 +197,7 @@ describe('desktop title-bar surface audit', () => { * reservation outright — class gone, nothing put back — also drops the file from the check. * That regression is loud rather than silent (the surface stops being full height, which is * plainly visible), and closing it properly means treating every route entry point as a - * window root, which pulls in seven account/organization/selfhost pages that each need + * window root, which pulls in the account/selfhost pages that each need * their own assessment. Worth doing; not worth guessing at here. Reaching for this allowlist should feel like a claim you have to defend — the * entry that read "marketing chrome, not reachable in the desktop shell" was false, and * hid four live surfaces behind one unverified sentence. diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 63cfb4994dd..d880aaf1dae 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -160,7 +160,7 @@ const emailTemplates = { scope: 'organization', currentUsage: 500, limit: 500, - ctaLink: 'https://sim.ai/organization/org_123/settings/billing', + ctaLink: 'https://sim.ai/workspace/ws_123/settings/billing', }), // Operational notification emails diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 416d5c3ba03..1d97ac8368a 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -1,5 +1,11 @@ import { db } from '@sim/db' -import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema' +import { + member, + organizationMemberUsageLimit, + subscription as subscriptionTable, + user, + userStats, +} from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray } from 'drizzle-orm' @@ -94,11 +100,25 @@ export const GET = withRouteHandler( userEmail: user.email, currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, + organizationCreditLimit: organizationMemberUsageLimit.usageLimit, usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, }) .from(member) .innerJoin(user, eq(member.userId, user.id)) .leftJoin(userStats, eq(user.id, userStats.userId)) + /** + * The org-scoped cap lives in its own table keyed by (organization, + * user). `userStats.currentUsageLimit` is the member's PERSONAL + * subscription cap and is nulled for org-scoped members, so reading it + * here would report "no limit" for every member of every organization. + */ + .leftJoin( + organizationMemberUsageLimit, + and( + eq(organizationMemberUsageLimit.organizationId, member.organizationId), + eq(organizationMemberUsageLimit.userId, member.userId) + ) + ) .where(eq(member.organizationId, organizationId)) // The billing period is the same for every member — it comes from diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index 17bdf76153e..e150af1c128 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -92,6 +92,8 @@ export const GET = withRouteHandler( .select({ id: workspace.id, name: workspace.name, + logoUrl: workspace.logoUrl, + color: workspace.color, ownerId: workspace.ownerId, billedAccountUserId: workspace.billedAccountUserId, }) @@ -291,7 +293,12 @@ export const GET = withRouteHandler( const data = { members: rosterMembers, pendingInvitations, - workspaces: orgWorkspaces.map((ws) => ({ id: ws.id, name: ws.name })), + workspaces: orgWorkspaces.map((ws) => ({ + id: ws.id, + name: ws.name, + logoUrl: ws.logoUrl, + color: ws.color, + })), } satisfies OrganizationRoster return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/workspaces/[id]/background-work/route.ts b/apps/sim/app/api/workspaces/[id]/background-work/route.ts index 727b4445f78..49e8726a1f2 100644 --- a/apps/sim/app/api/workspaces/[id]/background-work/route.ts +++ b/apps/sim/app/api/workspaces/[id]/background-work/route.ts @@ -17,13 +17,18 @@ export const GET = withRouteHandler( const parsed = await parseRequest(getWorkspaceBackgroundWorkContract, req, context) if (!parsed.success) return parsed.response const { id } = parsed.data.params - const { cursor, limit } = parsed.data.query + const { cursor, limit, kind } = parsed.data.query // The fork Activity feed is a fork feature: gate it behind the same forking-enabled + // workspace-admin check the other fork routes use, instead of a bare access check. await assertWorkspaceAdminAccess(id, session.user.id) - const { rows, nextCursor } = await listSurfacedBackgroundWork(db, id, { cursor, limit }) + const { rows, nextCursor } = await listSurfacedBackgroundWork(db, id, { + cursor, + limit, + // `all` leaves the store's own fork-kind default in place rather than restating it here. + ...(kind === 'all' ? {} : { kinds: [kind] }), + }) return NextResponse.json({ items: rows.map((row) => ({ id: row.id, diff --git a/apps/sim/app/api/workspaces/[id]/fork/forest/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/forest/route.test.ts new file mode 100644 index 00000000000..8a714dbe1d4 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/forest/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAssertWorkspaceAdminAccess, mockGetForkForest, mockGetManageableWorkspaces } = + vi.hoisted(() => ({ + mockAssertWorkspaceAdminAccess: vi.fn(), + mockGetForkForest: vi.fn(), + mockGetManageableWorkspaces: vi.fn(), + })) + +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ + assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, +})) + +vi.mock('@/ee/workspace-forking/lib/lineage/forest', () => ({ + getForkForest: mockGetForkForest, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getManageableWorkspaces: mockGetManageableWorkspaces, +})) + +import { GET } from '@/app/api/workspaces/[id]/fork/forest/route' + +const mockGetSession = authMockFns.mockGetSession + +const WORKSPACE_ID = 'workspace-1' +const VIEWER_ID = 'user-1' +const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +const node = (id: string, parentId: string | null) => ({ + id, + name: `Workspace ${id}`, + color: '#33C482', + logoUrl: null, + organizationId: 'org-1', + parentId, + createdAt: '2026-01-02T03:04:05.000Z', + viewerAccessible: true, + viewerCanAdmin: true, + deployedWorkflowCount: 2, + edge: parentId + ? { mapped: 3, unmapped: 1, lastSyncAt: '2026-01-03T00:00:00.000Z', undoableRun: null } + : null, +}) + +describe('fork forest route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID } }) + mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID }) + mockGetManageableWorkspaces.mockResolvedValue([{ id: WORKSPACE_ID }, { id: 'workspace-2' }]) + mockGetForkForest.mockResolvedValue([]) + }) + + it('returns 401 when there is no session', async () => { + mockGetSession.mockResolvedValue(null) + + const res = await GET(createMockRequest('GET'), routeContext) + + expect(res.status).toBe(401) + expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled() + }) + + it('requires admin on the anchor workspace before loading the forest', async () => { + await GET(createMockRequest('GET'), routeContext) + + expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, VIEWER_ID) + }) + + it('seeds the walk from every workspace the viewer administers', async () => { + await GET(createMockRequest('GET'), routeContext) + + expect(mockGetForkForest).toHaveBeenCalledWith({ + anchorWorkspaceId: WORKSPACE_ID, + viewerId: VIEWER_ID, + manageableWorkspaceIds: [WORKSPACE_ID, 'workspace-2'], + }) + }) + + it('returns the forest nodes verbatim alongside the anchor id', async () => { + const nodes = [node(WORKSPACE_ID, null), node('fork-1', WORKSPACE_ID)] + mockGetForkForest.mockResolvedValue(nodes) + + const res = await GET(createMockRequest('GET'), routeContext) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ workspaceId: WORKSPACE_ID, nodes }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/forest/route.ts b/apps/sim/app/api/workspaces/[id]/fork/forest/route.ts new file mode 100644 index 00000000000..4d01654d487 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/forest/route.ts @@ -0,0 +1,40 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getForkForestContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getManageableWorkspaces } from '@/lib/workspaces/permissions/utils' +import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' +import { getForkForest } from '@/ee/workspace-forking/lib/lineage/forest' + +/** + * Every fork lineage the viewer can reach from this workspace, as flat depth-first rows. + * + * Admin on the anchor is the gate, matching every other fork route; each returned node then + * carries its own `viewerAccessible` / `viewerCanAdmin` so the console can gate per row rather + * than hiding a link and breaking the chain. + */ +export const GET = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getForkForestContract, req, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + + await assertWorkspaceAdminAccess(workspaceId, session.user.id) + + const manageable = await getManageableWorkspaces(session.user.id) + const nodes = await getForkForest({ + anchorWorkspaceId: workspaceId, + viewerId: session.user.id, + manageableWorkspaceIds: manageable.map((entry) => entry.id), + }) + + return NextResponse.json({ workspaceId, nodes }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts deleted file mode 100644 index c3c265ba4c6..00000000000 --- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @vitest-environment node - */ -import { authMockFns, createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAssertWorkspaceAdminAccess, - mockGetForkParent, - mockGetForkChildren, - mockGetUndoableRunForTarget, - mockGetEffectiveWorkspacePermission, -} = vi.hoisted(() => ({ - mockAssertWorkspaceAdminAccess: vi.fn(), - mockGetForkParent: vi.fn(), - mockGetForkChildren: vi.fn(), - mockGetUndoableRunForTarget: vi.fn(), - mockGetEffectiveWorkspacePermission: vi.fn(), -})) - -vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ - assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, -})) - -vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ - getForkParent: mockGetForkParent, - getForkChildren: mockGetForkChildren, -})) - -vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({ - getUndoableRunForTarget: mockGetUndoableRunForTarget, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission, -})) - -import { GET } from '@/app/api/workspaces/[id]/fork/lineage/route' - -const mockGetSession = authMockFns.mockGetSession - -const WORKSPACE_ID = 'workspace-1' -const VIEWER_ID = 'user-1' -const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } - -const parentNode = { id: 'parent-1', name: 'Parent', organizationId: 'org-1' } -const childCreatedAt = new Date('2026-01-02T03:04:05.000Z') -const childNode = (id: string, name: string) => ({ - id, - name, - organizationId: 'org-1', - createdAt: childCreatedAt, -}) - -describe('fork lineage route', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID } }) - mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID }) - mockGetForkParent.mockResolvedValue(null) - mockGetForkChildren.mockResolvedValue([]) - mockGetUndoableRunForTarget.mockResolvedValue(null) - mockGetEffectiveWorkspacePermission.mockResolvedValue(null) - }) - - it('returns 401 when there is no session', async () => { - mockGetSession.mockResolvedValue(null) - - const res = await GET(createMockRequest('GET'), routeContext) - - expect(res.status).toBe(401) - expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled() - }) - - it('requires admin on the current workspace before loading lineage', async () => { - await GET(createMockRequest('GET'), routeContext) - - expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, VIEWER_ID) - }) - - it('marks accessible and inaccessible nodes via the canonical permission resolver', async () => { - mockGetForkParent.mockResolvedValue(parentNode) - mockGetForkChildren.mockResolvedValue([ - childNode('fork-accessible', 'Accessible fork'), - childNode('fork-hidden', 'Hidden fork'), - ]) - mockGetEffectiveWorkspacePermission.mockImplementation( - async (_userId: string, ws: { id: string }) => { - if (ws.id === parentNode.id) return 'read' - if (ws.id === 'fork-accessible') return 'admin' - return null - } - ) - - const res = await GET(createMockRequest('GET'), routeContext) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body.parent).toEqual({ ...parentNode, viewerAccessible: true }) - expect(body.children).toEqual([ - { - id: 'fork-accessible', - name: 'Accessible fork', - organizationId: 'org-1', - createdAt: childCreatedAt.toISOString(), - viewerAccessible: true, - }, - { - id: 'fork-hidden', - name: 'Hidden fork', - organizationId: 'org-1', - createdAt: childCreatedAt.toISOString(), - viewerAccessible: false, - }, - ]) - expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalledWith( - VIEWER_ID, - expect.objectContaining({ id: parentNode.id, organizationId: 'org-1' }) - ) - }) - - it('marks the parent inaccessible when the viewer holds no permission on it', async () => { - mockGetForkParent.mockResolvedValue(parentNode) - mockGetEffectiveWorkspacePermission.mockResolvedValue(null) - - const res = await GET(createMockRequest('GET'), routeContext) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body.parent).toEqual({ ...parentNode, viewerAccessible: false }) - expect(body.children).toEqual([]) - }) - - it('keeps a null parent null without resolving permissions', async () => { - const res = await GET(createMockRequest('GET'), routeContext) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body.parent).toBeNull() - expect(body.children).toEqual([]) - expect(body.undoableRun).toBeNull() - expect(mockGetEffectiveWorkspacePermission).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts deleted file mode 100644 index 6e8b5b364e5..00000000000 --- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { db } from '@sim/db' -import { workspace } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' -import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' -import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' -import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store' - -/** - * Annotates a lineage node with whether the viewer holds any access to it (explicit - * grant or org-admin derivation, via the canonical workspace-permission resolver). - * Lineage rows are visible to any admin of the CURRENT workspace, who may have no - * access to the other side of an edge; the flag drives per-action gating in the - * Forks UI. Resolved per node - lineage children lists are small and bounded. - */ -async function withViewerAccess( - node: T, - viewerId: string -): Promise { - const permission = await getEffectiveWorkspacePermission(viewerId, node) - return { ...node, viewerAccessible: permission !== null } -} - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkLineageContract, req, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - - await assertWorkspaceAdminAccess(workspaceId, session.user.id) - - const [rawParent, rawChildren, run] = await Promise.all([ - getForkParent(workspaceId), - getForkChildren(workspaceId), - getUndoableRunForTarget(db, workspaceId), - ]) - - const [parent, children] = await Promise.all([ - rawParent ? withViewerAccess(rawParent, session.user.id) : null, - Promise.all(rawChildren.map((child) => withViewerAccess(child, session.user.id))), - ]) - - let undoableRun: { - otherWorkspaceId: string - otherName: string - direction: 'push' | 'pull' - } | null = null - if (run) { - const [other] = await db - .select({ name: workspace.name }) - .from(workspace) - .where(eq(workspace.id, run.sourceWorkspaceId)) - .limit(1) - undoableRun = { - otherWorkspaceId: run.sourceWorkspaceId, - otherName: other?.name ?? 'workspace', - direction: run.direction, - } - } - - return NextResponse.json({ - workspaceId, - parent, - children: children.map((child) => ({ - ...child, - createdAt: child.createdAt.toISOString(), - })), - undoableRun, - }) - } -) diff --git a/apps/sim/app/api/workspaces/[id]/fork/matrix/route.ts b/apps/sim/app/api/workspaces/[id]/fork/matrix/route.ts new file mode 100644 index 00000000000..6f8593a6548 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/matrix/route.ts @@ -0,0 +1,100 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import type { ForkForestNode } from '@/lib/api/contracts/workspace-fork' +import { getForkMatrixContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getManageableWorkspaces } from '@/lib/workspaces/permissions/utils' +import { assertWorkspaceAdminAccess, ForkError } from '@/ee/workspace-forking/lib/lineage/authz' +import { getForkForest } from '@/ee/workspace-forking/lib/lineage/forest' +import { getForkMatrix } from '@/ee/workspace-forking/lib/mapping/matrix' + +/** + * The lineage rooted at `rootId`, depth-first, restricted to the workspaces the viewer can + * actually see. + * + * An inaccessible workspace truncates the lineage at that point rather than being skipped over: + * the matrix labels every resource it lists, so composing a chain THROUGH a workspace the viewer + * has no access to would report that workspace's resource names to someone who cannot open it. + */ +function visibleLineage(nodes: ForkForestNode[], rootId: string): ForkForestNode[] { + const childrenByParent = new Map() + for (const node of nodes) { + if (!node.parentId) continue + const siblings = childrenByParent.get(node.parentId) + if (siblings) siblings.push(node) + else childrenByParent.set(node.parentId, [node]) + } + + const root = nodes.find((node) => node.id === rootId) + if (!root || !root.viewerAccessible) return [] + + const lineage: ForkForestNode[] = [] + const visit = (node: ForkForestNode) => { + lineage.push(node) + for (const child of childrenByParent.get(node.id) ?? []) { + if (child.viewerAccessible) visit(child) + } + } + visit(root) + return lineage +} + +/** + * The mappings matrix for one lineage: every resource chain across its workspaces, plus the + * targets each cell may be re-pointed at. + * + * Admin on the anchor is the gate, as on every fork route; the lineage is then narrowed to the + * workspaces the viewer can see, and each column reports whether the viewer may edit the edge + * that lands in it. + */ +export const GET = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getForkMatrixContract, req, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { rootId } = parsed.data.query + + await assertWorkspaceAdminAccess(workspaceId, session.user.id) + + const manageable = await getManageableWorkspaces(session.user.id) + const nodes = await getForkForest({ + anchorWorkspaceId: workspaceId, + viewerId: session.user.id, + manageableWorkspaceIds: manageable.map((entry) => entry.id), + }) + + const lineage = visibleLineage(nodes, rootId) + if (lineage.length === 0) { + throw new ForkError('That lineage is not reachable from this workspace', 404) + } + + const lineageIds = new Set(lineage.map((node) => node.id)) + const columns = lineage.map((node) => ({ + id: node.id, + // The root's own parent sits outside this matrix, so it anchors no edge here. + parentId: node.parentId && lineageIds.has(node.parentId) ? node.parentId : null, + })) + + const matrix = await getForkMatrix(columns) + + return NextResponse.json({ + rootWorkspaceId: rootId, + workspaces: lineage.map((node) => ({ + id: node.id, + name: node.name, + color: node.color, + logoUrl: node.logoUrl, + parentId: node.parentId && lineageIds.has(node.parentId) ? node.parentId : null, + viewerCanAdmin: node.viewerCanAdmin, + })), + ...matrix, + }) + } +) diff --git a/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx deleted file mode 100644 index ab2f2fce4ae..00000000000 --- a/apps/sim/app/organization/[organizationId]/settings/[section]/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type { Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' -import { - getOrganizationSettingsFeatures, - getSettingsSectionMeta, - isOrganizationSettingsSectionAvailable, - ORGANIZATION_SETTINGS_ITEMS, - ORGANIZATION_SETTINGS_PATH_ALIASES, - parseSettingsPathSection, -} from '@/components/settings/navigation' -import { OrganizationSettingsRenderer } from '@/components/settings/organization-settings-renderer' -import { SettingsUnavailable } from '@/components/settings/settings-unavailable' -import { getSession } from '@/lib/auth' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' - -interface OrganizationSettingsSectionPageProps { - params: Promise<{ organizationId: string; section: string }> -} - -export async function generateMetadata({ - params, -}: OrganizationSettingsSectionPageProps): Promise { - const { section } = await params - const parsed = parseSettingsPathSection({ - path: section, - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: null, - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - const meta = parsed ? getSettingsSectionMeta('organization', parsed) : null - return { title: meta ? `${meta.label} - Organization settings` : 'Organization settings' } -} - -export default async function OrganizationSettingsSectionPage({ - params, -}: OrganizationSettingsSectionPageProps) { - const session = await getSession() - if (!session?.user) redirect('/login') - - const { organizationId, section } = await params - const parsed = parseSettingsPathSection({ - path: section, - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: null, - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - if (!parsed) notFound() - - const canOpen = await canOpenOrganizationSettingsSection(organizationId, session.user.id, parsed) - if (!canOpen) return - const hasEnterprisePlan = - parsed !== 'members' && - parsed !== 'billing' && - (await isOrganizationOnEnterprisePlan(organizationId)) - if ( - !isOrganizationSettingsSectionAvailable( - parsed, - getOrganizationSettingsFeatures(hasEnterprisePlan) - ) - ) { - return ( - - ) - } - - return -} diff --git a/apps/sim/app/organization/[organizationId]/settings/layout.tsx b/apps/sim/app/organization/[organizationId]/settings/layout.tsx deleted file mode 100644 index 12dce2f96e3..00000000000 --- a/apps/sim/app/organization/[organizationId]/settings/layout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { redirect } from 'next/navigation' -import { SettingsUnavailable } from '@/components/settings/settings-unavailable' -import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell' -import { getSession } from '@/lib/auth' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' - -interface OrganizationSettingsLayoutProps { - children: React.ReactNode - params: Promise<{ organizationId: string }> -} - -export default async function OrganizationSettingsLayout({ - children, - params, -}: OrganizationSettingsLayoutProps) { - const session = await getSession() - if (!session?.user) redirect('/login') - - const { organizationId } = await params - const access = await getOrganizationSettingsAccess(organizationId, session.user.id) - if (!access.isMember) return - const hasEnterprisePlan = access.isAdmin && (await isOrganizationOnEnterprisePlan(organizationId)) - - return ( - - {children} - - ) -} diff --git a/apps/sim/app/organization/[organizationId]/settings/page.tsx b/apps/sim/app/organization/[organizationId]/settings/page.tsx deleted file mode 100644 index c0d5c917a48..00000000000 --- a/apps/sim/app/organization/[organizationId]/settings/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from 'next/navigation' -import { getOrganizationSettingsHref } from '@/components/settings/navigation' - -interface OrganizationSettingsPageProps { - params: Promise<{ organizationId: string }> -} - -export default async function OrganizationSettingsPage({ params }: OrganizationSettingsPageProps) { - const { organizationId } = await params - redirect(getOrganizationSettingsHref(organizationId, 'members')) -} diff --git a/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx b/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx deleted file mode 100644 index 9e4bd26f677..00000000000 --- a/apps/sim/app/organization/[organizationId]/settings/unavailable/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsUnavailable } from '@/components/settings/settings-unavailable' - -export default function OrganizationSettingsUnavailablePage() { - return -} diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index ddee87cff6f..d6752e9e2e0 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -63,13 +63,8 @@ import { Slider, Switch, Table, - TableBody, - TableCaption, - TableCell, - TableFooter, - TableHead, - TableHeader, - TableRow, + type TableColumn, + TableIdentityCell, TagInput, type TagItem, Textarea, @@ -107,6 +102,77 @@ function VariantRow({ label, children }: { label: string; children: React.ReactN ) } +interface DemoMember { + id: string + name: string + email: string + role: string +} + +const DEMO_MEMBERS: DemoMember[] = [ + { id: '1', name: 'Ada Lovelace', email: 'ada@example.com', role: 'Owner' }, + { id: '2', name: 'Grace Hopper', email: 'grace@example.com', role: 'Admin' }, + { id: '3', name: 'Alan Turing', email: 'alan@example.com', role: 'Member' }, +] + +const DEMO_TABS = [ + { id: 'members', label: 'Members' }, + { id: 'invites', label: 'Pending invitations' }, +] + +/** + * Exercises the full Table surface in one place — tabs, the search toolbar, + * controlled selection with a bulk action, an identity cell, and a + * right-aligned action column. Kept local so the page component's state list + * does not grow for a single gallery entry. + */ +function TableDemo() { + const [activeTab, setActiveTab] = useState('members') + const [search, setSearch] = useState('') + const [selectedIds, setSelectedIds] = useState([]) + + const rows = + activeTab === 'members' + ? DEMO_MEMBERS.filter( + (member) => + member.name.toLowerCase().includes(search.toLowerCase()) || + member.email.toLowerCase().includes(search.toLowerCase()) + ) + : [] + + const columns: TableColumn[] = [ + { + key: 'identity', + header: 'Member', + cell: (member) => , + }, + { key: 'role', header: 'Role', align: 'right', cell: (member) => member.role, width: 120 }, + { + key: 'actions', + align: 'right', + width: 140, + cell: () => , + }, + ] + + return ( +
member.id} + columns={columns} + tabs={{ items: DEMO_TABS, activeId: activeTab, onChange: setActiveTab }} + toolbar={{ search: { value: search, onChange: setSearch, placeholder: 'Filter' } }} + selection={{ + selectedIds, + onSelectionChange: setSelectedIds, + bulkActions: , + }} + empty={activeTab === 'members' ? 'No members found' : 'No pending invitations'} + /> + ) +} + const SAMPLE_CODE = `function greet(name) { console.log("Hello, " + name); return { success: true }; @@ -603,79 +669,7 @@ export default function PlaygroundPage() { {/* Table */}
-
- - - Name - Status - Role - - - - - Alice - Active - Admin - - - Bob - Pending - User - - - Charlie - Active - User - - -
- - - - - - Item - Price - - - - - Product A - $10.00 - - - Product B - $20.00 - - - - - Total - $30.00 - - -
-
- - - A list of team members - - - Name - Department - - - - - Alice - Engineering - - - Bob - Design - - -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx index 5bf9becae94..260b5de71d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.test.tsx @@ -36,7 +36,7 @@ vi.mock('@/lib/auth/auth-client', () => ({ })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useWorkspaceHostContext: () => hostContext.current, + useOptionalWorkspaceHostContext: () => hostContext.current, })) vi.mock('@/hooks/queries/invitations', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx index 9b21c25ed31..0ee7bfdeba0 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx @@ -19,7 +19,7 @@ import { isEnterprise } from '@/lib/billing/plan-helpers' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { quickValidateEmail } from '@/lib/messaging/email/validation' import type { PermissionType } from '@/lib/workspaces/permissions/utils' -import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useSendWorkspaceInvitations } from '@/hooks/queries/invitations' import { useOrganizationBilling } from '@/hooks/queries/organization' import { useAdminWorkspaces } from '@/hooks/queries/workspace' @@ -99,6 +99,15 @@ interface InviteModalProps { inviteDisabledReason?: string | null /** False when the viewer lacks permission to invite. */ canInvite?: boolean + /** + * Whether the viewer administers {@link InviteModalProps.organizationId}. + * + * Inside a workspace route the host context answers this on its own. On the + * organization plane there is no host workspace, so the calling page — which + * already resolved the viewer's organization role to decide whether to render + * its admin controls at all — passes the answer in. + */ + isOrganizationAdmin?: boolean } /** @@ -114,6 +123,7 @@ export function InviteModal({ organizationId = null, inviteDisabledReason = null, canInvite = true, + isOrganizationAdmin, }: InviteModalProps) { const [emails, setEmails] = useState([]) const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState( @@ -170,27 +180,32 @@ export function InviteModal({ * it is fetched solely when the viewer administers the organization the page * is actually hosted by. */ - const hostContext = useWorkspaceHostContext() /** - * Organization Admin is an organization-level grant — it carries admin on every - * workspace the org owns plus member and billing management — so it is only - * offered to someone who already holds it. The batch endpoint enforces the same - * rule; this only keeps the UI from presenting an option that would be refused. + * Optional because this modal also opens from the organization plane, which + * is outside every workspace route and so has no host provider. There the + * caller supplies {@link InviteModalProps.isOrganizationAdmin} instead. */ - const canGrantOrganizationAdmin = + const hostContext = useOptionalWorkspaceHostContext() + /** + * Whether the viewer administers the organization this invite is scoped to. + * Gates two things: offering Organization Admin as a membership — an + * org-level grant carrying admin on every workspace the org owns plus member + * and billing management, so it is only offered to someone who already holds + * it — and reading the seat counts, which are org-admin-only. The batch + * endpoint enforces both; this only keeps the UI from presenting what would + * be refused. + */ + const administersOrganization = isOrganizationInvite && - hostContext.hostOrganizationId === organizationId && - hostContext.viewer.isHostOrganizationAdmin - const membershipOptions = canGrantOrganizationAdmin + (isOrganizationAdmin ?? + (hostContext?.hostOrganizationId === organizationId && + hostContext.viewer.isHostOrganizationAdmin)) + const membershipOptions = administersOrganization ? MEMBERSHIP_OPTIONS : MEMBERSHIP_OPTIONS.filter((option) => option.value !== 'admin') - const canViewOrganizationBilling = - isOrganizationInvite && - hostContext.hostOrganizationId === organizationId && - hostContext.viewer.isHostOrganizationAdmin const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', { - enabled: open && isBillingEnabled && canViewOrganizationBilling, + enabled: open && isBillingEnabled && administersOrganization, }) const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 @@ -201,7 +216,7 @@ export function InviteModal({ * are provisioned when an invitee accepts, and externals never take one. */ const isEnterpriseOrg = isEnterprise(organizationBillingData?.data?.subscriptionPlan) - const hasSeatData = canViewOrganizationBilling && isEnterpriseOrg && totalSeats > 0 + const hasSeatData = administersOrganization && isEnterpriseOrg && totalSeats > 0 /** * Advisory only. The server decides per email and does not charge a seat for * everyone: an existing organization member is granted access directly, and an diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 2f84f225401..2a806d2e3e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -5,7 +5,7 @@ import { notFound, redirect } from 'next/navigation' import { getOrganizationSettingsFeatures, isOrganizationSettingsSectionAvailable, - type OrganizationSettingsSection, + ORGANIZATION_SCOPED_SECTIONS, resolveWorkspaceNavigation, type WorkspaceSettingsSection, } from '@/components/settings/navigation' @@ -61,17 +61,6 @@ const WORKSPACE_SECTION_MAP: Partial> = { - organization: 'members', - billing: 'billing', - 'access-control': 'access-control', - 'audit-logs': 'audit-logs', - sso: 'sso', - 'data-retention': 'data-retention', - 'data-drains': 'data-drains', - whitelabeling: 'whitelabeling', -} - function parseSection(section: string): SettingsSection | null { const normalized = SECTION_ALIASES[section] ?? section return allNavigationItems.some((item) => item.id === normalized) @@ -134,7 +123,8 @@ export default async function WorkspaceSettingsSectionPage({ if (!navigation.some((item) => item.id === workspaceSection)) notFound() } - const organizationSection = ORGANIZATION_SECTION_MAP[parsed] + const organizationSection = + ORGANIZATION_SCOPED_SECTIONS[parsed as keyof typeof ORGANIZATION_SCOPED_SECTIONS] if (organizationSection) { if (!isBillingEnabled && (parsed === 'billing' || parsed === 'organization')) { redirect(`/workspace/${workspaceId}/settings/general`) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index bde90b3f029..a546102ffbc 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -19,36 +19,6 @@ export const mcpServerIdUrlKeys = { clearOnDefault: true, } as const -/** - * `fork-id` deep-links the Forks settings tab to a specific fork's detail - * sub-view (mirrors `mcpServerId` on the MCP tab). - */ -export const forkIdParam = { - key: 'fork-id', - parser: parseAsString, -} as const - -/** Opening a fork's detail is a destination → push to history; clear on close. */ -export const forkIdUrlKeys = { - history: 'push', - clearOnDefault: true, -} as const - -/** - * `fork-view` deep-links the Forks settings tab to its workspace-scoped Activity - * view (opened from the page header's "See activity" action). - */ -export const forkViewParam = { - key: 'fork-view', - parser: parseAsStringLiteral(['activity'] as const), -} as const - -/** Opening the activity view is a destination → push to history; clear on close. */ -export const forkViewUrlKeys = { - history: 'push', - clearOnDefault: true, -} as const - /** * `server-tab` is the active tab (Details / Workflows) inside the deep-linked * workflow MCP server detail view, so a shared `mcpServerId` link can land on @@ -178,18 +148,3 @@ export const dataDrainIdUrlKeys = { history: 'push', clearOnDefault: true, } as const - -/** - * `fork-direction` is the sync direction (push/pull) on the parent fork's detail - * page — shareable view state, so a copied link opens the same side of the sync. - */ -export const forkSyncDirectionParam = { - key: 'fork-direction', - parser: parseAsStringLiteral(['push', 'pull'] as const).withDefault('push'), -} as const - -/** Toggling direction is in-place view state → replace history; clear at the push default. */ -export const forkSyncDirectionUrlKeys = { - history: 'replace', - clearOnDefault: true, -} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 632e818dec1..d1772ac2d8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -73,6 +73,11 @@ const TeamManagement = dynamic(() => (m) => m.TeamManagement ) ) +const OrganizationWorkspaces = dynamic(() => + import( + '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/organization-workspaces' + ).then((m) => m.OrganizationWorkspaces) +) const WorkflowMcpServers = dynamic(() => import( '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers' @@ -184,6 +189,9 @@ export function SettingsPage({ section }: SettingsPageProps) { billingHref={`/workspace/${hostContext.workspace.id}/settings/billing`} /> )} + {effectiveSection === 'workspaces' && organizationId && ( + + )} {effectiveSection === 'sso' && organizationId && } {effectiveSection === 'sessions' && organizationId && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/bulk-action.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/bulk-action.tsx new file mode 100644 index 00000000000..5d5e8072a3c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/bulk-action.tsx @@ -0,0 +1,97 @@ +import { ChipConfirmModal } from '@sim/emcn' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' + +/** + * Everything one bulk action says about itself, in one place: the menu row that + * opens it, the confirmation it opens, and the toast that reports the result. + * + * Splitting this across call sites is how a single action ends up called + * "Revoke invites" in the menu, "Revoke invitations" in the modal, and + * "Cancelled" in the toast. + */ +export interface BulkActionCopy { + /** Names the action everywhere it appears: menu row, confirmation title, confirm button. */ + title: string + /** Accessible name of the `...` trigger standing over the current selection. */ + triggerLabel: string + /** Confirm-button label while the batch runs. */ + pendingLabel: string + /** Pluralized row count — `1 member`, `3 workspaces`. */ + count: (rows: number) => string + /** The verb before the bolded count — `Remove `, `Delete `, `Revoke `. */ + lead: string + /** Everything after the bolded count, its punctuation included. */ + consequence: string + /** Toast headline once every row succeeded. */ + succeeded: (rows: number) => string + /** Toast headline when some rows failed. */ + failed: (failures: number, rows: number) => string +} + +interface BulkActionMenuProps { + copy: BulkActionCopy + /** True when nothing is selected — the menu stays put and disables itself. */ + disabled: boolean + onSelect: () => void +} + +/** + * The `...` that stands over a table's current selection. + * + * Always mounted, never conditional on the selection being non-empty: a control + * that appears on first tick is undiscoverable, and its slot reflows the + * select-all band as it comes and goes. + */ +export function BulkActionMenu({ copy, disabled, onSelect }: BulkActionMenuProps) { + return ( + + ) +} + +export interface BulkActionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + copy: BulkActionCopy + /** How many rows the confirmed action will target. */ + count: number + isSubmitting: boolean + onConfirm: () => void +} + +/** + * Confirms a table's bulk action. One surface for all of them — the wording is + * the only thing that varies, and it arrives as {@link BulkActionCopy}. + * + * Bulk removal states its consequence in general terms. Where a single-row flow + * can disclose more (the members page fetches the exact credentials a removal + * breaks), that flow keeps its own dialog rather than fetching an impact report + * per selected row. + */ +export function BulkActionDialog({ + open, + onOpenChange, + copy, + count, + isSubmitting, + onConfirm, +}: BulkActionDialogProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/index.ts new file mode 100644 index 00000000000..ac53b200957 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/index.ts @@ -0,0 +1,7 @@ +export { + type BulkActionCopy, + BulkActionDialog, + type BulkActionDialogProps, + BulkActionMenu, +} from './bulk-action' +export { useBulkAction } from './use-bulk-action' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/use-bulk-action.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/use-bulk-action.ts new file mode 100644 index 00000000000..f61552f7271 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/bulk-action/use-bulk-action.ts @@ -0,0 +1,108 @@ +'use client' + +import { useState } from 'react' +import { toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { + BulkActionCopy, + BulkActionDialogProps, +} from '@/app/workspace/[workspaceId]/settings/components/bulk-action/bulk-action' + +const RETRY_HINT = 'Please try again in a moment.' + +interface BulkActionConfig { + copy: BulkActionCopy + /** Rows the confirmed action targets, in the order it should run them. */ + rows: T[] + /** Runs the action for one row. A rejection is collected, never fatal to the batch. */ + perform: (row: T) => Promise + /** + * Runs once the batch settles either way — clear the selection here. Receives + * the rows that actually succeeded, so a caller can react to what is now gone + * (navigating away when the batch deleted the page's own ground) rather than + * assuming the whole selection went through. + */ + onSettled?: (outcome: { succeeded: T[] }) => void +} + +interface BulkAction { + /** Opens the confirmation. Wire to the `...` menu row. */ + confirm: () => void + /** Spread onto {@link BulkActionDialog}. */ + dialogProps: BulkActionDialogProps +} + +/** + * Runs a table's bulk action over the selected rows behind one confirmation. + * + * Rows go **sequentially** because these routes each take one row, and a + * failure is **collected rather than fatal**: a row the server refuses — a race + * with someone else's edit, a guard that only applies to that row — must not + * strand the rest of the batch. The selection is cleared either way, so a + * partial success cannot leave the rows that already succeeded still ticked. + * + * The pending flag spans the whole batch, which a mutation's own `isPending` + * does not: that drops between two sequential calls, briefly re-enabling the + * confirm button mid-run. + * + * @example + * ```tsx + * const bulk = useBulkAction({ + * copy: DELETE_WORKSPACES_COPY, + * rows: selectedRows, + * perform: (row) => deleteWorkspace.mutateAsync({ workspaceId: row.id }), + * onSettled: () => setSelection([]), + * }) + * // … + * + * + * ``` + */ +export function useBulkAction({ + copy, + rows, + perform, + onSettled, +}: BulkActionConfig): BulkAction { + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + const [isRunning, setIsRunning] = useState(false) + + const run = async () => { + if (isRunning || rows.length === 0) return + setIsRunning(true) + + const failures: string[] = [] + const succeeded: T[] = [] + for (const row of rows) { + try { + await perform(row) + succeeded.push(row) + } catch (error) { + failures.push(getErrorMessage(error, RETRY_HINT)) + } + } + + const attempted = rows.length + setIsRunning(false) + setIsConfirmOpen(false) + onSettled?.({ succeeded }) + + if (failures.length > 0) { + toast.error(copy.failed(failures.length, attempted), { description: failures[0] }) + return + } + toast.success(copy.succeeded(attempted)) + } + + return { + confirm: () => setIsConfirmOpen(true), + dialogProps: { + open: isConfirmOpen, + onOpenChange: setIsConfirmOpen, + copy, + count: rows.length, + isSubmitting: isRunning, + onConfirm: () => void run(), + }, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/credit-limit-modal/credit-limit-modal.tsx similarity index 57% rename from apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx rename to apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/credit-limit-modal/credit-limit-modal.tsx index 5a2f8f09b7e..0fe30e232e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/credit-limit-modal/credit-limit-modal.tsx @@ -4,52 +4,61 @@ import { useEffect, useRef, useState } from 'react' import { ChipModal, ChipModalBody, - ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, Info, + toast, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' +import { useQueryClient } from '@tanstack/react-query' import { + organizationKeys, useOrganizationMemberUsageLimit, useUpdateOrganizationMemberUsageLimit, } from '@/hooks/queries/organization' -export interface ManageCreditsTarget { +/** The member whose credit limit is being edited. */ +export interface CreditLimitTarget { userId: string name: string email: string } -interface ManageCreditsModalProps { +interface CreditLimitModalProps { open: boolean onOpenChange: (open: boolean) => void organizationId: string - member: ManageCreditsTarget | null + member: CreditLimitTarget | null } +const INVALID_LIMIT_MESSAGE = 'Enter a whole number of credits, or leave blank for no limit.' + /** - * Modal for viewing a member's credits used in the organization's workspaces and - * setting their per-member credit limit. "Credits used" is a read-only chip; - * "Credit limit" is editable (blank = no limit). Hosted-only feature — surfaced - * only from the Organization tab, which already requires hosted + Team plan. + * Sets (or clears) one member's per-organization credit limit. + * + * The cap is read from and written to the org-scoped + * `organization_member_usage_limit` record — the same value cap enforcement + * reads — so the field always shows what is actually enforced. A blank field + * means no cap. */ -export function ManageCreditsModal({ +export function CreditLimitModal({ open, onOpenChange, organizationId, member, -}: ManageCreditsModalProps) { +}: CreditLimitModalProps) { + const queryClient = useQueryClient() const userId = member?.userId const { data, isLoading } = useOrganizationMemberUsageLimit(organizationId, userId, open) const updateLimit = useUpdateOrganizationMemberUsageLimit() const [draft, setDraft] = useState('') - const [error, setError] = useState(null) - // Seed the draft from server data only until the admin starts typing, so a - // background refetch (window focus, post-save invalidation) can't clobber an - // in-progress edit. Reset when the modal closes. + /** + * Seed the draft from server data only until the admin starts typing, so a + * background refetch (window focus, post-save invalidation) can't clobber an + * in-progress edit. Reset when the modal closes. + */ const hasEditedRef = useRef(false) useEffect(() => { @@ -59,7 +68,6 @@ export function ManageCreditsModal({ } if (data && !hasEditedRef.current) { setDraft(data.creditLimit === null ? '' : String(data.creditLimit)) - setError(null) } }, [open, data]) @@ -71,39 +79,33 @@ export function ManageCreditsModal({ const isDirty = parsedLimit !== currentLimit const isSaving = updateLimit.isPending - const creditsUsed = data ? data.creditsUsed.toLocaleString() : '—' - const creditsUsedTitle = data - ? `Credits used this ${data.billingInterval === 'year' ? 'year' : 'month'}` - : 'Credits used' - const handleSave = () => { - if (!userId) return - if (!isValid) { - setError('Enter a whole number of credits, or leave blank for no limit.') - return - } - setError(null) + if (!userId || !isValid) return updateLimit.mutate( { orgId: organizationId, userId, creditLimit: parsedLimit }, { - onSuccess: () => onOpenChange(false), - onError: (err) => setError(getErrorMessage(err, 'Failed to update credit limit')), + onSuccess: () => { + /** + * The roster query feeds the table's limit column, and the mutation + * hook only invalidates the single-member key — refresh it here so + * the row reflects the new cap without a manual reload. + */ + queryClient.invalidateQueries({ + queryKey: organizationKeys.memberUsage(organizationId), + }) + onOpenChange(false) + }, + onError: (error) => toast.error(getErrorMessage(error, 'Failed to update credit limit')), } ) } return ( - + onOpenChange(false)}> - {member ? `Manage credits — ${member.name || member.email}` : 'Manage credits'} + {member ? `Credit limit — ${member.name || member.email}` : 'Credit limit'} - { - "Set in credits — Sim's usage unit (1,000 credits = $5). Caps this member's usage across this organization's workspaces each billing period." + "Credits are Sim's usage unit — 1,000 credits = $5. Caps this member's usage across this organization's workspaces each billing period." } @@ -123,16 +125,15 @@ export function ManageCreditsModal({ setDraft(value) }} placeholder='No limit' - hint='Leave blank for no limit.' + error={isValid ? undefined : INVALID_LIMIT_MESSAGE} disabled={isLoading || isSaving} /> - {error} onOpenChange(false)} cancelDisabled={isSaving} primaryAction={{ - label: isSaving ? 'Saving…' : 'Save', + label: isSaving ? 'Saving...' : 'Save', onClick: handleSave, disabled: !isValid || !isDirty || isSaving || isLoading, }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/credit-limit-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/credit-limit-modal/index.ts new file mode 100644 index 00000000000..131dca69661 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/credit-limit-modal/index.ts @@ -0,0 +1 @@ +export { CreditLimitModal, type CreditLimitTarget } from './credit-limit-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/member-usage-table/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/member-usage-table/index.ts new file mode 100644 index 00000000000..8727a090790 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/member-usage-table/index.ts @@ -0,0 +1 @@ +export { MemberUsageTable } from './member-usage-table' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/member-usage-table/member-usage-table.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/member-usage-table/member-usage-table.tsx new file mode 100644 index 00000000000..569132b8505 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/components/member-usage-table/member-usage-table.tsx @@ -0,0 +1,133 @@ +'use client' + +import { Chip, Table, type TableColumn, TableIdentityCell } from '@sim/emcn' +import { dollarsToCredits, formatCredits } from '@/lib/billing/credits/conversion' +import type { CreditLimitTarget } from '@/app/workspace/[workspaceId]/settings/components/organization-usage/components/credit-limit-modal' +import type { MemberUsageRow } from '@/app/workspace/[workspaceId]/settings/components/organization-usage/types' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' + +const USAGE_COLUMN_WIDTH = 160 +const LIMIT_COLUMN_WIDTH = 160 +const ACTION_COLUMN_WIDTH = 120 +/** Digit-aligned so the credit columns compare down the page. */ +const NUMERIC_CELL_CLASS = 'tabular-nums' + +function displayName(row: MemberUsageRow): string { + return row.userName || row.userEmail || 'Unknown member' +} + +/** Suppressed when the email is already standing in as the primary line. */ +function displayEmail(row: MemberUsageRow): string | undefined { + return row.userName ? (row.userEmail ?? undefined) : undefined +} + +function toTarget(row: MemberUsageRow): CreditLimitTarget { + return { userId: row.userId, name: row.userName ?? '', email: row.userEmail ?? '' } +} + +interface MemberUsageTableProps { + members: MemberUsageRow[] + /** Whether the viewer may set limits — drives the trailing action column. */ + canManage: boolean + searchTerm: string + onSearchTermChange: (value: string) => void + onEditLimit: (target: CreditLimitTarget) => void +} + +/** + * The organization's per-member credit breakdown: who has spent what this + * billing period, the cap each member carries, and the way in to change it. + * + * Every member arrives in the single roster request the page already makes, so + * the search filters in memory and the list is never paged. + */ +export function MemberUsageTable({ + members, + canManage, + searchTerm, + onSearchTermChange, + onEditLimit, +}: MemberUsageTableProps) { + const query = searchTerm.trim().toLowerCase() + const rows = query + ? members.filter( + (row) => + (row.userName ?? '').toLowerCase().includes(query) || + (row.userEmail ?? '').toLowerCase().includes(query) + ) + : members + + const columns: TableColumn[] = [ + { + key: 'member', + header: 'Member', + cell: (row) => , + }, + { + key: 'used', + header: 'Credits used', + align: 'right', + width: USAGE_COLUMN_WIDTH, + cell: (row) => ( + + {dollarsToCredits(row.currentPeriodCost ?? 0).toLocaleString()} + + ), + }, + /** + * `organizationCreditLimit` is the org-scoped cap + * (`organization_member_usage_limit`) — the one the modal writes and the one + * usage enforcement reads. Deliberately NOT `currentUsageLimit`, which is the + * member's personal subscription cap and is nulled for org-scoped members, so + * reading it here would report "No limit" for everyone. + */ + { + key: 'limit', + header: 'Credit limit', + align: 'right', + width: LIMIT_COLUMN_WIDTH, + cell: (row) => ( + + {row.organizationCreditLimit == null + ? 'No limit' + : formatCredits(row.organizationCreditLimit)} + + ), + }, + ...(canManage + ? [ + { + key: 'action', + align: 'right' as const, + width: ACTION_COLUMN_WIDTH, + cell: (row: MemberUsageRow) => ( + onEditLimit(toTarget(row))}> + Set limit + + ), + }, + ] + : []), + ] + + return ( + row.userId} + columns={columns} + toolbar={{ + search: { + value: searchTerm, + onChange: onSearchTermChange, + placeholder: 'Search members', + }, + }} + empty={ + + No members matching "{searchTerm}" + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/organization-usage.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/organization-usage.tsx new file mode 100644 index 00000000000..1908efd14fb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/organization-usage.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { isHosted } from '@/lib/core/config/env-flags' +import { + CreditLimitModal, + type CreditLimitTarget, +} from '@/app/workspace/[workspaceId]/settings/components/organization-usage/components/credit-limit-modal' +import { MemberUsageTable } from '@/app/workspace/[workspaceId]/settings/components/organization-usage/components/member-usage-table' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { useOrganizationMembers } from '@/hooks/queries/organization' + +interface OrganizationUsageProps { + organizationId: string +} + +/** + * Organization-plane Usage page. Owns the per-member view of organization + * credit consumption: what each member spent this billing period, the credit + * limit they carry, and the ability to change it. + * + * The whole roster — identity and usage together — arrives in one request + * (`useOrganizationMembers` sends `?include=usage`), so the table filters in + * memory and never pages. Per-member limits are a hosted-only feature; off the + * hosted deployment the API 404s, so the page says so rather than rendering a + * table it cannot fill. + * + * Expected to grow with organization-wide totals, a spend-over-time chart, and + * a per-workspace breakdown — each a sibling under `components/`, with this + * file staying the composition root. + */ +export function OrganizationUsage({ organizationId }: OrganizationUsageProps) { + const [searchTerm, setSearchTerm] = useSettingsSearch() + const [limitTarget, setLimitTarget] = useState(null) + const { data, isLoading, isError, error } = useOrganizationMembers(organizationId) + + if (!isHosted) { + return ( + + + Member credit usage is available on Sim's hosted service. + + + ) + } + + const members = data?.data ?? [] + /** + * The roster route only attaches usage for organization admins, so a + * non-admin viewer would otherwise get a table of blank credit columns. + */ + const canManage = data?.hasAdminAccess ?? false + + const body = isError ? ( + + {getErrorMessage(error, 'Failed to load member usage')} + + ) : isLoading ? ( + Loading... + ) : !canManage ? ( + Only organization admins can view member usage + ) : members.length === 0 ? ( + No members yet + ) : ( + + ) + + return ( + <> + {body} + {canManage && ( + { + if (!open) setLimitTarget(null) + }} + organizationId={organizationId} + member={limitTarget} + /> + )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/types.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/types.ts new file mode 100644 index 00000000000..5328878aa02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-usage/types.ts @@ -0,0 +1,12 @@ +import type { OrganizationMembersResponse } from '@/lib/api/contracts/organization' + +/** + * One row of the organization's usage roster, exactly as + * `GET /api/organizations/[id]/members?include=usage` returns it. + * + * Derived from the contract rather than restated, so a schema change surfaces + * here as a type error instead of silently drifting. `currentPeriodCost` and + * `currentUsageLimit` are **dollars** on the wire — every credit figure this + * page renders goes through `@/lib/billing/credits/conversion`. + */ +export type MemberUsageRow = OrganizationMembersResponse['data'][number] diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/bulk-actions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/bulk-actions.ts new file mode 100644 index 00000000000..da9bdb1e34b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/bulk-actions.ts @@ -0,0 +1,58 @@ +import type { BulkActionCopy } from '@/app/workspace/[workspaceId]/settings/components/bulk-action' +import type { WorkspaceAccessTab } from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/search-params' + +const workspaceCount = (rows: number) => `${rows} ${rows === 1 ? 'workspace' : 'workspaces'}` +const memberCount = (rows: number) => `${rows} ${rows === 1 ? 'member' : 'members'}` +const inviteCount = (rows: number) => `${rows} ${rows === 1 ? 'invite' : 'invites'}` + +/** + * Deleting workspaces from the organization list. + * + * Worded as deletion because that is the decision the user is making, though + * the route archives rather than hard-deletes — the workspace and everything in + * it stops being reachable either way, and "archive" would understate that. + */ +export const DELETE_WORKSPACES_COPY: BulkActionCopy = { + title: 'Delete workspaces', + triggerLabel: 'Actions for selected workspaces', + pendingLabel: 'Deleting...', + count: workspaceCount, + lead: 'Delete ', + consequence: + '? Every workflow, log, and file inside them stops being reachable, and everyone who had access loses it. This action cannot be undone.', + succeeded: (rows) => `Deleted ${workspaceCount(rows)}`, + failed: (failures, rows) => `Couldn't delete ${failures} of ${workspaceCount(rows)}`, +} + +/** + * Withdrawing access to one workspace, per tab of its detail view. + * + * Both are scoped to that workspace alone: a member keeps their organization + * membership and every other workspace they belong to, and an invitation keeps + * its remaining grants — it is cancelled outright only when this was the last + * one. + */ +export const WORKSPACE_ACCESS_BULK_COPY: Record = { + members: { + title: 'Remove from workspace', + triggerLabel: 'Actions for selected members', + pendingLabel: 'Removing...', + count: memberCount, + lead: 'Remove ', + consequence: + ' from this workspace? They keep their organization membership and any other workspace they belong to.', + succeeded: (rows) => `Removed ${memberCount(rows)} from this workspace`, + failed: (failures, rows) => `Couldn't remove ${failures} of ${memberCount(rows)}`, + }, + pending: { + title: 'Revoke access', + triggerLabel: 'Actions for selected invites', + pendingLabel: 'Revoking...', + count: inviteCount, + lead: 'Revoke ', + consequence: + ' to this workspace? Their invitation stands for any other workspace it grants, and is cancelled outright only if this was the last one.', + succeeded: (rows) => `Revoked ${inviteCount(rows)} to this workspace`, + failed: (failures, rows) => `Couldn't revoke ${failures} of ${inviteCount(rows)}`, + }, +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/index.ts new file mode 100644 index 00000000000..dbeadcc2348 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/index.ts @@ -0,0 +1,3 @@ +export { WorkspaceAccessModal } from './workspace-access-modal' +export { WorkspaceDetail } from './workspace-detail' +export { WorkspaceList } from './workspace-list' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-access-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-access-modal/index.ts new file mode 100644 index 00000000000..123c6fb7a32 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-access-modal/index.ts @@ -0,0 +1 @@ +export { WorkspaceAccessModal } from './workspace-access-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-access-modal/workspace-access-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-access-modal/workspace-access-modal.tsx new file mode 100644 index 00000000000..3c73636021e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-access-modal/workspace-access-modal.tsx @@ -0,0 +1,197 @@ +'use client' + +import { useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + type ChipModalFooterSlotAction, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { + isWorkspaceAccessRemovable, + WORKSPACE_ACCESS_LABELS, + WORKSPACE_ACCESS_LEVELS, + type WorkspaceAccessLevel, + type WorkspaceAccessRow, + workspaceAccessLockReason, +} from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/roster-groups' +import { + useCancelWorkspaceInvitation, + useRemoveWorkspaceMember, + useUpdateWorkspacePermissions, +} from '@/hooks/queries/invitations' +import { useUpdateInvitation } from '@/hooks/queries/organization' + +const ACCESS_OPTIONS = WORKSPACE_ACCESS_LEVELS.map((value) => ({ + value, + label: WORKSPACE_ACCESS_LABELS[value], +})) + +const RETRY_HINT = 'Please try again in a moment.' + +interface WorkspaceAccessModalProps { + open: boolean + onOpenChange: (open: boolean) => void + organizationId: string + workspaceId: string + /** The row whose access is being managed. Remount on change via a `key`. */ + row: WorkspaceAccessRow + /** Whether the viewer administers the organization. */ + canManage: boolean + currentUserId: string +} + +/** + * Every per-person action for one row of a workspace's access list: the + * permission this workspace grants them, plus the withdrawal of that grant. + * + * The counterpart of the members page's Manage access modal, and deliberately + * the same shape — an identity field, one control behind Save, and the + * destructive exit in the footer — so managing who is in a workspace reads + * exactly like managing who is in the organization. + * + * Everything here is scoped to this one workspace. Removing a member leaves + * their organization membership and every other workspace untouched; revoking + * an invitation's grant leaves the invitation standing for whatever else it + * grants. + */ +export function WorkspaceAccessModal({ + open, + onOpenChange, + organizationId, + workspaceId, + row, + canManage, + currentUserId, +}: WorkspaceAccessModalProps) { + const [access, setAccess] = useState(row.access.permission) + + const updatePermissions = useUpdateWorkspacePermissions() + const updateInvitation = useUpdateInvitation() + const removeMember = useRemoveWorkspaceMember() + const cancelInvitation = useCancelWorkspaceInvitation() + + const lockReason = workspaceAccessLockReason(row, { canManage, currentUserId }) + const isLocked = lockReason !== null + const isRemovable = isWorkspaceAccessRemovable(row, { canManage, currentUserId }) + const isSaving = updatePermissions.isPending || updateInvitation.isPending + const isRemoving = removeMember.isPending || cancelInvitation.isPending + + const close = () => onOpenChange(false) + + const handleAccessChange = (value: string) => { + const next = WORKSPACE_ACCESS_LEVELS.find((level) => level === value) + if (next) setAccess(next) + } + + const report = (message: string) => (error: unknown) => + toast.error(message, { description: getErrorMessage(error, RETRY_HINT) }) + + const handleSave = () => { + if (isLocked || access === row.access.permission) return + + if (row.kind === 'invite') { + updateInvitation.mutate( + { + orgId: organizationId, + invitationId: row.invitationId, + grants: [{ workspaceId, permission: access }], + }, + { onSuccess: close, onError: report("Couldn't update invite access") } + ) + return + } + + updatePermissions.mutate( + { + workspaceId, + organizationId, + updates: [{ userId: row.member.userId, permissions: access }], + }, + /** + * `useUpdateWorkspacePermissions` raises its own toast — it carries the + * route's validation detail, which is more specific than anything this + * modal could say — so only the close is wired here. + */ + { onSuccess: close } + ) + } + + const handleRemove = () => { + if (!isRemovable) return + + if (row.kind === 'invite') { + cancelInvitation.mutate( + { invitationId: row.invitationId, workspaceId, organizationId }, + { + onSuccess: () => { + toast.success('Revoked access to this workspace', { description: row.email }) + close() + }, + onError: report("Couldn't revoke access"), + } + ) + return + } + + removeMember.mutate( + { userId: row.member.userId, workspaceId, organizationId }, + { + onSuccess: () => { + toast.success('Removed from this workspace', { description: row.email }) + close() + }, + onError: report("Couldn't remove member"), + } + ) + } + + const secondaryActions: ChipModalFooterSlotAction[] = isRemovable + ? [ + { + label: row.kind === 'invite' ? 'Revoke access' : 'Remove from workspace', + variant: 'destructive', + onClick: handleRemove, + disabled: isRemoving, + }, + ] + : [] + + return ( + + Manage access + + + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-detail/index.ts new file mode 100644 index 00000000000..9e8ba9747ac --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-detail/index.ts @@ -0,0 +1 @@ +export { WorkspaceDetail } from './workspace-detail' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-detail/workspace-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-detail/workspace-detail.tsx new file mode 100644 index 00000000000..0e5f8f4da7a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-detail/workspace-detail.tsx @@ -0,0 +1,320 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Chip, ChipDropdown, Table, type TableColumn, TableIdentityCell } from '@sim/emcn' +import { ArrowLeft, Plus } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { RoleLockTooltip } from '@/components/permissions' +import { + BulkActionDialog, + BulkActionMenu, + useBulkAction, +} from '@/app/workspace/[workspaceId]/settings/components/bulk-action' +import { WORKSPACE_ACCESS_BULK_COPY } from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/bulk-actions' +import { WorkspaceAccessModal } from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-access-modal' +import { + isWorkspaceAccessRemovable, + rowMatchesQuery, + WORKSPACE_ACCESS_LABELS, + type WorkspaceAccessRow, + type WorkspaceGroup, + workspaceAccessLabel, + workspaceAccessLockReason, +} from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/roster-groups' +import { + organizationWorkspaceDetailParsers, + organizationWorkspaceDetailUrlKeys, + WORKSPACE_ACCESS_FILTERS, + WORKSPACE_ACCESS_ORDERS, + WORKSPACE_ACCESS_TABS, + type WorkspaceAccessFilter, + type WorkspaceAccessOrder, + type WorkspaceAccessTab, +} from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/search-params' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useCancelWorkspaceInvitation, useRemoveWorkspaceMember } from '@/hooks/queries/invitations' + +/** + * Every control here is labelled through the literal union `search-params.ts` + * owns, so a parser and the control it drives cannot drift: a value added to a + * parser fails to compile until it is named below. + */ +const TAB_LABELS: Record = { + members: 'Members', + pending: 'Pending invitations', +} + +const ACCESS_FILTER_LABELS: Record = { + all: 'All access', + ...WORKSPACE_ACCESS_LABELS, +} + +const ORDER_LABELS: Record = { + az: 'A-Z', + za: 'Z-A', +} + +const TABS = WORKSPACE_ACCESS_TABS.map((id) => ({ id, label: TAB_LABELS[id] })) + +const ACCESS_FILTER_OPTIONS = WORKSPACE_ACCESS_FILTERS.map((value) => ({ + value, + label: ACCESS_FILTER_LABELS[value], +})) + +const ORDER_OPTIONS = WORKSPACE_ACCESS_ORDERS.map((value) => ({ + value, + label: ORDER_LABELS[value], +})) + +/** Matches the members table, so the two toolbars line up on the same edge. */ +const FILTER_TRIGGER_WIDTH = 'w-[130px]' + +/** @see the identical helper in the members table — same reason, same shape. */ +function pick(values: readonly T[], value: string): T | undefined { + return values.find((candidate) => candidate === value) +} + +interface WorkspaceDetailProps { + group: WorkspaceGroup + organizationId: string + currentUserId: string + canManage: boolean + /** The page search, exactly as typed — normalized here for matching. */ + searchTerm: string + onSearchTermChange: (value: string) => void + onInvite: () => void + onBack: () => void +} + +/** + * One workspace's access list: the organization members who belong to it on the + * first tab, the invitations that will grant access once accepted on the second. + * + * Deliberately the members page in miniature — same tabs, same toolbar, same + * select-all band, same Manage access chip opening the same shape of modal — so + * that managing who is in a workspace and managing who is in the organization + * are one thing to learn rather than two. + */ +export function WorkspaceDetail({ + group, + organizationId, + currentUserId, + canManage, + searchTerm, + onSearchTermChange, + onInvite, + onBack, +}: WorkspaceDetailProps) { + const [filters, setFilters] = useQueryStates( + organizationWorkspaceDetailParsers, + organizationWorkspaceDetailUrlKeys + ) + const [rawSelection, setRawSelection] = useState([]) + const [manageRow, setManageRow] = useState(null) + + const removeMember = useRemoveWorkspaceMember() + const cancelInvitation = useCancelWorkspaceInvitation() + + const workspaceId = group.workspace.id + const isMembersTab = filters.tab === 'members' + const bulkCopy = WORKSPACE_ACCESS_BULK_COPY[filters.tab] + const query = searchTerm.trim() + + const rows = useMemo(() => { + const source: WorkspaceAccessRow[] = isMembersTab ? group.members : group.invites + + /** + * A search that names the workspace itself keeps every row: the user has + * already narrowed to this workspace, so filtering its people by the same + * word would empty a list they just asked to see. + */ + const needle = query.toLowerCase() + const namesWorkspace = group.workspace.name.toLowerCase().includes(needle) + const matching = source.filter((row) => { + if (filters.access !== 'all' && row.access.permission !== filters.access) return false + return namesWorkspace || rowMatchesQuery(row, needle) + }) + + return matching.sort((a, b) => + filters.order === 'za' ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name) + ) + }, [group, isMembersTab, query, filters.access, filters.order]) + + /** + * The stored selection is raw user intent; what the table and the bulk action + * see is this projection onto the rows currently visible AND removable, so a + * tab or filter change can never leave a hidden row armed. + */ + const selectedRows = useMemo(() => { + const armed = new Set(rawSelection) + return rows.filter( + (row) => isWorkspaceAccessRemovable(row, { canManage, currentUserId }) && armed.has(row.id) + ) + }, [rows, rawSelection, canManage, currentUserId]) + + const selectedIds = useMemo(() => selectedRows.map((row) => row.id), [selectedRows]) + + /** + * Both tabs withdraw access to this workspace alone — a member keeps their + * organization membership, and an invitation keeps its other grants. + */ + const bulk = useBulkAction({ + copy: bulkCopy, + rows: selectedRows, + perform: (row) => + row.kind === 'member' + ? removeMember.mutateAsync({ userId: row.member.userId, workspaceId, organizationId }) + : cancelInvitation.mutateAsync({ + invitationId: row.invitationId, + workspaceId, + organizationId, + }), + onSettled: () => setRawSelection([]), + }) + + const emptyMessage = query + ? `No results for “${query}”` + : filters.access !== 'all' + ? 'No results match your filters' + : isMembersTab + ? 'No members in this workspace' + : 'No pending invitations' + + const columns: TableColumn[] = [ + { + key: 'identity', + cell: (row) => ( + + ), + }, + { + key: 'access', + /** Holds "Admin (Organization)" on one line. */ + width: 170, + /** + * The level names its own source and carries the tooltip explaining why it + * is fixed. Without both, a row whose checkbox and dropdown are inert — an + * organization admin, the billing account — looks broken rather than + * governed. + */ + cell: (row) => ( + + {workspaceAccessLabel(row.access)} + + ), + }, + { + key: 'manage', + align: 'right', + width: 160, + cell: (row) => ( + setManageRow(row)}> + Manage access + + ), + }, + ] + + return ( + <> + +
row.id} + columns={columns} + tabs={{ + items: TABS, + activeId: filters.tab, + onChange: (value) => setFilters({ tab: pick(WORKSPACE_ACCESS_TABS, value) ?? null }), + }} + toolbar={{ + search: { + value: searchTerm, + onChange: onSearchTermChange, + placeholder: 'Search by name or email', + }, + filters: ( + <> + + setFilters({ access: pick(WORKSPACE_ACCESS_FILTERS, value) ?? null }) + } + /> + + setFilters({ order: pick(WORKSPACE_ACCESS_ORDERS, value) ?? null }) + } + /> + + ), + }} + selection={ + canManage + ? { + selectedIds, + onSelectionChange: setRawSelection, + isRowSelectable: (row: WorkspaceAccessRow) => + isWorkspaceAccessRemovable(row, { canManage, currentUserId }), + bulkActions: ( + + ), + } + : undefined + } + empty={emptyMessage} + /> + + + {manageRow && ( + { + if (!open) setManageRow(null) + }} + organizationId={organizationId} + workspaceId={workspaceId} + row={manageRow} + canManage={canManage} + currentUserId={currentUserId} + /> + )} + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-list/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-list/index.ts new file mode 100644 index 00000000000..c14c3e09243 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-list/index.ts @@ -0,0 +1 @@ +export { WorkspaceList } from './workspace-list' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-list/workspace-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-list/workspace-list.tsx new file mode 100644 index 00000000000..72220737b02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/components/workspace-list/workspace-list.tsx @@ -0,0 +1,237 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Chip, ChipDropdown, Table, type TableColumn, TableIdentityCell } from '@sim/emcn' +import { useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import { + BulkActionDialog, + BulkActionMenu, + useBulkAction, +} from '@/app/workspace/[workspaceId]/settings/components/bulk-action' +import { DELETE_WORKSPACES_COPY } from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/bulk-actions' +import { + groupMatchesQuery, + type WorkspaceGroup, +} from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/roster-groups' +import { + ORGANIZATION_WORKSPACE_ORDERS, + type OrganizationWorkspaceOrder, + organizationWorkspaceListParsers, + organizationWorkspaceListUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/search-params' +import { useDeleteWorkspace } from '@/hooks/queries/workspace' + +const ORDER_LABELS: Record = { + az: 'A-Z', + za: 'Z-A', + most: 'Most members', + fewest: 'Fewest members', +} + +const ORDER_OPTIONS = ORGANIZATION_WORKSPACE_ORDERS.map((value) => ({ + value, + label: ORDER_LABELS[value], +})) + +/** Wide enough to hold "Fewest members" without the trigger resizing per selection. */ +const FILTER_TRIGGER_WIDTH = 'w-[150px]' + +interface WorkspaceListProps { + groups: WorkspaceGroup[] + /** The page search, exactly as typed — normalized here for matching. */ + searchTerm: string + onSearchTermChange: (value: string) => void + canManage: boolean + /** + * The workspace whose settings this page is being viewed from, when there is + * one. Deletable like any other — but deleting it strands the page, so its + * removal is what triggers the walk back out to `/workspace`. + */ + hostWorkspaceId?: string + onOpen: (workspaceId: string) => void +} + +/** + * Every workspace the organization owns, one row each, with the size of its + * member list and a control that opens its access detail. + * + * Selection exists only to delete workspaces, and every workspace an + * organization admin can see is selectable — including the one they are + * standing in. The route owns the real limits: it refuses a viewer without + * workspace admin, and refuses to delete a viewer's last workspace. Both arrive + * as a per-row failure in the result toast rather than being guessed at here, + * because neither is a property of the row — "the only workspace" depends on + * how many the acting user has left at that moment, which changes as the batch + * runs. + */ +export function WorkspaceList({ + groups, + searchTerm, + onSearchTermChange, + canManage, + hostWorkspaceId, + onOpen, +}: WorkspaceListProps) { + const [filters, setFilters] = useQueryStates( + organizationWorkspaceListParsers, + organizationWorkspaceListUrlKeys + ) + const [rawSelection, setRawSelection] = useState([]) + + const router = useRouter() + const deleteWorkspace = useDeleteWorkspace() + + const query = searchTerm.trim() + + const rows = useMemo(() => { + const needle = query.toLowerCase() + const matching = needle ? groups.filter((group) => groupMatchesQuery(group, needle)) : groups + + return [...matching].sort((a, b) => { + switch (filters.order) { + case 'za': + return b.workspace.name.localeCompare(a.workspace.name) + case 'most': + return b.members.length - a.members.length + case 'fewest': + return a.members.length - b.members.length + default: + return a.workspace.name.localeCompare(b.workspace.name) + } + }) + }, [groups, query, filters.order]) + + /** + * The stored selection is raw user intent; what the table and the deletion see + * is this projection onto the rows currently visible, so a search change can + * never leave a hidden workspace armed for deletion. + */ + const selectedRows = useMemo(() => { + if (!canManage) return [] + const armed = new Set(rawSelection) + /** + * The workspace hosting this page is deleted LAST. + * + * Two reasons, both about the batch running one row at a time. Deleting it + * mid-batch pulls the page's own ground away while later rows are still in + * flight, and the route's "only workspace" guard re-counts per request — so + * putting it last means a "select everything" ends with the others gone and + * this one refused, leaving the viewer exactly where they are standing + * rather than stranded on a workspace that no longer exists. + */ + return rows + .filter((group) => armed.has(group.workspace.id)) + .sort( + (a, b) => + Number(a.workspace.id === hostWorkspaceId) - Number(b.workspace.id === hostWorkspaceId) + ) + }, [rows, rawSelection, canManage, hostWorkspaceId]) + + const selectedIds = useMemo(() => selectedRows.map((group) => group.workspace.id), [selectedRows]) + + const bulk = useBulkAction({ + copy: DELETE_WORKSPACES_COPY, + rows: selectedRows, + perform: (group) => deleteWorkspace.mutateAsync({ workspaceId: group.workspace.id }), + onSettled: ({ succeeded }) => { + setRawSelection([]) + /** + * Deleting the workspace this page is hosted by strands it — the host + * context 403s on its next read and the shell swaps in an access-denied + * card. Walk out to `/workspace`, which picks the next workspace, or + * creates one when that was the last. + */ + if (hostWorkspaceId && succeeded.some((group) => group.workspace.id === hostWorkspaceId)) { + router.push('/workspace') + } + }, + }) + + /** + * No column headers: a workspace name and its member count are self-evident, + * so labelling them adds a band of chrome that says nothing the rows do not. + * The count carries its own noun for the same reason — a bare number in a + * column with no header would be ambiguous. + */ + const columns: TableColumn[] = [ + { + key: 'workspace', + cell: (group) => ( + + ), + }, + { + key: 'members', + align: 'right', + width: 140, + cell: (group) => + `${group.members.length} ${group.members.length === 1 ? 'member' : 'members'}`, + }, + { + key: 'open', + align: 'right', + width: 160, + cell: (group) => ( + onOpen(group.workspace.id)}> + {canManage ? 'Manage access' : 'View access'} + + ), + }, + ] + + return ( + <> +
group.workspace.id} + columns={columns} + toolbar={{ + search: { + value: searchTerm, + onChange: onSearchTermChange, + placeholder: 'Search workspaces and members', + }, + filters: ( + + setFilters({ + order: ORGANIZATION_WORKSPACE_ORDERS.find((order) => order === value) ?? null, + }) + } + /> + ), + }} + selection={ + canManage + ? { + selectedIds, + onSelectionChange: setRawSelection, + bulkActions: ( + + ), + } + : undefined + } + empty={query ? `No workspaces matching “${query}”` : 'No workspaces in this organization'} + /> + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/organization-workspaces.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/organization-workspaces.tsx new file mode 100644 index 00000000000..e59fca08b1e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/organization-workspaces.tsx @@ -0,0 +1,196 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { ArrowLeft, Plus } from '@sim/emcn/icons' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' +import { getErrorMessage } from '@sim/utils/errors' +import { useQueryState, useQueryStates } from 'nuqs' +import { useSession } from '@/lib/auth/auth-client' +import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { + WorkspaceDetail, + WorkspaceList, +} from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/components' +import { groupRosterByWorkspace } from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/roster-groups' +import { + organizationWorkspaceDetailParsers, + organizationWorkspaceDetailUrlKeys, + organizationWorkspaceIdParam, + organizationWorkspaceIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/organization-workspaces/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { useOrganizationRoster } from '@/hooks/queries/organization' + +interface OrganizationWorkspacesProps { + organizationId: string +} + +/** + * Organization-plane Workspaces page. Owns every workspace the organization + * owns and, for each one, the members who belong to it — the workspace-major + * reading of the same roster the Members page reads member-major. + * + * The list drills into one workspace's access detail, deep-linked by + * `?workspace-id=`. Both levels carry an invite action, and both open the one + * invite surface: from the list it offers every workspace the viewer + * administers, and from a detail it opens with that workspace preselected. + */ +export function OrganizationWorkspaces({ organizationId }: OrganizationWorkspacesProps) { + const { data: session } = useSession() + const { data: roster, isLoading, error } = useOrganizationRoster(organizationId) + const [searchTerm, setSearchTerm] = useSettingsSearch() + const [isInviteOpen, setIsInviteOpen] = useState(false) + + /** + * Absent on the organization plane, which is outside every workspace route. + * Read only to keep the workspace being viewed from out of the deletable set. + */ + const hostContext = useOptionalWorkspaceHostContext() + + const [selectedWorkspaceId, setSelectedWorkspaceId] = useQueryState( + organizationWorkspaceIdParam.key, + { ...organizationWorkspaceIdParam.parser, ...organizationWorkspaceIdUrlKeys } + ) + /** + * Cleared alongside the workspace id so the detail's tab, access filter, and + * ordering never linger on the list URL. `null` clears the whole group. + */ + const [, setDetailFilters] = useQueryStates( + organizationWorkspaceDetailParsers, + organizationWorkspaceDetailUrlKeys + ) + + const groups = useMemo(() => groupRosterByWorkspace(roster), [roster]) + const groupsById = useMemo( + () => new Map(groups.map((group) => [group.workspace.id, group])), + [groups] + ) + + const openWorkspace = useCallback( + (workspaceId: string) => { + // Lingering detail filters must not re-target this open — reset in the same batched push. + void setDetailFilters(null) + void setSelectedWorkspaceId(workspaceId) + }, + [setDetailFilters, setSelectedWorkspaceId] + ) + + const closeWorkspace = useCallback(() => { + void setDetailFilters(null, { history: 'replace' }) + void setSelectedWorkspaceId(null, { history: 'replace' }) + }, [setDetailFilters, setSelectedWorkspaceId]) + + const currentUserId = session?.user?.id ?? '' + const canManage = isOrgAdminRole( + roster?.members.find((member) => member.userId === currentUserId)?.role + ) + + /** + * Derived from the loaded roster rather than duplicated into state. A stale id + * — a deleted workspace restored from history, or a dead link — resolves to + * nothing and falls back to the list; the lingering param is harmless and the + * next selection overwrites it. + */ + const selectedGroup = selectedWorkspaceId ? groupsById.get(selectedWorkspaceId) : undefined + /** A deep link arrives before the roster does, so hold the detail frame while it loads. */ + const isDetailFrame = selectedWorkspaceId !== null && (isLoading || selectedGroup !== undefined) + + const back = isDetailFrame + ? { text: 'Workspaces', icon: ArrowLeft, onSelect: closeWorkspace } + : undefined + + /** + * One invite surface for both levels: from the list it offers every workspace + * the viewer administers, and from a detail it opens with that workspace + * preselected. + * + * Keyed on the workspace because the modal seeds its selection from + * `workspaceId` in `useState`, which only reads its initial value — without a + * remount, walking from the list into a workspace would leave the invite + * preselecting nothing. + */ + const inviteModal = ( + + ) + + if (isLoading) { + return ( + + Loading workspaces... + + ) + } + + if (error) { + return ( + + + {getErrorMessage(error, 'Failed to load workspaces')} + + + ) + } + + if (selectedGroup) { + return ( + <> + setIsInviteOpen(true)} + onBack={closeWorkspace} + /> + {inviteModal} + + ) + } + + return ( + <> + setIsInviteOpen(true), + }, + ] + : [] + } + > + {groups.length === 0 ? ( + No workspaces in this organization + ) : ( + + )} + + {inviteModal} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/roster-groups.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/roster-groups.ts new file mode 100644 index 00000000000..c0cec24b62f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/roster-groups.ts @@ -0,0 +1,224 @@ +import { workspaceRoleLockReason } from '@/components/permissions' +import type { + OrganizationRoster, + RosterMember, + RosterWorkspaceAccess, +} from '@/lib/api/contracts/organization' + +/** The three workspace permissions, weakest first — the order every access control reads. */ +export const WORKSPACE_ACCESS_LEVELS = ['read', 'write', 'admin'] as const + +export type WorkspaceAccessLevel = (typeof WORKSPACE_ACCESS_LEVELS)[number] + +/** Display label for every workspace permission, shared by the tables and the modal. */ +export const WORKSPACE_ACCESS_LABELS: Record = { + read: 'Read', + write: 'Write', + admin: 'Admin', +} + +/** + * How a row's access reads in the table, naming where it comes from when it is + * not a grant on this workspace. + * + * An organization admin holds admin on every workspace the organization owns, + * and a workspace owner cannot drop below admin — neither is editable here, and + * an unqualified "Admin" makes those rows indistinguishable from an ordinary + * grant another admin CAN edit. That ambiguity is the whole reason their + * checkbox and dropdown are inert, so the level says it outright rather than + * leaving a tooltip to explain an apparent bug. + * + * The filter dropdown keeps the plain {@link WORKSPACE_ACCESS_LABELS} — it + * filters on the permission, which is `admin` either way. + */ +export function workspaceAccessLabel(access: RosterWorkspaceAccess): string { + const level = WORKSPACE_ACCESS_LABELS[access.permission] + if (access.roleSource === 'org-admin') return `${level} (Organization)` + if (access.roleSource === 'owner') return `${level} (Owner)` + return level +} + +/** One workspace the organization owns, as the roster reports it. */ +export type RosterWorkspace = OrganizationRoster['workspaces'][number] + +interface WorkspaceAccessRowBase { + /** Stable row identity — the org membership id, or the invitation id. */ + id: string + name: string + email: string + image: string | null + /** This row's standing in the workspace: permission, why it is fixed, billing. */ + access: RosterWorkspaceAccess +} + +/** A joined organization member with access to the workspace. */ +export type WorkspaceMemberRow = WorkspaceAccessRowBase & { + kind: 'member' + member: RosterMember +} + +/** A pending invitation that grants access to the workspace once accepted. */ +export type WorkspaceInviteRow = WorkspaceAccessRowBase & { + kind: 'invite' + invitationId: string +} + +export type WorkspaceAccessRow = WorkspaceMemberRow | WorkspaceInviteRow + +/** One workspace with everyone who can reach it, joined or invited. */ +export interface WorkspaceGroup { + workspace: RosterWorkspace + members: WorkspaceMemberRow[] + invites: WorkspaceInviteRow[] +} + +function appendRow(index: Map, key: string, row: T) { + const rows = index.get(key) + if (rows) rows.push(row) + else index.set(key, [row]) +} + +/** + * Inverts the member-major roster into the workspace-major shape this page + * reads: one entry per organization workspace, carrying its joined members and + * its pending invitations. + * + * Both collections are indexed in a single pass keyed by workspace id — a + * `.find` per workspace × member would make this O(workspaces × members × + * access-entries). Rows keep roster order within each workspace, and the + * workspaces themselves keep the order the roster returned, which is the order + * the organization already reads them in elsewhere. + */ +export function groupRosterByWorkspace( + roster: OrganizationRoster | null | undefined +): WorkspaceGroup[] { + if (!roster) return [] + + const membersByWorkspace = new Map() + for (const member of roster.members) { + const seen = new Set() + for (const access of member.workspaces) { + if (seen.has(access.workspaceId)) continue + seen.add(access.workspaceId) + appendRow(membersByWorkspace, access.workspaceId, { + kind: 'member', + id: member.memberId, + name: member.name, + email: member.email, + image: member.image, + access, + member, + }) + } + } + + const invitesByWorkspace = new Map() + for (const invitation of roster.pendingInvitations) { + const seen = new Set() + for (const access of invitation.workspaces) { + if (seen.has(access.workspaceId)) continue + seen.add(access.workspaceId) + appendRow(invitesByWorkspace, access.workspaceId, { + kind: 'invite', + id: invitation.id, + name: invitation.inviteeName ?? invitation.email, + email: invitation.email, + image: invitation.inviteeImage, + access, + invitationId: invitation.id, + }) + } + } + + return roster.workspaces.map((workspace) => ({ + workspace, + members: membersByWorkspace.get(workspace.id) ?? [], + invites: invitesByWorkspace.get(workspace.id) ?? [], + })) +} + +/** Who is looking, and whether they administer the organization. */ +interface AccessViewer { + canManage: boolean + currentUserId: string +} + +const SELF_ADMIN_LOCK_REASON = 'You cannot remove your own admin access' + +/** + * Why this row's workspace access cannot be changed, or `null` when it can. + * + * Every reason has a matching guard on `PATCH /api/workspaces/[id]/permissions`, + * so a locked control is one the server would have refused — plus the viewer's + * own admin access, which they must not be able to drop out from under + * themselves. A pending invitation's grant is always editable: nothing depends + * on it until it is accepted. + * + * Single source for the table's control and the manage-access modal, so the two + * cannot disagree about whether a row is editable. + */ +export function workspaceAccessLockReason( + row: WorkspaceAccessRow, + { canManage, currentUserId }: AccessViewer +): string | null { + if (!canManage) return 'Only organization admins can change workspace access.' + if (row.kind === 'invite') return null + return ( + workspaceRoleLockReason(row.access.roleSource, { + isBilledAccount: row.access.isBilledAccount, + }) ?? + (row.member.userId === currentUserId && row.access.permission === 'admin' + ? SELF_ADMIN_LOCK_REASON + : null) + ) +} + +/** + * Whether this row's access to the workspace can be withdrawn — which drives + * both the row's checkbox and the modal's destructive action. + * + * Keyed on how the access was GRANTED, not on the person's organization role. + * A workspace admin is removable by another admin, including one who happens to + * administer the organization: `DELETE /api/workspaces/members/[id]` has no + * org-role guard, and even removing the workspace owner is allowed (it transfers + * ownership). What it does refuse is the billing account, and a target holding + * no permission row at all — which is exactly the `org-admin` case, where access + * is derived from the organization rather than granted here, so there is nothing + * to withdraw and removing it would leave them an admin anyway. + * + * The one rule the server does not enforce is the viewer's own row: removing + * yourself would revoke the access you are managing from. + */ +export function isWorkspaceAccessRemovable( + row: WorkspaceAccessRow, + { canManage, currentUserId }: AccessViewer +): boolean { + if (!canManage) return false + if (row.kind === 'invite') return true + if (row.access.roleSource === 'org-admin' || row.access.isBilledAccount) return false + return row.member.userId !== currentUserId +} + +/** + * Whether a person row matches the page search. `query` must already be + * trimmed and lowercased by the caller — it is compared once per row. + */ +export function rowMatchesQuery(row: WorkspaceAccessRow, query: string): boolean { + if (!query) return true + return row.name.toLowerCase().includes(query) || row.email.toLowerCase().includes(query) +} + +/** + * Whether a workspace matches the page search. One predicate serves both views: + * a (workspace, person) pair matches when either side does, so the list keeps a + * workspace whose members match, and the detail keeps every member of a + * workspace whose own name matched. + */ +export function groupMatchesQuery(group: WorkspaceGroup, query: string): boolean { + if (!query) return true + if (group.workspace.name.toLowerCase().includes(query)) return true + return ( + group.members.some((row) => rowMatchesQuery(row, query)) || + group.invites.some((row) => rowMatchesQuery(row, query)) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/search-params.ts new file mode 100644 index 00000000000..6fa77c42ba6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/organization-workspaces/search-params.ts @@ -0,0 +1,99 @@ +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** + * Co-located URL query-param definitions for the organization Workspaces page. + * The client hooks consume these typed definitions as the single source of + * truth. + * + * `workspace-id` deep-links the page to one workspace's access detail sub-view + * (mirrors `mcpServerId` on the MCP tab). The id is stored alone — the + * workspace itself is derived from the loaded roster. + */ +export const organizationWorkspaceIdParam = { + key: 'workspace-id', + parser: parseAsString, +} as const + +/** Opening a workspace's detail is a destination → push to history; clear on close. */ +export const organizationWorkspaceIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + +/** + * Ordering of the workspace list. A single scalar rather than the shared + * `sort` + `dir` pair, for the same reason the members table uses one: column + * and direction are not independent here, so four values name every reachable + * state exactly. + * + * There is no date ordering because the roster reports a workspace as `{ id, + * name }` only — offering "Newest first" would sort by a value the row cannot + * show. + */ +export const ORGANIZATION_WORKSPACE_ORDERS = ['az', 'za', 'most', 'fewest'] as const + +export type OrganizationWorkspaceOrder = (typeof ORGANIZATION_WORKSPACE_ORDERS)[number] + +/** Tabs inside the workspace detail sub-view. */ +export const WORKSPACE_ACCESS_TABS = ['members', 'pending'] as const + +export type WorkspaceAccessTab = (typeof WORKSPACE_ACCESS_TABS)[number] + +/** + * Access-level filter inside the workspace detail. `all` is the unfiltered + * default; the rest mirror the workspace permission enum. + */ +export const WORKSPACE_ACCESS_FILTERS = ['all', 'admin', 'write', 'read'] as const + +export type WorkspaceAccessFilter = (typeof WORKSPACE_ACCESS_FILTERS)[number] + +/** + * Ordering inside the workspace detail. Alphabetical only: a workspace grant + * carries no timestamp, and a member's organization join date would order these + * rows by a value the workspace view never displays. + */ +export const WORKSPACE_ACCESS_ORDERS = ['az', 'za'] as const + +export type WorkspaceAccessOrder = (typeof WORKSPACE_ACCESS_ORDERS)[number] + +/** + * List view-state. Kept separate from the detail's group below so the two + * orderings cannot collide on one wire key — only one view is mounted at a + * time, and a stale value carried between them would silently reset. + */ +export const organizationWorkspaceListParsers = { + order: parseAsStringLiteral(ORGANIZATION_WORKSPACE_ORDERS).withDefault('az'), +} as const + +/** + * Detail view-state: which side of the workspace is showing, filtered by access + * level and ordered by name. `workspace-tab` is cleared alongside + * `workspace-id` on close, so it never lingers on the list URL. + * + * The name/email filter is the settings-wide `?search=` key, owned by + * `settingsSearchParam` and consumed through `useSettingsSearch` — deliberately + * not redeclared here, since two definitions of one wire key drift. + */ +export const organizationWorkspaceDetailParsers = { + tab: parseAsStringLiteral(WORKSPACE_ACCESS_TABS).withDefault('members'), + access: parseAsStringLiteral(WORKSPACE_ACCESS_FILTERS).withDefault('all'), + order: parseAsStringLiteral(WORKSPACE_ACCESS_ORDERS).withDefault('az'), +} as const + +/** Wire keys for the detail group — `tab`/`order` are too generic to own bare. */ +export const organizationWorkspaceDetailUrlKeys = { + history: 'replace', + clearOnDefault: true, + urlKeys: { + tab: 'workspace-tab', + access: 'workspace-access', + order: 'workspace-order', + }, +} as const + +/** List filter view-state: clean URLs, no back-stack churn. */ +export const organizationWorkspaceListUrlKeys = { + history: 'replace', + clearOnDefault: true, + urlKeys: { order: 'order' }, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx index 5c3d29bb494..5f039296337 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu.tsx @@ -23,22 +23,36 @@ interface RowActionsMenuProps { /** Accessible label for the trigger, e.g. `API key actions`. */ label: string actions: RowAction[] + /** + * Disables the whole menu — for a menu that acts on a selection and has + * nothing to act on. `disabled` belongs on the TRIGGER, not on the button + * inside it: Radix gates opening on the trigger's own prop, and a disabled + * ` diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/bulk-actions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/bulk-actions.ts new file mode 100644 index 00000000000..c1c40f7ef43 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/bulk-actions.ts @@ -0,0 +1,36 @@ +import type { BulkActionCopy } from '@/app/workspace/[workspaceId]/settings/components/bulk-action' +import type { OrganizationMemberTab } from '@/app/workspace/[workspaceId]/settings/components/team-management/search-params' + +const memberCount = (rows: number) => `${rows} ${rows === 1 ? 'member' : 'members'}` +const invitationCount = (rows: number) => `${rows} ${rows === 1 ? 'invitation' : 'invitations'}` + +/** + * The members table's bulk action, which is per-tab: remove accepted members, or + * revoke pending invitations. The two are the same gesture from the user's side + * — tick rows, open the `...`, confirm — so they share one runner and one + * confirmation surface, and this table is the single place they differ. + */ +export const BULK_ACTION_COPY: Record = { + members: { + title: 'Remove from Organization', + triggerLabel: 'Actions for selected members', + pendingLabel: 'Removing...', + count: memberCount, + lead: 'Remove ', + consequence: + ' from the organization? Their workspace access is revoked, and credentials they own stop working until another member reconnects them. This action cannot be undone.', + succeeded: (rows) => `Removed ${memberCount(rows)} from the organization`, + failed: (failures, rows) => `Couldn't remove ${failures} of ${memberCount(rows)}`, + }, + invitations: { + title: 'Revoke invites', + triggerLabel: 'Actions for selected invitations', + pendingLabel: 'Revoking...', + count: invitationCount, + lead: 'Revoke ', + consequence: + '? The invite links stop working immediately. You can invite these people again at any time.', + succeeded: (rows) => `Revoked ${invitationCount(rows)}`, + failed: (failures, rows) => `Couldn't revoke ${failures} of ${invitationCount(rows)}`, + }, +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts index fc31aad7ec5..af5c175fbd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts @@ -1,6 +1,6 @@ -export { ManageCreditsModal } from './manage-credits-modal' +export { ManageAccessModal } from './manage-access-modal' export { NoOrganizationView } from './no-organization-view' -export { OrganizationMemberLists } from './organization-member-lists' +export { OrganizationMembersTable } from './organization-members-table' export { RemoveMemberDialog } from './remove-member-dialog' export { TeamSeatsOverview } from './team-seats-overview' export { TransferOwnershipDialog } from './transfer-ownership-dialog' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-access-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-access-modal/index.ts new file mode 100644 index 00000000000..a5a05830592 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-access-modal/index.ts @@ -0,0 +1 @@ +export { ManageAccessModal } from './manage-access-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-access-modal/manage-access-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-access-modal/manage-access-modal.tsx new file mode 100644 index 00000000000..4e5d5be31e0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-access-modal/manage-access-modal.tsx @@ -0,0 +1,244 @@ +'use client' + +import { useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + type ChipModalFooterSlotAction, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { Member } from '@/lib/workspaces/organization' +import { + ORGANIZATION_ROLE_LABELS, + type OrganizationMemberRole, + type OrganizationRosterRow, +} from '@/app/workspace/[workspaceId]/settings/components/team-management/member-rows' +import { + useCancelInvitation, + useResendInvitation, + useUpdateOrganizationMemberRole, +} from '@/hooks/queries/organization' + +const ORGANIZATION_ROLE_OPTIONS = [ + { value: 'admin', label: ORGANIZATION_ROLE_LABELS.admin }, + { value: 'member', label: ORGANIZATION_ROLE_LABELS.member }, +] as const + +const RETRY_HINT = 'Please try again in a moment.' + +interface ManageAccessModalProps { + open: boolean + onOpenChange: (open: boolean) => void + organizationId: string + /** The row whose access is being managed. Remount on change via a `key`. */ + row: OrganizationRosterRow + /** Whether the viewer administers the organization. */ + canManage: boolean + currentUserId: string + /** Opens the shared removal confirmation, which owns the credential-impact disclosure. */ + onRemoveMember: (member: Member) => void + /** Opens the shared ownership-transfer dialog. */ + onTransferOwnership: () => void +} + +/** + * Every per-member action for one roster row: the organization role plus the + * destructive exits. A member gets a role change behind Save and a removal (or + * a transfer/leave when the row is the viewer); a pending invitation gets + * resend and revoke instead of a role change. + * + * The role control is disabled with a reason wherever the route would refuse + * the change, so the modal never offers an edit that can only fail. + */ +export function ManageAccessModal({ + open, + onOpenChange, + organizationId, + row, + canManage, + currentUserId, + onRemoveMember, + onTransferOwnership, +}: ManageAccessModalProps) { + const [role, setRole] = useState(row.role) + + const updateMemberRole = useUpdateOrganizationMemberRole() + const resendInvitation = useResendInvitation() + const cancelInvitation = useCancelInvitation() + + const isSelf = row.kind === 'member' && row.member.userId === currentUserId + const isOwner = row.role === 'owner' + + const roleLockReason = !canManage + ? 'Only organization admins can change roles.' + : row.kind === 'invitation' + ? "A pending invitation's role is set when the invite is sent." + : isOwner + ? "The organization owner's role cannot be changed." + : isSelf + ? 'You cannot change your own role.' + : row.role === 'external' + ? 'External members keep a fixed role.' + : null + + const isRoleLocked = roleLockReason !== null + const roleOptions = isRoleLocked + ? [{ value: row.role, label: ORGANIZATION_ROLE_LABELS[row.role] }] + : ORGANIZATION_ROLE_OPTIONS + + const close = () => onOpenChange(false) + + const handleRoleChange = (value: string) => { + if (value === 'admin' || value === 'member') setRole(value) + } + + const handleSave = () => { + if (row.kind !== 'member') return + if (role !== 'admin' && role !== 'member') return + + updateMemberRole.mutate( + { orgId: organizationId, userId: row.member.userId, role }, + { + onSuccess: close, + onError: (error) => + toast.error("Couldn't update role", { + description: getErrorMessage(error, RETRY_HINT), + }), + } + ) + } + + const handOffToDialog = (open: () => void) => { + close() + open() + } + + const toRemovalTarget = (): Member | null => { + if (row.kind !== 'member') return null + return { + id: row.member.memberId, + role: row.member.role, + user: { + id: row.member.userId, + name: row.member.name, + email: row.member.email, + image: row.member.image, + }, + } + } + + const handleRemove = () => { + const target = toRemovalTarget() + if (!target) return + handOffToDialog(() => onRemoveMember(target)) + } + + const handleResend = () => { + if (row.kind !== 'invitation') return + resendInvitation.mutate( + { invitationId: row.invitation.id, orgId: organizationId }, + { + onSuccess: () => { + toast.success('Invitation resent', { description: row.email }) + close() + }, + onError: (error) => + toast.error("Couldn't resend invitation", { + description: getErrorMessage(error, RETRY_HINT), + }), + } + ) + } + + const handleRevoke = () => { + if (row.kind !== 'invitation') return + cancelInvitation.mutate( + { invitationId: row.invitation.id, orgId: organizationId }, + { + onSuccess: () => { + toast.success('Invitation revoked', { description: row.email }) + close() + }, + onError: (error) => + toast.error("Couldn't revoke invitation", { + description: getErrorMessage(error, RETRY_HINT), + }), + } + ) + } + + const isInvitationBusy = resendInvitation.isPending || cancelInvitation.isPending + + const secondaryActions: ChipModalFooterSlotAction[] = + row.kind === 'invitation' + ? canManage + ? [ + { + label: cancelInvitation.isPending ? 'Revoking...' : 'Revoke invite', + variant: 'destructive', + onClick: handleRevoke, + disabled: isInvitationBusy, + }, + ] + : [] + : isSelf && isOwner + ? [{ label: 'Transfer ownership', onClick: () => handOffToDialog(onTransferOwnership) }] + : isSelf + ? [{ label: 'Leave organization', variant: 'destructive', onClick: handleRemove }] + : canManage && !isOwner + ? [ + { + label: 'Remove from Organization', + variant: 'destructive', + onClick: handleRemove, + }, + ] + : [] + + const primaryAction = + row.kind === 'invitation' + ? { + label: resendInvitation.isPending ? 'Resending...' : 'Resend invite', + onClick: handleResend, + disabled: !canManage || isInvitationBusy, + } + : { + label: updateMemberRole.isPending ? 'Saving...' : 'Save', + onClick: handleSave, + disabled: isRoleLocked || role === row.role || updateMemberRole.isPending, + } + + return ( + + Manage access + + + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/index.ts deleted file mode 100644 index accec25dcd3..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ManageCreditsModal, type ManageCreditsTarget } from './manage-credits-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index 84fb6d65b94..7a7a572edfb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -45,7 +45,7 @@ export function NoOrganizationView({
-

Create Your Team Workspace

+

Create your Team workspace

You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your workspace to start collaborating with your team. @@ -65,7 +65,7 @@ export function NoOrganizationView({ aria-label='Ignore this field' />

- + - {isCreatingOrg ? 'Creating...' : 'Create Team Workspace'} + {isCreatingOrg ? 'Creating...' : 'Create Team workspace'}
@@ -174,7 +174,7 @@ export function NoOrganizationView({ return (
-

No Team Workspace

+

No Team workspace

You don't have a team workspace yet. To collaborate with others, first upgrade to a team or enterprise plan. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/index.ts deleted file mode 100644 index 30241e812a1..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OrganizationMemberLists } from './organization-member-lists' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx deleted file mode 100644 index aae0637e267..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ /dev/null @@ -1,496 +0,0 @@ -'use client' - -import { useMemo, useState } from 'react' -import { ChipDropdown, toast } from '@sim/emcn' -import { createLogger } from '@sim/logger' -import { isOrgAdminRole } from '@sim/platform-authz/predicates' -import { getErrorMessage } from '@sim/utils/errors' -import { formatDate } from '@sim/utils/formatting' -import { - type OrgRole, - type PermissionType, - RoleLockTooltip, - workspaceRoleLockReason, -} from '@/components/permissions' -import type { - OrganizationRoster, - RosterMember, - RosterPendingInvitation, - RosterWorkspaceAccess, -} from '@/lib/api/contracts/organization' -import type { Member } from '@/lib/workspaces/organization' -import { - MemberRow, - MemberSection, -} from '@/app/workspace/[workspaceId]/settings/components/member-list' -import { - type RowAction, - RowActionsMenu, -} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { - ManageCreditsModal, - type ManageCreditsTarget, -} from '@/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal' -import { - useRemoveWorkspaceMember, - useUpdateWorkspacePermissions, -} from '@/hooks/queries/invitations' -import { - useCancelInvitation, - useResendInvitation, - useUpdateInvitation, - useUpdateOrganizationMemberRole, -} from '@/hooks/queries/organization' - -const logger = createLogger('OrganizationMemberLists') - -const ORG_ROLE_OPTIONS = [ - { value: 'admin', label: 'Admin' }, - { value: 'member', label: 'Member' }, -] as const - -const WORKSPACE_ROLE_OPTIONS = [ - { value: 'read', label: 'Read' }, - { value: 'write', label: 'Write' }, - { value: 'admin', label: 'Admin' }, -] as const - -function capitalize(value: string) { - return value.charAt(0).toUpperCase() + value.slice(1) -} - -function copyToClipboard(text: string) { - void navigator.clipboard.writeText(text) -} - -function buildActionsMenu(actions: RowAction[]) { - return -} - -interface OrganizationMemberListsProps { - canManage: boolean - organizationId: string - roster: OrganizationRoster | null | undefined - isLoadingRoster: boolean - currentUserId: string - /** - * The roster filter, owned by the page so it can live in the URL — this - * component renders the shared `SettingsPanel` search box's results, it does - * not own the box. - */ - query: string - onRemoveMember: (member: Member) => void - onTransferOwnership?: () => void -} - -/** - * Renders the organization roster as Teammates-style sections: an org-level - * "Members" section followed by one section per workspace, each listing that - * workspace's members and pending grants. A single search box filters every - * section; sections with no matches collapse while a search is active. - */ -export function OrganizationMemberLists({ - canManage, - organizationId, - roster, - isLoadingRoster, - currentUserId, - query, - onRemoveMember, - onTransferOwnership, -}: OrganizationMemberListsProps) { - const [creditsTarget, setCreditsTarget] = useState(null) - - const updateMemberRole = useUpdateOrganizationMemberRole() - const updateInvitation = useUpdateInvitation() - const updatePermissions = useUpdateWorkspacePermissions() - const removeWorkspaceMember = useRemoveWorkspaceMember() - const cancelInvitation = useCancelInvitation() - const resendInvitation = useResendInvitation() - - const members = useMemo(() => roster?.members ?? [], [roster]) - const pendingInvitations = useMemo(() => roster?.pendingInvitations ?? [], [roster]) - const workspaces = useMemo(() => roster?.workspaces ?? [], [roster]) - - const q = query.trim().toLowerCase() - const matches = (name: string, email: string) => - !q || name.toLowerCase().includes(q) || email.toLowerCase().includes(q) - - const isActiveSearch = q.length > 0 - - const renderOrgMemberRow = (member: RosterMember) => { - const isSelf = member.userId === currentUserId - const isOwner = member.role === 'owner' - const isExternal = member.role === 'external' - const editable = canManage && !isSelf && !isOwner && !isExternal - const canRemove = canManage && !isSelf && !isOwner - - return ( - - updateMemberRole - .mutateAsync({ - orgId: organizationId, - userId: member.userId, - role: role as OrgRole, - }) - .catch((error) => logger.error('Failed to update member role', { error })) - } - options={ORG_ROLE_OPTIONS} - matchTriggerWidth={false} - disabled={updateMemberRole.isPending} - /> - ) : ( - - ) - } - menu={buildActionsMenu([ - { label: 'Copy email', onSelect: () => copyToClipboard(member.email) }, - ...(canManage && !isOwner - ? [ - { - label: 'Manage Credits', - onSelect: () => - setCreditsTarget({ - userId: member.userId, - name: member.name, - email: member.email, - }), - }, - ] - : []), - ...(canRemove - ? [ - { - label: 'Remove', - destructive: true, - onSelect: () => - onRemoveMember({ - id: member.memberId, - role: member.role, - user: { - id: member.userId, - name: member.name, - email: member.email, - image: member.image, - }, - }), - }, - ] - : []), - ...(isSelf && isOwner && onTransferOwnership - ? [{ label: 'Transfer ownership', onSelect: () => onTransferOwnership() }] - : []), - ...(canManage && isSelf && !isOwner - ? [ - { - label: 'Leave organization', - destructive: true, - onSelect: () => - onRemoveMember({ - id: member.memberId, - role: member.role, - user: { - id: member.userId, - name: member.name, - email: member.email, - image: member.image, - }, - }), - }, - ] - : []), - ])} - /> - ) - } - - const renderInviteRow = ( - invitation: RosterPendingInvitation, - keyPrefix: string, - roleControl: React.ReactNode - ) => ( - copyToClipboard(invitation.email) }, - ...(canManage - ? [ - { - label: 'Resend invite', - onSelect: () => - resendInvitation - .mutateAsync({ invitationId: invitation.id, orgId: organizationId }) - .catch((error) => logger.error('Failed to resend invitation', { error })), - }, - { - label: 'Revoke invite', - destructive: true, - onSelect: () => - cancelInvitation - .mutateAsync({ invitationId: invitation.id, orgId: organizationId }) - .catch((error) => logger.error('Failed to revoke invitation', { error })), - }, - ] - : []), - ])} - /> - ) - - const renderOrgInviteRow = (invitation: RosterPendingInvitation) => { - const isExternal = invitation.membershipIntent === 'external' - const roleControl = isExternal ? ( - - ) : ( - - updateInvitation - .mutateAsync({ - orgId: organizationId, - invitationId: invitation.id, - role: role as OrgRole, - }) - .catch((error) => logger.error('Failed to update invitation role', { error })) - } - options={ORG_ROLE_OPTIONS} - matchTriggerWidth={false} - disabled={!canManage || updateInvitation.isPending} - /> - ) - return renderInviteRow(invitation, 'org-invite', roleControl) - } - - const renderWorkspaceMemberRow = ( - member: RosterMember, - workspaceId: string, - access: RosterWorkspaceAccess - ) => { - const isSelf = member.userId === currentUserId - const wouldDemoteSelf = isSelf && access.permission === 'admin' - /** - * Every reason here has a matching server guard, so a locked control is one - * the route would have refused. Derived from the roster payload rather than - * from the org role alone, which missed the workspace owner and the billing - * account. - */ - const lockReason = workspaceRoleLockReason(access.roleSource, { - isBilledAccount: access.isBilledAccount, - }) - const disabled = - !canManage || lockReason !== null || wouldDemoteSelf || updatePermissions.isPending - const canRemoveFromWorkspace = canManage && !isOrgAdminRole(member.role) && !isSelf - - return ( - - - updatePermissions - .mutateAsync({ - workspaceId, - organizationId, - updates: [{ userId: member.userId, permissions: permission as PermissionType }], - }) - .catch((error) => - logger.error('Failed to update workspace permission', { error }) - ) - } - options={WORKSPACE_ROLE_OPTIONS} - matchTriggerWidth={false} - disabled={disabled} - /> - - } - menu={buildActionsMenu([ - { label: 'Copy email', onSelect: () => copyToClipboard(member.email) }, - ...(canRemoveFromWorkspace - ? [ - { - label: 'Remove from workspace', - destructive: true, - onSelect: () => - removeWorkspaceMember - .mutateAsync({ userId: member.userId, workspaceId, organizationId }) - .catch((error) => { - logger.error('Failed to remove workspace member', { error }) - toast.error("Couldn't remove member", { - description: getErrorMessage(error, 'Please try again in a moment.'), - }) - }), - }, - ] - : []), - ])} - /> - ) - } - - const renderWorkspaceInviteRow = ( - invitation: RosterPendingInvitation, - workspaceId: string, - access: RosterWorkspaceAccess - ) => { - const roleControl = ( - - updateInvitation - .mutateAsync({ - orgId: organizationId, - invitationId: invitation.id, - grants: [{ workspaceId, permission: permission as PermissionType }], - }) - .catch((error) => logger.error('Failed to update invitation grant', { error })) - } - options={WORKSPACE_ROLE_OPTIONS} - matchTriggerWidth={false} - disabled={!canManage || updateInvitation.isPending} - /> - ) - return renderInviteRow(invitation, `ws-${workspaceId}-invite`, roleControl) - } - - const filteredOrgMembers = members.filter((m) => matches(m.name, m.email)) - const orgPending = pendingInvitations.filter((inv) => inv.kind === 'organization') - const filteredOrgPending = orgPending.filter((inv) => - matches(inv.inviteeName ?? inv.email, inv.email) - ) - const orgRowCount = members.length + orgPending.length - const hasOrgMatches = filteredOrgMembers.length + filteredOrgPending.length > 0 - const showMembersSection = !isActiveSearch || hasOrgMatches - - /** - * Group each workspace's members and pending invites once per roster change. - * Indexed by a single pass over the roster rather than a `.find` per - * workspace × member — that inner scan made this O(workspaces × members × - * access-entries). Members are appended in roster order, so each group keeps - * the same ordering the per-workspace scan produced. - */ - const workspaceGroups = useMemo(() => { - const membersByWorkspace = new Map< - string, - { member: RosterMember; access: RosterWorkspaceAccess }[] - >() - for (const member of members) { - const seen = new Set() - for (const access of member.workspaces) { - if (seen.has(access.workspaceId)) continue - seen.add(access.workspaceId) - const entries = membersByWorkspace.get(access.workspaceId) - if (entries) entries.push({ member, access }) - else membersByWorkspace.set(access.workspaceId, [{ member, access }]) - } - } - - const invitesByWorkspace = new Map< - string, - { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess }[] - >() - for (const invitation of pendingInvitations) { - const seen = new Set() - for (const access of invitation.workspaces) { - if (seen.has(access.workspaceId)) continue - seen.add(access.workspaceId) - const entries = invitesByWorkspace.get(access.workspaceId) - if (entries) entries.push({ invitation, access }) - else invitesByWorkspace.set(access.workspaceId, [{ invitation, access }]) - } - } - - return workspaces.map((workspace) => ({ - workspace, - workspaceMembers: membersByWorkspace.get(workspace.id) ?? [], - workspaceInvites: invitesByWorkspace.get(workspace.id) ?? [], - })) - }, [workspaces, members, pendingInvitations]) - - return ( - <> - {showMembersSection && ( - - {filteredOrgMembers.map(renderOrgMemberRow)} - {filteredOrgPending.map(renderOrgInviteRow)} - - )} - - {workspaceGroups.map(({ workspace, workspaceMembers, workspaceInvites }) => { - const visibleMembers = workspaceMembers.filter(({ member }) => - matches(member.name, member.email) - ) - const visibleInvites = workspaceInvites.filter(({ invitation }) => - matches(invitation.inviteeName ?? invitation.email, invitation.email) - ) - const totalCount = workspaceMembers.length + workspaceInvites.length - const hasMatches = visibleMembers.length + visibleInvites.length > 0 - - if (isActiveSearch && !hasMatches) return null - - return ( - - {visibleMembers.map(({ member, access }) => - renderWorkspaceMemberRow(member, workspace.id, access) - )} - {visibleInvites.map(({ invitation, access }) => - renderWorkspaceInviteRow(invitation, workspace.id, access) - )} - - ) - })} - - {canManage && ( - { - if (!open) setCreditsTarget(null) - }} - organizationId={organizationId} - member={creditsTarget} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-members-table/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-members-table/index.ts new file mode 100644 index 00000000000..f6665a7699f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-members-table/index.ts @@ -0,0 +1 @@ +export { OrganizationMembersTable } from './organization-members-table' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-members-table/organization-members-table.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-members-table/organization-members-table.tsx new file mode 100644 index 00000000000..85df83f7a30 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-members-table/organization-members-table.tsx @@ -0,0 +1,357 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Chip, ChipDropdown, Table, type TableColumn, TableIdentityCell } from '@sim/emcn' +import { formatDate } from '@sim/utils/formatting' +import { useQueryStates } from 'nuqs' +import type { OrganizationRoster } from '@/lib/api/contracts/organization' +import type { Member } from '@/lib/workspaces/organization' +import { + BulkActionDialog, + BulkActionMenu, + useBulkAction, +} from '@/app/workspace/[workspaceId]/settings/components/bulk-action' +import { BULK_ACTION_COPY } from '@/app/workspace/[workspaceId]/settings/components/team-management/bulk-actions' +import { ManageAccessModal } from '@/app/workspace/[workspaceId]/settings/components/team-management/components/manage-access-modal' +import { + ORGANIZATION_ROLE_LABELS, + type OrganizationRosterRow, + toOrganizationInvitationRow, + toOrganizationMemberRow, +} from '@/app/workspace/[workspaceId]/settings/components/team-management/member-rows' +import { + ORGANIZATION_MEMBER_TABS, + ORGANIZATION_ROLE_FILTERS, + ORGANIZATION_ROW_ORDERS, + type OrganizationMemberTab, + type OrganizationRoleFilter, + type OrganizationRowOrder, + organizationMembersParsers, + organizationMembersUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/team-management/search-params' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { useCancelInvitation, useRemoveMember } from '@/hooks/queries/organization' + +/** + * Every control here is labelled through the literal union `search-params.ts` + * owns, so a parser and the control it drives cannot drift: a value added to a + * parser fails to compile until it is named below. + */ +const TAB_LABELS: Record = { + members: 'Members', + invitations: 'Pending invitations', +} + +const ROLE_FILTER_LABELS: Record = { + all: 'All roles', + ...ORGANIZATION_ROLE_LABELS, +} + +const ORDER_LABELS: Record = { + newest: 'Newest first', + oldest: 'Oldest first', + az: 'A-Z', + za: 'Z-A', +} + +const TABS = ORGANIZATION_MEMBER_TABS.map((id) => ({ id, label: TAB_LABELS[id] })) + +const ROLE_FILTER_OPTIONS = ORGANIZATION_ROLE_FILTERS.map((value) => ({ + value, + label: ROLE_FILTER_LABELS[value], +})) + +/** + * Ownership is never invited — it is set when the organization is created and + * afterwards only moves through the transfer flow, so the invite contract's + * membership enum has no `owner` and an invitation row can only ever project to + * admin / member / external. Offering an Owner filter over invitations would be + * a control that always returns nothing. + */ +const INVITATION_ROLE_FILTER_OPTIONS = ROLE_FILTER_OPTIONS.filter( + (option) => option.value !== 'owner' +) + +const ORDER_OPTIONS = ORGANIZATION_ROW_ORDERS.map((value) => ({ + value, + label: ORDER_LABELS[value], +})) + +/** + * Both toolbar filters are pinned to one width so the row does not reflow as a + * value changes — a trigger that resizes to its label shifts every control + * beside it on every selection. 130px clears the longest label ("Newest first", + * 114px) with room to spare, and holds both dropdowns on the same edge. + */ +const FILTER_TRIGGER_WIDTH = 'w-[130px]' + +/** + * Narrows the bare `string` a `ChipDropdown` or the tab strip reports back to the + * literal union its parser accepts. `undefined` means the control named a value + * the URL cannot hold, which callers write as `null` — nuqs then clears the param + * so the filter falls back to its default rather than persisting a dead value. + */ +function pick(values: readonly T[], value: string): T | undefined { + return values.find((candidate) => candidate === value) +} + +interface OrganizationMembersTableProps { + organizationId: string + /** Whether the viewer administers the organization. */ + canManage: boolean + currentUserId: string + roster: OrganizationRoster | null | undefined + isLoadingRoster: boolean + /** Opens the shared removal confirmation, which owns the credential-impact disclosure. */ + onRemoveMember: (member: Member) => void + /** Opens the shared ownership-transfer dialog. */ + onTransferOwnership: () => void +} + +/** + * The organization roster as one table: accepted members on the first tab, + * pending organization invitations on the second, filtered by name/email, by + * role, and ordered by join date. Tabs, filters, and ordering live in the URL; + * the visible rows are derived from them, never stored. + * + * Selection exists only to bulk-remove members. The owner and the viewer's own + * row are never selectable — both are refused by the removal route, and leaving + * is a deliberate single-member flow. `isRowSelectable` disables their + * checkboxes and drops them from the header's counts, so select-all still + * reaches "all" and toggles back off. + */ +export function OrganizationMembersTable({ + organizationId, + canManage, + currentUserId, + roster, + isLoadingRoster, + onRemoveMember, + onTransferOwnership, +}: OrganizationMembersTableProps) { + const [filters, setFilters] = useQueryStates( + organizationMembersParsers, + organizationMembersUrlKeys + ) + const [search, setSearch] = useSettingsSearch() + const [rawSelection, setRawSelection] = useState([]) + const [manageRow, setManageRow] = useState(null) + + const removeMember = useRemoveMember() + const cancelInvitation = useCancelInvitation() + + const isMembersTab = filters.tab === 'members' + const bulkCopy = BULK_ACTION_COPY[filters.tab] + const query = search.trim() + const roleFilterOptions = isMembersTab ? ROLE_FILTER_OPTIONS : INVITATION_ROLE_FILTER_OPTIONS + /** + * A deep link can still carry `role=owner` onto the invitations tab, where the + * option no longer exists. Derive the applied value rather than trusting the + * param, so the dropdown never displays a role it is not filtering by. + */ + const activeRole = roleFilterOptions.some((option) => option.value === filters.role) + ? filters.role + : 'all' + + const rows = useMemo(() => { + const source: OrganizationRosterRow[] = isMembersTab + ? (roster?.members ?? []).map((member) => + toOrganizationMemberRow(member, { canManage, currentUserId }) + ) + : (roster?.pendingInvitations ?? []) + .filter((invitation) => invitation.kind === 'organization') + .map((invitation) => toOrganizationInvitationRow(invitation, { canManage })) + + const needle = query.toLowerCase() + const matching = source.filter((row) => { + if (activeRole !== 'all' && row.role !== activeRole) return false + if (!needle) return true + return row.name.toLowerCase().includes(needle) || row.email.toLowerCase().includes(needle) + }) + + /** + * Alphabetical sorts read the identity the tab actually shows: a member's + * name, and an invitation's email — an invitee who has not signed up has no + * name, so the projection falls back to the email and sorting by `name` + * would order those rows by a value the row never displays. + */ + const sortKey = (row: OrganizationRosterRow) => (isMembersTab ? row.name : row.email) + + return matching.sort((a, b) => { + switch (filters.order) { + case 'oldest': + return a.createdAt - b.createdAt + case 'az': + return sortKey(a).localeCompare(sortKey(b)) + case 'za': + return sortKey(b).localeCompare(sortKey(a)) + default: + return b.createdAt - a.createdAt + } + }) + }, [roster, isMembersTab, activeRole, filters.order, query, canManage, currentUserId]) + + /** + * The stored selection is raw user intent; what the table and the bulk action + * see is this projection onto the rows that are currently visible AND + * actionable, so a tab or filter change can never leave a hidden row armed for + * removal. + */ + const selectedRows = useMemo(() => { + const armed = new Set(rawSelection) + return rows.filter((row) => row.selectable && armed.has(row.id)) + }, [rows, rawSelection]) + + const selectedIds = useMemo(() => selectedRows.map((row) => row.id), [selectedRows]) + + /** + * One action for both tabs: removing a member and revoking an invitation + * differ in the call they make per row and in how the result is worded, and + * nothing else. + */ + const bulk = useBulkAction({ + copy: bulkCopy, + rows: selectedRows, + perform: (row) => + row.kind === 'member' + ? removeMember.mutateAsync({ memberId: row.member.userId, orgId: organizationId }) + : cancelInvitation.mutateAsync({ invitationId: row.invitation.id, orgId: organizationId }), + onSettled: () => setRawSelection([]), + }) + + const emptyMessage = isLoadingRoster + ? 'Loading…' + : query + ? `No results for “${query}”` + : activeRole !== 'all' + ? 'No results match your filters' + : isMembersTab + ? 'No members yet' + : 'No pending invitations' + + /** + * No column headers: a name over an email, a role word, and a date are each + * self-evident, so labelling them adds a band of chrome that says nothing the + * rows do not. The select-all band above still carries the row count. + */ + const columns: TableColumn[] = [ + { + key: 'identity', + cell: (row) => ( + + ), + }, + { + key: 'role', + width: 120, + cell: (row) => ORGANIZATION_ROLE_LABELS[row.role], + }, + { + key: 'date', + align: 'right', + width: 140, + cell: (row) => formatDate(new Date(row.createdAt)), + }, + { + key: 'access', + align: 'right', + width: 160, + cell: (row) => ( + setManageRow(row)}> + Manage access + + ), + }, + ] + + return ( + <> +

row.id} + columns={columns} + tabs={{ + items: TABS, + activeId: filters.tab, + onChange: (value) => setFilters({ tab: pick(ORGANIZATION_MEMBER_TABS, value) ?? null }), + }} + toolbar={{ + search: { + value: search, + onChange: setSearch, + placeholder: 'Search by name or email', + }, + filters: ( + <> + + setFilters({ role: pick(ORGANIZATION_ROLE_FILTERS, value) ?? null }) + } + /> + + setFilters({ order: pick(ORGANIZATION_ROW_ORDERS, value) ?? null }) + } + /> + + ), + }} + selection={ + canManage + ? { + selectedIds, + onSelectionChange: setRawSelection, + isRowSelectable: (row: OrganizationRosterRow) => row.selectable, + bulkActions: ( + + ), + } + : undefined + } + empty={emptyMessage} + /> + + {manageRow && ( + { + if (!open) setManageRow(null) + }} + organizationId={organizationId} + row={manageRow} + canManage={canManage} + currentUserId={currentUserId} + onRemoveMember={onRemoveMember} + onTransferOwnership={onTransferOwnership} + /> + )} + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx index e526f782360..22db1fc9d38 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx @@ -44,8 +44,8 @@ export function RemoveMemberDialog({ const title = isSelfRemoval ? 'Leave Organization' : isExternalRemoval - ? 'Remove External Member' - : 'Remove Team Member' + ? 'Remove external member' + : 'Remove team member' const errorMessage = error ? getErrorMessage(error) || 'Failed to remove member' : null @@ -84,7 +84,7 @@ export function RemoveMemberDialog({ ] } confirm={{ - label: isSelfRemoval ? 'Leave Organization' : 'Remove', + label: isSelfRemoval ? 'Leave Organization' : 'Remove from Organization', onClick: () => onConfirmRemove(), pending: isSubmitting, /** diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx index 8df81724bb1..37de8e78a43 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/team-seats-overview/team-seats-overview.tsx @@ -1,4 +1,4 @@ -import { Badge, ChipLink, cn } from '@sim/emcn' +import { Badge, ChipLink, Info } from '@sim/emcn' import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -19,8 +19,6 @@ interface TeamSeatsOverviewProps { totalSeats: number /** Seats consumed by actual members. Pending invites are not counted here. */ usedSeats: number - /** Outstanding invites that have not been accepted yet (do not consume a seat). */ - pendingSeats?: number } export function TeamSeatsOverview({ @@ -29,7 +27,6 @@ export function TeamSeatsOverview({ isLoadingSubscription, totalSeats, usedSeats, - pendingSeats = 0, }: TeamSeatsOverviewProps) { if (isLoadingSubscription) { return null @@ -41,7 +38,7 @@ export function TeamSeatsOverview({
No active Team subscription - + Purchase a Team plan to invite teammates to this organization.
@@ -58,80 +55,47 @@ export function TeamSeatsOverview({ const isEnterprise = checkEnterprisePlan(subscriptionData) const isSeatDataPending = !isEnterprise && totalSeats === 0 const isOverLimit = totalSeats > 0 && usedSeats > totalSeats - const pillCount = Math.max(totalSeats, usedSeats, 1) if (isSeatDataPending) { return null } - const pendingBadge = - pendingSeats > 0 ? ( - - {pendingSeats} pending - - ) : null - /** * Team plans have no fixed seat cap — the seat count is reconciled to the - * member count, so a used/total ratio (and its meter) is always 100% and - * carries no information. Show a plain seat count instead, and reserve the - * cap meter for Enterprise, where seats are a fixed allotment. + * member count, so a used/total ratio is always 100% and carries no + * information. Show a plain seat count instead, and reserve the used/total + * line for Enterprise, where seats are a fixed allotment. */ if (!isEnterprise) { return ( -
- - {usedSeats} {usedSeats === 1 ? 'seat' : 'seats'} - - {pendingBadge} -
+ + {usedSeats} {usedSeats === 1 ? 'seat' : 'seats'} +
) } return ( - -
-
- - {usedSeats} used / {totalSeats} total - -
- {pendingBadge} - {isOverLimit && ( - - Over limit - - )} -
-
- -
- {Array.from({ length: pillCount }).map((_, i) => { - const isFilled = i < usedSeats - const isOverage = i >= totalSeats - return ( -
- ) - })} -
- -

+ {isOverLimit ? 'You have more teammates than seats. Contact support to adjust your enterprise seat count.' : 'Contact support for enterprise seat changes.'} -

+ + } + > +
+ + {usedSeats} used / {totalSeats} total + + {isOverLimit && ( + + Over limit + + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx index 7da7370a690..d2b4dcdda60 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx @@ -2,21 +2,51 @@ import { useMemo, useState } from 'react' import { - Avatar, - AvatarFallback, - AvatarImage, - Badge, Banner, - ChipConfirmModal, - ChipInput, - cn, - Search, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, Skeleton, + Table, + type TableColumn, + TableIdentityCell, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { getUserColor } from '@/lib/workspaces/colors' +import { ORGANIZATION_ROLE_LABELS } from '@/app/workspace/[workspaceId]/settings/components/team-management/member-rows' import type { RosterMember } from '@/hooks/queries/organization' +/** + * Candidates are ranked admins-first because an admin already holds most of what + * ownership adds, then alphabetically so the list is stable between opens. + */ +function byOwnershipReadiness(a: RosterMember, b: RosterMember) { + if (a.role === 'admin' && b.role !== 'admin') return -1 + if (a.role !== 'admin' && b.role === 'admin') return 1 + return a.name.localeCompare(b.name) +} + +const CANDIDATE_COLUMNS: TableColumn[] = [ + { + key: 'identity', + cell: (member) => ( + + ), + }, + { + key: 'role', + align: 'right', + width: 96, + cell: (member) => ORGANIZATION_ROLE_LABELS[member.role], + }, +] + interface TransferOwnershipDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -32,6 +62,13 @@ interface TransferOwnershipDialogProps { onOpenBillingPortal: () => void } +/** + * Hands the organization to another member and leaves. Structured as an ordinary + * chip modal — header, one labelled field, footer — rather than a confirm modal, + * because the decision the user makes here is a *choice from a list*, not a + * yes/no. The list is the shared `Table` in single-select mode, so a candidate + * row reads exactly like a member row on the Members page. + */ export function TransferOwnershipDialog({ open, onOpenChange, @@ -49,25 +86,25 @@ export function TransferOwnershipDialog({ const [search, setSearch] = useState('') const [selectedUserId, setSelectedUserId] = useState(null) + /** Ownership can only pass to an internal member who is not already the owner. */ + const eligible = useMemo( + () => + members.filter( + (member) => + member.userId !== currentUserId && member.role !== 'owner' && member.role !== 'external' + ), + [members, currentUserId] + ) + const candidates = useMemo(() => { - const others = members.filter( - (m) => m.userId !== currentUserId && m.role !== 'owner' && m.role !== 'external' - ) - others.sort((a, b) => { - if (a.role === 'admin' && b.role !== 'admin') return -1 - if (a.role !== 'admin' && b.role === 'admin') return 1 - return a.name.localeCompare(b.name) - }) - if (!search.trim()) return others - const q = search.trim().toLowerCase() - return others.filter( - (m) => m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) + const ranked = [...eligible].sort(byOwnershipReadiness) + const query = search.trim().toLowerCase() + if (!query) return ranked + return ranked.filter( + (member) => + member.name.toLowerCase().includes(query) || member.email.toLowerCase().includes(query) ) - }, [members, currentUserId, search]) - - const hasCandidates = members.some( - (m) => m.userId !== currentUserId && m.role !== 'owner' && m.role !== 'external' - ) + }, [eligible, search]) const handleClose = (next: boolean) => { if (!next) { @@ -77,139 +114,92 @@ export function TransferOwnershipDialog({ onOpenChange(next) } - const handleConfirm = async () => { + const handleConfirm = () => { if (!selectedUserId) return - await onConfirm(selectedUserId) + void onConfirm(selectedUserId) } return ( - -
+ + handleClose(false)}>Leave organization + + {hasPaidSubscription && ( + /** + * Wrapped to the field gutter: `ChipModalBody` pads `px-2` and every + * `ChipModalField` adds another `px-2`, so a bare notice would sit 8px + * proud of the field beneath it. + */ +
+ +
+ )} + {isLoadingMembers ? ( -
- - -
- - - + +
+ +
-
- ) : !hasCandidates ? ( -

- You're the only member of this organization. Invite another admin before leaving. -

- ) : ( -
-

- As the owner, you need to hand off the organization before you can leave. Pick a - member to become the new owner. They'll inherit billing access, seat management, and - all owner-only permissions. You'll lose access to every shared workspace in this - organization. + + ) : eligible.length === 0 ? ( + +

+ You're the only member of this organization.

- - {hasPaidSubscription && ( - - Your payment method stays on this organization - - Future charges will keep hitting the card you added. Open the Stripe billing - portal to remove it before you leave. - - - } - /> - )} - - {portalError &&

{portalError}

} - - setSearch(e.target.value)} - placeholder='Search members...' + + ) : ( + +
member.userId} + columns={CANDIDATE_COLUMNS} + toolbar={{ + search: { + value: search, + onChange: setSearch, + placeholder: 'Search members...', + }, + }} + selection={{ + mode: 'single', + selectedId: selectedUserId, + onSelect: setSelectedUserId, + }} + empty={`No members match "${search.trim()}"`} /> - -
- {candidates.length === 0 ? ( -
- No members match "{search}" -
- ) : ( -
    - {candidates.map((m) => { - const isSelected = selectedUserId === m.userId - return ( -
  • - -
  • - ) - })} -
- )} -
- + )} - {error && ( -

- {getErrorMessage(error) || 'Failed to transfer ownership'} -

- )} - - + + {portalError ?? (error ? getErrorMessage(error, 'Failed to transfer ownership') : null)} + + + handleClose(false)} + cancelDisabled={isSubmitting} + primaryAction={{ + label: isSubmitting ? 'Transferring...' : 'Transfer & leave', + onClick: handleConfirm, + disabled: !selectedUserId || isSubmitting || isLoadingMembers, + }} + /> + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/member-rows.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/member-rows.ts new file mode 100644 index 00000000000..695503c6609 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/member-rows.ts @@ -0,0 +1,99 @@ +import type { RosterMember, RosterPendingInvitation } from '@/lib/api/contracts/organization' + +/** The four organization roles the roster can report. */ +export type OrganizationMemberRole = RosterMember['role'] + +/** Display label for every organization role, shared by the table and the modal. */ +export const ORGANIZATION_ROLE_LABELS: Record = { + owner: 'Owner', + admin: 'Admin', + member: 'Member', + external: 'External', +} + +interface OrganizationRosterRowBase { + /** + * Stable row identity. Invitation ids are namespaced so a member and an + * invitation can never collide in the table's selection set. + */ + id: string + name: string + email: string + image: string | null + role: OrganizationMemberRole + /** Join (member) or invite (invitation) date, in epoch milliseconds, for ordering. */ + createdAt: number + /** + * Whether the row may take part in its tab's bulk action — removal on the + * members tab, revocation on the invitations tab. + */ + selectable: boolean +} + +export interface OrganizationMemberRow extends OrganizationRosterRowBase { + kind: 'member' + member: RosterMember +} + +export interface OrganizationInvitationRow extends OrganizationRosterRowBase { + kind: 'invitation' + invitation: RosterPendingInvitation +} + +/** One row of the organization members table — an accepted member or a pending invitation. */ +export type OrganizationRosterRow = OrganizationMemberRow | OrganizationInvitationRow + +interface MemberRowContext { + /** Whether the viewer administers the organization. */ + canManage: boolean + currentUserId: string +} + +/** + * Projects a roster member onto a table row. `selectable` mirrors what the + * removal route accepts: the owner cannot be removed, and leaving is a + * deliberate single-member flow rather than something a bulk action performs. + */ +export function toOrganizationMemberRow( + member: RosterMember, + { canManage, currentUserId }: MemberRowContext +): OrganizationMemberRow { + return { + id: member.memberId, + kind: 'member', + name: member.name, + email: member.email, + image: member.image, + role: member.role, + createdAt: new Date(member.createdAt).getTime(), + selectable: canManage && member.role !== 'owner' && member.userId !== currentUserId, + member, + } +} + +/** + * Projects a pending organization invitation onto a table row. Every pending + * invitation is revocable by an admin — there is no owner-equivalent to protect + * here — so `selectable` follows the viewer's authority alone. + */ +export function toOrganizationInvitationRow( + invitation: RosterPendingInvitation, + { canManage }: { canManage: boolean } +): OrganizationInvitationRow { + return { + id: `invitation:${invitation.id}`, + kind: 'invitation', + name: invitation.inviteeName ?? invitation.email, + email: invitation.email, + image: invitation.inviteeImage, + role: + invitation.membershipIntent === 'external' + ? 'external' + : invitation.role === 'admin' + ? 'admin' + : 'member', + createdAt: new Date(invitation.createdAt).getTime(), + selectable: canManage, + invitation, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/search-params.ts new file mode 100644 index 00000000000..6eac0bbd4d6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/search-params.ts @@ -0,0 +1,48 @@ +import { parseAsStringLiteral } from 'nuqs/server' + +/** Tabs of the organization members table. `TABS` in the table labels this same list. */ +export const ORGANIZATION_MEMBER_TABS = ['members', 'invitations'] as const + +export type OrganizationMemberTab = (typeof ORGANIZATION_MEMBER_TABS)[number] + +/** Role filter values. `all` is the unfiltered default. */ +export const ORGANIZATION_ROLE_FILTERS = ['all', 'owner', 'admin', 'member', 'external'] as const + +export type OrganizationRoleFilter = (typeof ORGANIZATION_ROLE_FILTERS)[number] + +/** + * Row ordering. A single scalar rather than the shared `sort` + `dir` pair + * because column and direction are not independent here: the table offers one + * date ordering and one alphabetical ordering, so four values name every + * reachable state exactly, where a column/direction pair would also admit + * combinations the UI never offers. + * + * `az`/`za` sort by the identity the tab actually shows — a member's name, and a + * pending invitation's email, since an invitee who has not signed up has no name. + */ +export const ORGANIZATION_ROW_ORDERS = ['newest', 'oldest', 'az', 'za'] as const + +export type OrganizationRowOrder = (typeof ORGANIZATION_ROW_ORDERS)[number] + +/** + * Co-located, typed URL query-param definitions for the organization members + * table. + * + * - `tab` picks accepted members or pending organization invitations. + * - `role` filters by organization role. + * - `order` orders rows by join/invite date or alphabetically. + * - The name/email filter is the settings-wide `?search=` key, owned by + * `settingsSearchParam` and consumed through `useSettingsSearch` — it is + * deliberately not redeclared here (two definitions of one wire key drift). + */ +export const organizationMembersParsers = { + tab: parseAsStringLiteral(ORGANIZATION_MEMBER_TABS).withDefault('members'), + role: parseAsStringLiteral(ORGANIZATION_ROLE_FILTERS).withDefault('all'), + order: parseAsStringLiteral(ORGANIZATION_ROW_ORDERS).withDefault('newest'), +} as const + +/** Tab/filter/order view-state: clean URLs, no back-stack churn. */ +export const organizationMembersUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 53883488cdf..948a07ef576 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { Plus } from '@sim/emcn' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -11,12 +12,11 @@ import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-mod import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { NoOrganizationView, - OrganizationMemberLists, + OrganizationMembersTable, RemoveMemberDialog, TeamSeatsOverview, TransferOwnershipDialog, } from '@/app/workspace/[workspaceId]/settings/components/team-management/components' -import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useCreateOrganization, useMemberRemovalImpact, @@ -33,16 +33,13 @@ const logger = createLogger('TeamManagement') interface TeamManagementProps { organizationId: string - billingHref?: string + /** Where "View plans" goes. Always workspace-scoped — organization settings live there now. */ + billingHref: string } -export function TeamManagement({ - organizationId, - billingHref = `/organization/${organizationId}/settings/billing`, -}: TeamManagementProps) { +export function TeamManagement({ organizationId, billingHref }: TeamManagementProps) { const { data: session } = useSession() const { isInvitationsDisabled } = usePermissionConfig() - const [memberQuery, setMemberQuery] = useSettingsSearch() const { data: userSubscriptionData } = useSubscriptionData() const subscriptionAccess = getSubscriptionAccessState(userSubscriptionData?.data) @@ -97,8 +94,6 @@ export function TeamManagement({ const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 const usedSeats = organizationBillingData?.data?.members?.length ?? 0 - const reservedSeats = organizationBillingData?.data?.usedSeats ?? 0 - const pendingSeats = Math.max(0, reservedSeats - usedSeats) /** * The org's active subscription, derived from DB-backed organization billing @@ -263,17 +258,18 @@ export function TeamManagement({ portalWindow?.close() logger.error('Failed to open billing portal from transfer dialog', { error }) setTransferPortalError( - error instanceof Error - ? error.message - : 'Failed to open Stripe billing portal. Please try again.' + getErrorMessage(error, 'Failed to open Stripe billing portal. Please try again.') ) }, } ) }, [organizationId, openBillingPortal]) - const queryError = orgError - const errorMessage = queryError instanceof Error ? queryError.message : null + const errorMessage = orgError + ? getErrorMessage(orgError, 'Failed to load organization') + : createOrgMutation.error + ? getErrorMessage(createOrgMutation.error, 'Failed to create organization') + : null const displayOrganization = organization if (isLoading && !displayOrganization) { @@ -301,11 +297,6 @@ export function TeamManagement({ return ( <> )} - @@ -350,6 +339,7 @@ export function TeamManagement({ onOpenChange={setInviteModalOpen} organizationId={displayOrganization.id} canInvite={adminOrOwner} + isOrganizationAdmin={adminOrOwner} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 213d9d15a96..bcb201faf9a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -30,6 +30,7 @@ describe('unified settings navigation', () => { { id: 'billing', label: 'Subscription', section: 'account' }, { id: 'teammates', label: 'Teammates', section: 'workspace' }, { id: 'organization', label: 'Members', section: 'organization' }, + { id: 'workspaces', label: 'Workspaces', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, { id: 'mcp', label: 'MCP tools', section: 'workspace' }, @@ -78,6 +79,7 @@ describe('unified settings navigation', () => { 'recently-deleted', ]) expect(idsForSection('organization')).toEqual([ + 'workspaces', 'organization', 'custom-blocks', 'forks', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index 4ce77308595..a23f5bd49db 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { Button, ButtonGroup, @@ -14,11 +14,7 @@ import { ChipModalHeader, type ComboboxOption, Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + type TableColumn, toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' @@ -131,6 +127,11 @@ async function parseCsvPreview(file: File, fallbackDelimiter: CsvDelimiter) { return parseCsvBuffer(bytes, delimiter) } +/** A CSV header is unique within its file, so it is its own row identity. */ +function getCsvHeaderRowId(header: string): string { + return header +} + export function ImportCsvDialog({ open, onOpenChange, @@ -219,7 +220,7 @@ export function ImportCsvDialog({ if (file) void handleFileSelected(file) } - function handleMappingChange(header: string, value: string) { + const handleMappingChange = useCallback((header: string, value: string) => { setSubmitError(null) if (value === CREATE_VALUE) { setCreateHeaders((prev) => { @@ -240,7 +241,7 @@ export function ImportCsvDialog({ ...prev, [header]: value === SKIP_VALUE ? null : value, })) - } + }, []) function handleCreateAllUnmapped() { if (!parsed) return @@ -296,6 +297,56 @@ export function ImportCsvDialog({ } }, [mapping, parsed?.headers, table.schema.columns, createHeaders]) + /** First two non-empty sample values per header, shown under the header name. */ + const sampleByHeader = useMemo(() => { + const samples = new Map() + if (!parsed) return samples + for (const header of parsed.headers) { + samples.set( + header, + parsed.sampleRows + .map((row) => (row[header] === '' || row[header] == null ? '' : String(row[header]))) + .filter(Boolean) + .slice(0, 2) + .join(', ') + ) + } + return samples + }, [parsed]) + + const mappingColumns = useMemo[]>( + () => [ + { + key: 'csv', + header: 'CSV column', + cell: (header) => { + const sample = sampleByHeader.get(header) + return ( +
+ {header} + {sample && ( + {sample} + )} +
+ ) + }, + }, + { + key: 'target', + header: 'Target column', + cell: (header) => ( + handleMappingChange(header, value)} + className='w-full' + /> + ), + }, + ], + [sampleByHeader, columnOptions, createHeaders, mapping, handleMappingChange] + ) + const canSubmit = parsed !== null && !importMutation.isPending && @@ -414,57 +465,13 @@ export function ImportCsvDialog({ )} -
-
-
- - - CSV column - Target column - - - - {parsed.headers.map((header) => { - const sample = parsed.sampleRows - .map((r) => - r[header] === '' || r[header] == null ? '' : String(r[header]) - ) - .filter(Boolean) - .slice(0, 2) - .join(', ') - return ( - - -
- - {header} - - {sample && ( - - {sample} - - )} -
-
- - handleMappingChange(header, value)} - className='w-full' - /> - -
- ) - })} -
-
-
-
+ {mappedCount} mapped {createCount > 0 diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 51721d89430..964989d3b6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -12,7 +12,7 @@ import { ChevronLeft } from '@sim/emcn/icons' import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' import type { DesktopSettingsSurface } from '@/components/settings/navigation' -import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation' +import { ORGANIZATION_SCOPED_UNIFIED_SECTIONS } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' @@ -143,12 +143,12 @@ export function SettingsSidebar({ if (item.selfHostedOverride && !isHosted) { /** - * Org-plane sections route through the organization gate in + * Organization-scoped sections route through the organization gate in * `settings/[section]/page.tsx` (host organization + org-admin viewer), * which 404s other viewers — mirror it here so the item never links to * a dead page. */ - if (ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id) && !isOrgAdminOrOwner) { + if (ORGANIZATION_SCOPED_UNIFIED_SECTIONS.has(item.id) && !isOrgAdminOrOwner) { return false } if (item.id === 'sso') { diff --git a/apps/sim/components/emails/render-notifications.test.ts b/apps/sim/components/emails/render-notifications.test.ts index b33ef4d8757..e175f9e622a 100644 --- a/apps/sim/components/emails/render-notifications.test.ts +++ b/apps/sim/components/emails/render-notifications.test.ts @@ -72,7 +72,7 @@ describe('renderUsageLimitReachedEmail', () => { scope: 'organization', currentUsage: 500, limit: 500, - ctaLink: 'https://sim.ai/organization/org_1/settings/billing', + ctaLink: 'https://sim.ai/workspace/ws_1/settings/billing', }) expect(html).toContain('Raise Organization Limit') diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index d23e5bab48e..a8840279e3a 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -11,17 +11,15 @@ import { buildUnifiedSettingsNavigation, canMutateWorkspaceSettingsSection, getAccountSettingsHref, - getOrganizationSettingsHref, getWorkspaceSettingsHref, isOrganizationSettingsSectionAvailable, - ORGANIZATION_PLANE_UNIFIED_SECTIONS, - ORGANIZATION_SETTINGS_ITEMS, - ORGANIZATION_SETTINGS_PATH_ALIASES, + ORGANIZATION_SCOPED_UNIFIED_SECTIONS, parseSettingsPathSection, resolveOrganizationSectionAccess, resolveWorkspaceNavigation, SELFHOST_SETTINGS_ITEMS, SETTINGS_SECTION_REGISTRY, + type UnifiedSettingsSection, WORKSPACE_SETTINGS_ITEMS, WORKSPACE_SETTINGS_PATH_ALIASES, } from '@/components/settings/navigation' @@ -37,7 +35,7 @@ afterAll(() => { }) describe('settings navigation boundaries', () => { - it('preserves the order of all four settings catalogs', () => { + it('preserves the order of every settings catalog', () => { expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toEqual([ 'general', 'desktop', @@ -49,6 +47,7 @@ describe('settings navigation boundaries', () => { 'billing', 'teammates', 'organization', + 'workspaces', 'secrets', 'custom-tools', 'mcp', @@ -76,17 +75,6 @@ describe('settings navigation boundaries', () => { 'mothership', ]) expect(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id)).toEqual(['general', 'billing', 'chat-keys']) - expect(ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([ - 'members', - 'billing', - 'access-control', - 'audit-logs', - 'sso', - 'sessions', - 'data-retention', - 'data-drains', - 'whitelabeling', - ]) expect(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([ 'teammates', 'secrets', @@ -188,9 +176,6 @@ describe('settings navigation boundaries', () => { const accountIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => planes?.account ? [planes.account.id] : [] ) - const organizationIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => - planes?.organization ? [planes.organization.id] : [] - ) const selfHostIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) => planes?.selfhost ? [planes.selfhost.id] : [] ) @@ -200,7 +185,6 @@ describe('settings navigation boundaries', () => { expect(new Set(unifiedIds).size).toBe(unifiedIds.length) expect(new Set(accountIds).size).toBe(accountIds.length) - expect(new Set(organizationIds).size).toBe(organizationIds.length) expect(new Set(selfHostIds).size).toBe(selfHostIds.length) expect(new Set(workspaceIds).size).toBe(workspaceIds.length) expect([...unifiedIds].sort()).toEqual( @@ -209,15 +193,17 @@ describe('settings navigation boundaries', () => { .sort() ) expect([...accountIds].sort()).toEqual(ACCOUNT_SETTINGS_ITEMS.map(({ id }) => id).sort()) - expect([...organizationIds].sort()).toEqual( - ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id).sort() - ) expect([...selfHostIds].sort()).toEqual(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id).sort()) expect([...workspaceIds].sort()).toEqual(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).sort()) }) - it('derives the organization-plane unified sections from the registry', () => { - expect([...ORGANIZATION_PLANE_UNIFIED_SECTIONS].sort()).toEqual([ + /** + * The route gate and this set read one map, so a section cannot be hidden in + * the sidebar while its page stays reachable — which is exactly how `sessions` + * shipped an editable policy form to any organization member with the URL. + */ + it('names every organization-scoped unified section', () => { + expect([...ORGANIZATION_SCOPED_UNIFIED_SECTIONS].sort()).toEqual([ 'access-control', 'audit-logs', 'billing', @@ -227,27 +213,63 @@ describe('settings navigation boundaries', () => { 'sessions', 'sso', 'whitelabeling', + 'workspaces', ]) }) - it('shares labels, icons, and docs links across projections', () => { - const unifiedSso = buildUnifiedSettingsNavigation().find(({ id }) => id === 'sso') - const organizationSso = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'sso') - - expect(organizationSso?.label).toBe(unifiedSso?.label) - expect(organizationSso?.icon).toBe(unifiedSso?.icon) - expect(organizationSso?.docsLink).toBe(unifiedSso?.docsLink) + /** + * The workspace-plane sidebar sorts each group by `order`, so this pins the + * Organization group's top-down reading order: the two views built on the + * roster lead, then the roster itself, then the enterprise controls. + */ + it('orders the unified Organization group top-down', () => { + expect( + buildUnifiedSettingsNavigation() + .filter(({ section }) => section === 'organization') + .sort((left, right) => left.order - right.order) + .map(({ id }) => id) + ).toEqual([ + 'workspaces', + 'organization', + 'custom-blocks', + 'forks', + 'access-control', + 'audit-logs', + 'whitelabeling', + 'sso', + 'sessions', + 'data-retention', + 'data-drains', + ]) }) - it('uses scope-specific labels consistently across settings surfaces', () => { - const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members') - const unifiedOrganization = buildUnifiedSettingsNavigation().find( - ({ id }) => id === 'organization' - ) + /** + * On the workspace plane both sections route through the organization gate in + * `settings/[section]/page.tsx` (host organization present + org-admin + * viewer). `requiresTeam` is the only flag that ANDs `isOrgAdminOrOwner` into + * the sidebar filter, so dropping it would list Workspaces for viewers the + * route 404s. + */ + it('gates the organization roster views exactly as it gates Members', () => { + const unified = buildUnifiedSettingsNavigation() + const gateFor = (id: UnifiedSettingsSection) => { + const item = unified.find((candidate) => candidate.id === id) + return { + section: item?.section, + hideWhenBillingDisabled: item?.hideWhenBillingDisabled, + requiresHosted: item?.requiresHosted, + requiresTeam: item?.requiresTeam, + } + } + const organizationRosterGate = { + section: 'organization', + hideWhenBillingDisabled: true, + requiresHosted: true, + requiresTeam: true, + } - expect(organizationMembers?.label).toBe('Members') - expect(organizationMembers?.description).toBe('Manage organization members, roles, and seats.') - expect(unifiedOrganization?.label).toBe('Members') + expect(gateFor('organization')).toEqual(organizationRosterGate) + expect(gateFor('workspaces')).toEqual(organizationRosterGate) }) it('keeps self-host settings on their standalone account projection', () => { @@ -280,11 +302,8 @@ describe('settings navigation boundaries', () => { ]) }) - it('builds canonical settings hrefs across all three planes', () => { + it('builds canonical settings hrefs across every plane', () => { expect(getAccountSettingsHref('general')).toBe('/account/settings/general') - expect(getOrganizationSettingsHref('organization-a', 'members')).toBe( - '/organization/organization-a/settings/members' - ) expect(getWorkspaceSettingsHref('workspace-a', 'teammates')).toBe( '/workspace/workspace-a/settings/teammates' ) @@ -317,20 +336,6 @@ describe('settings navigation boundaries', () => { expect(parseAccountPath('/account/settings', 'general')).toBe('general') }) - it('parses canonical, aliased, and invalid organization settings paths', () => { - const parseOrganizationPath = (path: string) => - parseSettingsPathSection({ - path, - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: null, - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - - expect(parseOrganizationPath('sso')).toBe('sso') - expect(parseOrganizationPath('/organization/org-a/settings/organization')).toBe('members') - expect(parseOrganizationPath('/organization/org-a/settings/not-a-section')).toBeNull() - }) - it('parses canonical, aliased, and invalid workspace settings paths', () => { const parseWorkspacePath = (path: string) => parseSettingsPathSection({ @@ -348,7 +353,6 @@ describe('settings navigation boundaries', () => { it('keeps API keys split between account and workspace settings', () => { expect(ACCOUNT_SETTINGS_ITEMS.some(({ id }) => id === 'api-keys')).toBe(true) expect(WORKSPACE_SETTINGS_ITEMS.some(({ id }) => id === 'api-keys')).toBe(true) - expect(ORGANIZATION_SETTINGS_ITEMS.some(({ id }) => String(id) === 'api-keys')).toBe(false) }) it('requires target-organization membership and admin authority', () => { diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index cb8e23ec1a6..c59f3549f31 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -8,6 +8,7 @@ import { HexSimple, Key, KeySquare, + Layout, Lock, LogIn, Palette, @@ -40,7 +41,7 @@ import { isWhitelabelingEnabled, } from '@/lib/core/config/env-flags' -export type SettingsPlane = 'account' | 'organization' | 'selfhost' | 'workspace' +export type SettingsPlane = 'account' | 'selfhost' | 'workspace' export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' @@ -50,8 +51,15 @@ export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin */ export type SelfHostSettingsSection = 'general' | 'billing' | 'chat-keys' +/** + * The organization-scoped settings sections. There is no organization plane — + * every one of these is reached at `/workspace/[id]/settings/...` — but they are + * authorized against the host ORGANIZATION rather than the workspace, so they + * keep their own union as the gate's vocabulary. + */ export type OrganizationSettingsSection = | 'members' + | 'workspaces' | 'billing' | 'access-control' | 'audit-logs' @@ -82,8 +90,6 @@ export type SettingsSection = | SelfHostSettingsSection | WorkspaceSettingsSection -export type OrganizationSettingsRouteSection = OrganizationSettingsSection | 'unavailable' - export interface SettingsNavigationItem
{ id: Section label: string @@ -107,6 +113,7 @@ export type UnifiedSettingsSection = | 'billing' | 'teammates' | 'organization' + | 'workspaces' | 'sso' | 'whitelabeling' | 'forks' @@ -168,7 +175,6 @@ interface UnifiedSettingsProjection interface SettingsPlaneSectionMap { account: AccountSettingsSection - organization: OrganizationSettingsSection selfhost: SelfHostSettingsSection workspace: WorkspaceSettingsSection } @@ -246,17 +252,6 @@ export function getSelfHostSettingsHref( return withSettingsSearchParams(`/selfhost/settings/${section}`, searchParams) } -export function getOrganizationSettingsHref( - organizationId: string, - section: OrganizationSettingsRouteSection, - searchParams?: SettingsHrefSearchParams -): string { - return withSettingsSearchParams( - `/organization/${organizationId}/settings/${section}`, - searchParams - ) -} - export function getWorkspaceSettingsHref( workspaceId: string, section: WorkspaceSettingsSection, @@ -269,12 +264,6 @@ export const ACCOUNT_SETTINGS_PATH_ALIASES = { apikeys: 'api-keys', } as const satisfies Readonly> -export const ORGANIZATION_SETTINGS_PATH_ALIASES = { - organization: 'members', - // Verified domains moved into the SSO page; keep old links working. - domains: 'sso', -} as const satisfies Readonly> - export const WORKSPACE_SETTINGS_PATH_ALIASES = { apikeys: 'api-keys', } as const satisfies Readonly> @@ -338,7 +327,6 @@ export const SETTINGS_PLANE_CHROME: Record< { label: string; showWordmark: boolean } > = { account: { label: 'Account', showWordmark: false }, - organization: { label: 'Organization', showWordmark: false }, selfhost: { label: 'Self-host', showWordmark: true }, } @@ -347,12 +335,6 @@ export const SELFHOST_SETTINGS_GROUPS = [ { key: 'developer', title: 'Developer' }, ] as const -export const ORGANIZATION_SETTINGS_GROUPS = [ - { key: 'organization', title: 'Organization' }, - { key: 'security', title: 'Security' }, - { key: 'enterprise', title: 'Enterprise' }, -] as const - export const WORKSPACE_SETTINGS_GROUPS = [ { key: 'workspace', title: 'Workspace' }, { key: 'tools', title: 'Tools' }, @@ -416,14 +398,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'access-control', description: 'Manage permission groups across your organization.', group: 'organization', - order: 3, + order: 5, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, }, - planes: { - organization: { id: 'access-control', group: 'security', order: 2 }, - }, + planes: {}, }, { label: 'Audit logs', @@ -433,14 +413,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'audit-logs', description: 'Review activity and changes across your organization.', group: 'organization', - order: 4, + order: 6, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, }, - planes: { - organization: { id: 'audit-logs', group: 'security', order: 3 }, - }, + planes: {}, }, { label: 'Workspace forks', @@ -450,7 +428,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'forks', description: 'Fork this workspace and sync changes with its parent.', group: 'organization', - order: 2, + order: 4, }, planes: { workspace: { id: 'forks', group: 'enterprise', order: 10 }, @@ -479,12 +457,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'account', order: 1, }, - organization: { - id: 'billing', - description: 'Manage the organization plan, usage, and invoices.', - group: 'organization', - order: 1, - }, }, }, { @@ -507,19 +479,37 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'organization', description: "Manage your organization's members and seats.", group: 'organization', - order: 0, + order: 2, hideWhenBillingDisabled: true, requiresHosted: true, requiresTeam: true, }, - planes: { - organization: { - id: 'members', - description: 'Manage organization members, roles, and seats.', - group: 'organization', - order: 0, - }, + planes: {}, + }, + { + label: 'Workspaces', + icon: Layout, + /** + * This section routes through the organization gate in + * `settings/[section]/page.tsx` — host organization present, org-admin + * viewer — so the sidebar must never offer it more widely than the route + * serves it. `requiresTeam` is the only flag that ANDs `isOrgAdminOrOwner` + * into the sidebar filter, so it is what stops a non-admin seeing a link + * that 404s; `requiresHosted` keeps it to deployments that have + * organizations at all; and `hideWhenBillingDisabled` keeps it co-visible + * with Members — the roster this view groups by workspace. All three land + * strictly inside what the route will serve. + */ + unified: { + id: 'workspaces', + description: 'Browse every workspace and the members in each.', + group: 'organization', + order: 0, + hideWhenBillingDisabled: true, + requiresHosted: true, + requiresTeam: true, }, + planes: {}, }, { label: 'Secrets', @@ -692,14 +682,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'sso', description: 'Configure single sign-on for your organization.', group: 'organization', - order: 6, + order: 8, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, }, - planes: { - organization: { id: 'sso', group: 'security', order: 4 }, - }, + planes: {}, }, { label: 'Session policies', @@ -709,14 +697,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'sessions', description: 'Limit session lifetimes and sign out members org-wide.', group: 'organization', - order: 7, + order: 9, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, }, - planes: { - organization: { id: 'sessions', group: 'security', order: 5 }, - }, + planes: {}, }, { label: 'Data retention', @@ -727,14 +713,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] description: 'Control data retention windows and PII redaction. Workspaces without an override inherit the organization defaults.', group: 'organization', - order: 8, + order: 10, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, }, - planes: { - organization: { id: 'data-retention', group: 'enterprise', order: 6 }, - }, + planes: {}, }, { label: 'Data drains', @@ -744,14 +728,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'data-drains', description: 'Stream your logs and events to external destinations.', group: 'organization', - order: 9, + order: 11, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, }, - planes: { - organization: { id: 'data-drains', group: 'enterprise', order: 7 }, - }, + planes: {}, }, { label: 'White-labeling', @@ -761,14 +743,12 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'whitelabeling', description: 'Customize your workspace branding and appearance.', group: 'organization', - order: 5, + order: 7, requiresHosted: true, requiresEnterprise: true, selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, }, - planes: { - organization: { id: 'whitelabeling', group: 'enterprise', order: 8 }, - }, + planes: {}, }, { label: 'Custom blocks', @@ -778,7 +758,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'custom-blocks', description: 'Publish workflows as reusable blocks for your organization.', group: 'organization', - order: 1, + order: 3, requiresHosted: true, requiresEnterprise: true, allowNonOrgAdmin: true, @@ -866,9 +846,6 @@ function buildPlaneSettingsItems( export const ACCOUNT_SETTINGS_ITEMS: SettingsNavigationItem[] = buildPlaneSettingsItems('account') -export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem[] = - buildPlaneSettingsItems('organization') - export const SELFHOST_SETTINGS_ITEMS: SettingsNavigationItem[] = buildPlaneSettingsItems('selfhost') @@ -876,15 +853,31 @@ export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem = new Set( - SETTINGS_SECTION_REGISTRY.flatMap((entry) => - entry.planes?.organization && entry.unified ? [entry.unified.id] : [] - ) +export const ORGANIZATION_SCOPED_SECTIONS = { + organization: 'members', + workspaces: 'workspaces', + billing: 'billing', + 'access-control': 'access-control', + 'audit-logs': 'audit-logs', + sso: 'sso', + sessions: 'sessions', + 'data-retention': 'data-retention', + 'data-drains': 'data-drains', + whitelabeling: 'whitelabeling', +} as const satisfies Partial> + +export const ORGANIZATION_SCOPED_UNIFIED_SECTIONS: ReadonlySet = new Set( + Object.keys(ORGANIZATION_SCOPED_SECTIONS) as UnifiedSettingsSection[] ) export type OrganizationSectionAccess = 'unavailable' | 'view' | 'manage' @@ -931,6 +924,18 @@ export function getOrganizationSettingsFeatures( } } +/** + * Sections every organization gets, on any plan and any deployment: the roster + * and the two views built directly on it. They are ordinary organization + * features rather than enterprise add-ons, so they are listed here instead of + * falling through to the plan/flag gate — which would hide them from every + * non-enterprise organization and leave no self-hosted flag to turn them on. + */ +const ALWAYS_AVAILABLE_ORGANIZATION_SECTIONS: ReadonlySet = new Set([ + 'members', + 'workspaces', +]) + /** * Applies deployment and target-organization plan gates without consulting the * viewer's active organization. @@ -939,7 +944,7 @@ export function isOrganizationSettingsSectionAvailable( section: OrganizationSettingsSection, features: OrganizationSettingsFeatures ): boolean { - if (section === 'members') return true + if (ALWAYS_AVAILABLE_ORGANIZATION_SECTIONS.has(section)) return true if (section === 'billing') return features.billingEnabled if (features.hosted) return features.hasEnterprisePlan return features.selfHosted[section] ?? false @@ -1053,11 +1058,9 @@ export function getSettingsSectionMeta( const catalog = plane === 'account' ? ACCOUNT_SETTINGS_ITEMS - : plane === 'organization' - ? ORGANIZATION_SETTINGS_ITEMS - : plane === 'selfhost' - ? SELFHOST_SETTINGS_ITEMS - : WORKSPACE_SETTINGS_ITEMS + : plane === 'selfhost' + ? SELFHOST_SETTINGS_ITEMS + : WORKSPACE_SETTINGS_ITEMS const item = catalog.find((candidate) => candidate.id === section) return item ? { label: item.label, description: item.description, docsLink: item.docsLink } : null } diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx deleted file mode 100644 index 66ae7a1efc3..00000000000 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import dynamic from 'next/dynamic' -import { usePostHog } from 'posthog-js/react' -import type { OrganizationSettingsSection } from '@/components/settings/navigation' -import { captureEvent } from '@/lib/posthog/client' - -const TeamManagement = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then( - (module) => module.TeamManagement - ) -) -const Billing = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( - (module) => module.Billing - ) -) -const AccessControl = dynamic(() => - import('@/ee/access-control/components/access-control').then((module) => module.AccessControl) -) -const AuditLogs = dynamic(() => - import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) -) -const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) -const SessionPolicySettings = dynamic(() => - import('@/ee/session-policy/components/session-policy-settings').then( - (module) => module.SessionPolicySettings - ) -) -const DataRetentionSettings = dynamic(() => - import('@/ee/data-retention/components/data-retention-settings').then( - (module) => module.DataRetentionSettings - ) -) -const DataDrainsSettings = dynamic(() => - import('@/ee/data-drains/components/data-drains-settings').then( - (module) => module.DataDrainsSettings - ) -) -const WhitelabelingSettings = dynamic( - () => - import('@/ee/whitelabeling/components/whitelabeling-settings').then( - (module) => module.WhitelabelingSettings - ), - { ssr: false } -) - -interface OrganizationSettingsRendererProps { - organizationId: string - section: OrganizationSettingsSection -} - -export function OrganizationSettingsRenderer({ - organizationId, - section, -}: OrganizationSettingsRendererProps) { - const posthog = usePostHog() - - useEffect(() => { - captureEvent(posthog, 'settings_tab_viewed', { plane: 'organization', section }) - }, [posthog, section]) - - if (section === 'members') return - if (section === 'billing') return - if (section === 'access-control') { - return - } - if (section === 'audit-logs') return - if (section === 'sso') return - if (section === 'sessions') { - return - } - if (section === 'data-retention') { - return - } - if (section === 'data-drains') return - return -} diff --git a/apps/sim/components/settings/standalone-settings-shell.test.ts b/apps/sim/components/settings/standalone-settings-shell.test.ts index 720bcdfff56..49d462e8f76 100644 --- a/apps/sim/components/settings/standalone-settings-shell.test.ts +++ b/apps/sim/components/settings/standalone-settings-shell.test.ts @@ -5,8 +5,6 @@ import { describe, expect, it } from 'vitest' import { ACCOUNT_SETTINGS_ITEMS, ACCOUNT_SETTINGS_PATH_ALIASES, - ORGANIZATION_SETTINGS_ITEMS, - ORGANIZATION_SETTINGS_PATH_ALIASES, parseSettingsPathSection, SELFHOST_SETTINGS_ITEMS, } from '@/components/settings/navigation' @@ -23,17 +21,6 @@ describe('standalone settings section resolution', () => { ).toBe('billing') }) - it('resolves the organization section from its pathname', () => { - expect( - parseSettingsPathSection({ - path: '/organization/org-1/settings/audit-logs', - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: 'members', - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - ).toBe('audit-logs') - }) - it('keeps Subscription active for the self-host billing route', () => { expect( parseSettingsPathSection({ diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 0ef8997c7b2..51a7db6fbda 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -8,15 +8,8 @@ import { ACCOUNT_SETTINGS_ITEMS, ACCOUNT_SETTINGS_PATH_ALIASES, getAccountSettingsHref, - getOrganizationSettingsFeatures, - getOrganizationSettingsHref, getSelfHostSettingsHref, - isOrganizationSettingsSectionAvailable, - ORGANIZATION_SETTINGS_GROUPS, - ORGANIZATION_SETTINGS_ITEMS, - ORGANIZATION_SETTINGS_PATH_ALIASES, parseSettingsPathSection, - resolveOrganizationSectionAccess, SELFHOST_SETTINGS_GROUPS, SELFHOST_SETTINGS_ITEMS, SETTINGS_PLANE_CHROME, @@ -41,40 +34,19 @@ interface SelfHostSettingsShellProps extends StandaloneSettingsShellBaseProps { plane: 'selfhost' } -interface OrganizationSettingsShellProps extends StandaloneSettingsShellBaseProps { - plane: 'organization' - organizationId: string - hasEnterprisePlan: boolean - isOrganizationAdmin: boolean -} - -type StandaloneSettingsShellProps = - | AccountSettingsShellProps - | OrganizationSettingsShellProps - | SelfHostSettingsShellProps +type StandaloneSettingsShellProps = AccountSettingsShellProps | SelfHostSettingsShellProps export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const { children, plane } = props useSettingsBeforeUnload() const pathname = usePathname() - const hasEnterprisePlan = plane === 'organization' ? props.hasEnterprisePlan : false - const isOrganizationAdmin = plane === 'organization' ? props.isOrganizationAdmin : false const isSuperUser = plane === 'account' ? (props.isSuperUser ?? false) : false - const organizationFeatures = getOrganizationSettingsFeatures(hasEnterprisePlan) const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => { if (item.id === 'billing' && !isBillingEnabled) return false if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false return true }) - const organizationItems = ORGANIZATION_SETTINGS_ITEMS.filter( - (item) => - resolveOrganizationSectionAccess({ - section: item.id, - isTargetOrganizationMember: true, - isTargetOrganizationAdmin: isOrganizationAdmin, - }) !== 'unavailable' && isOrganizationSettingsSectionAvailable(item.id, organizationFeatures) - ) const selfHostItems = SELFHOST_SETTINGS_ITEMS.filter((item) => { if (item.id === 'billing' && !isBillingEnabled) return false // Chat keys are issued by the managed service, so there are none to list on @@ -94,28 +66,9 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { defaultSection: 'general', aliases: ACCOUNT_SETTINGS_PATH_ALIASES, }) - const organizationSection = parseSettingsPathSection({ - path: pathname, - items: ORGANIZATION_SETTINGS_ITEMS, - defaultSection: 'members', - aliases: ORGANIZATION_SETTINGS_PATH_ALIASES, - }) - const activeSection = - plane === 'account' - ? accountSection - : plane === 'selfhost' - ? selfHostSection - : organizationSection + const activeSection = plane === 'account' ? accountSection : selfHostSection const sidebar = - plane === 'selfhost' ? ( - - ) : plane === 'account' ? ( + plane === 'account' ? ( ) : ( getOrganizationSettingsHref(props.organizationId, section)} - items={organizationItems} + groups={SELFHOST_SETTINGS_GROUPS} + hrefForSection={getSelfHostSettingsHref} + items={selfHostItems} /> ) diff --git a/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx index d0d5d7f4ceb..5976b6adc1a 100644 --- a/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx @@ -5,7 +5,7 @@ import { Badge, Button, Tooltip } from '@sim/emcn' import { createLogger } from '@sim/logger' import { formatDateTime } from '@sim/utils/formatting' import { truncate } from '@sim/utils/string' -import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork' +import type { BackgroundWorkItem, ForkActivityFilter } from '@/lib/api/contracts/workspace-fork' import { ActivityLog, type ActivityLogEntry, @@ -28,7 +28,7 @@ function countList(pairs: Array<[number | undefined, string]>): string { return pairs .filter(([n]) => (n ?? 0) > 0) .map(([n, verb]) => `${n} ${verb}`) - .join(' · ') + .join(', ') } /** A named group (one resource kind or change action) of a job's report. */ @@ -226,7 +226,7 @@ function jobReport(job: BackgroundWorkItem): JobReport { ] .filter(([n]) => ((n as number | undefined) ?? 0) > 0) .map(([n, noun]) => plural(n as number, noun as string)) - .join(' · ') + .join(', ') if (counts) notes.push({ value: counts }) } if (m.failed && m.failed > 0) { @@ -297,6 +297,8 @@ interface ForkActivityPanelProps { workspaceId: string /** Lineage partner names by id (the parent + forks), for phrasing partner-recorded rows. */ workspaceNames: ReadonlyMap + /** Narrows the feed to one kind of fork event; `all` leaves it unfiltered. */ + eventFilter?: ForkActivityFilter } /** @@ -305,9 +307,13 @@ interface ForkActivityPanelProps { * so it reads identically to the enterprise audit log: each row (timestamp, action * badge, description, actor) expands to a per-kind breakdown of what changed. */ -export function ForkActivityPanel({ workspaceId, workspaceNames }: ForkActivityPanelProps) { +export function ForkActivityPanel({ + workspaceId, + workspaceNames, + eventFilter = 'all', +}: ForkActivityPanelProps) { const { data, isPending, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = - useWorkspaceBackgroundWork(workspaceId) + useWorkspaceBackgroundWork(workspaceId, eventFilter) const view: ActivityView = { workspaceId, workspaceNames } const jobs = useMemo(() => { diff --git a/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx b/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx index 8ced29f7330..52115923e0d 100644 --- a/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows.tsx @@ -90,32 +90,46 @@ export function buildExcludedWorkflowTree( } interface ForkExcludedWorkflowsProps { + /** The workspace whose exclusion list is being edited — any the viewer administers. */ workspaceId: string + /** Narrows the tree by workflow name; folders with no surviving workflow are pruned. */ + searchTerm?: string } /** - * The Forks page's "Excluded workflows" section body: the workspace's deployed - * workflows in their sidebar folder structure, each with a checkbox. Checked = - * excluded - the workflow never syncs to or from a fork and is not copied into - * new forks. A folder's checkbox toggles its whole subtree at once (tri-state - * while partially excluded). Toggles apply immediately. + * A workspace's exclusion list: its deployed workflows in the sidebar's folder structure, each + * with a checkbox. Checked means excluded — the workflow never syncs in either direction and is + * never copied into a new fork. A folder's checkbox toggles its whole subtree at once, showing + * tri-state while partially excluded, and every toggle applies immediately. */ -export function ForkExcludedWorkflows({ workspaceId }: ForkExcludedWorkflowsProps) { +export function ForkExcludedWorkflows({ + workspaceId, + searchTerm = '', +}: ForkExcludedWorkflowsProps) { const workflowsQuery = useWorkflows(workspaceId) const foldersQuery = useFolders(workspaceId) const updateExcluded = useUpdateForkExcludedWorkflows() const workflows = workflowsQuery.data const folders = foldersQuery.data + const query = searchTerm.trim().toLowerCase() const excludedIds = useMemo( () => new Set((workflows ?? []).filter((workflow) => workflow.forkSyncExcluded).map((w) => w.id)), [workflows] ) + // Filtering the workflows is enough to filter the tree: the builder already prunes any branch + // left with no deployed workflow beneath it. const tree = useMemo( - () => buildExcludedWorkflowTree(workflows ?? [], folders ?? []), - [workflows, folders] + () => + buildExcludedWorkflowTree( + query + ? (workflows ?? []).filter((workflow) => workflow.name.toLowerCase().includes(query)) + : (workflows ?? []), + folders ?? [] + ), + [workflows, folders, query] ) const toggle = (workflowIds: string[], excluded: boolean) => { @@ -131,12 +145,16 @@ export function ForkExcludedWorkflows({ workspaceId }: ForkExcludedWorkflowsProp ) } - if (workflowsQuery.isLoading || foldersQuery.isLoading) return null + if (workflowsQuery.isLoading || foldersQuery.isLoading) { + return Loading workflows... + } if (tree.folders.length === 0 && tree.rootWorkflows.length === 0) { return ( - No deployed workflows — only deployed workflows sync + {query + ? `No deployed workflows matching “${searchTerm.trim()}”` + : 'No deployed workflows. Only deployed workflows ever sync.'} ) } diff --git a/apps/sim/ee/workspace-forking/components/fork-kind-label.ts b/apps/sim/ee/workspace-forking/components/fork-kind-label.ts new file mode 100644 index 00000000000..9caf819f2ce --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-kind-label.ts @@ -0,0 +1,29 @@ +/** + * Display names per remappable resource kind, in the two registers the console needs. + * + * `label` stands on its own — a table cell, a badge, a filter option. `phrase` reads inside a + * sentence ("map it to an existing knowledge base"). They are declared together rather than + * derived from one another because the difference is not mechanical: lowercasing "MCP server" + * would be wrong, and title-casing "knowledge base" for a mid-sentence use would be too. + */ +const FORK_KIND_NAMES: Record = { + credential: { label: 'Credential', phrase: 'credential' }, + 'env-var': { label: 'Secret', phrase: 'secret' }, + table: { label: 'Table', phrase: 'table' }, + 'knowledge-base': { label: 'Knowledge base', phrase: 'knowledge base' }, + 'knowledge-document': { label: 'Document', phrase: 'document' }, + file: { label: 'File', phrase: 'file' }, + 'mcp-server': { label: 'MCP server', phrase: 'MCP server' }, + 'custom-tool': { label: 'Custom tool', phrase: 'custom tool' }, + skill: { label: 'Skill', phrase: 'skill' }, +} + +/** Standalone name for a kind, falling back to the raw kind so a new one is still legible. */ +export function forkKindLabel(kind: string): string { + return FORK_KIND_NAMES[kind]?.label ?? kind +} + +/** Mid-sentence name for a kind, falling back to the generic noun. */ +export function forkKindPhrase(kind: string): string { + return FORK_KIND_NAMES[kind]?.phrase ?? 'resource' +} diff --git a/apps/sim/ee/workspace-forking/components/fork-lineage/bulk-actions.ts b/apps/sim/ee/workspace-forking/components/fork-lineage/bulk-actions.ts new file mode 100644 index 00000000000..9f87d3e4ed2 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-lineage/bulk-actions.ts @@ -0,0 +1,22 @@ +import type { BulkActionCopy } from '@/app/workspace/[workspaceId]/settings/components/bulk-action' + +const forkCount = (rows: number) => `${rows} ${rows === 1 ? 'fork' : 'forks'}` + +/** + * Severing several fork edges at once. + * + * Worded as disconnection rather than deletion because nothing is deleted: both workspaces and + * everything in them survive. What ends is the relationship, and with it the saved mappings and + * sync history that only existed to serve it. + */ +export const DISCONNECT_FORKS_COPY: BulkActionCopy = { + title: 'Disconnect forks', + triggerLabel: 'Actions for selected forks', + pendingLabel: 'Disconnecting...', + count: forkCount, + lead: 'Disconnect ', + consequence: + ' from the workspaces they were forked from? Both sides stay exactly as they are, but they stop appearing in each other’s lineage and syncing between them ends. The saved mappings and sync history for each pair are deleted, and this cannot be undone.', + succeeded: (rows) => `Disconnected ${forkCount(rows)}`, + failed: (failures, rows) => `Couldn't disconnect ${failures} of ${forkCount(rows)}`, +} diff --git a/apps/sim/ee/workspace-forking/components/fork-lineage/fork-lineage.tsx b/apps/sim/ee/workspace-forking/components/fork-lineage/fork-lineage.tsx new file mode 100644 index 00000000000..d63bd0d7d3c --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-lineage/fork-lineage.tsx @@ -0,0 +1,344 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Badge, Chip, ChipConfirmModal, TableIdentityCell, toast } from '@sim/emcn' +import { TriangleAlert } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { formatRelativeTime } from '@sim/utils/formatting' +import { useRouter } from 'next/navigation' +import type { ForkForestNode, ForkUndoableRun } from '@/lib/api/contracts/workspace-fork' +import { + BulkActionDialog, + BulkActionMenu, + useBulkAction, +} from '@/app/workspace/[workspaceId]/settings/components/bulk-action' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { DISCONNECT_FORKS_COPY } from '@/ee/workspace-forking/components/fork-lineage/bulk-actions' +import { + buildForkLineageRows, + type ForkLineageRow, +} from '@/ee/workspace-forking/components/fork-lineage/lineage-rows' +import { ForkTable, type ForkTableColumn } from '@/ee/workspace-forking/components/fork-table' +import { useRollbackFork, useUnlinkFork } from '@/ee/workspace-forking/hooks/workspace-fork' + +/** Explains a disabled action whose target workspace the viewer cannot open. */ +const NO_ACCESS_TOOLTIP = "You don't have access to this workspace" + +/** Wide enough for "12 unmapped" without the column reflowing as counts change. */ +const MAPPINGS_COLUMN_WIDTH = 140 +const WORKFLOWS_COLUMN_WIDTH = 120 +const LAST_SYNC_COLUMN_WIDTH = 120 +const ACTIONS_COLUMN_WIDTH = 120 + +/** The mapping badge for a node's parent edge, or null on a root, which has no edge. */ +function edgeBadge(node: ForkForestNode) { + if (!node.edge) return null + const { mapped, unmapped } = node.edge + if (mapped + unmapped === 0) { + return { label: 'No mappings', variant: 'gray-secondary' as const } + } + if (unmapped === 0) return { label: `${mapped} mapped`, variant: 'green' as const } + return { label: `${unmapped} unmapped`, variant: 'amber' as const } +} + +interface ForkLineageProps { + /** The workspace the console is open in, marked in the tree so its position is never in doubt. */ + workspaceId: string + nodes: ForkForestNode[] + loading: boolean + searchTerm: string + /** Opens an edge's sync detail, named by the edge's child workspace. */ + onOpenEdge: (childWorkspaceId: string) => void +} + +/** + * Every fork lineage the viewer can reach, as one tree. + * + * Rows are the forest in depth-first order with the sidebar's tree rails, so a chain reads as a + * chain rather than as a parent list and a fork list that have to be reconciled by eye. Each row + * carries its own parent edge: the mapping state it has stored, when it last synced, and the + * actions that edge supports. + */ +export function ForkLineage({ + workspaceId, + nodes, + loading, + searchTerm, + onOpenEdge, +}: ForkLineageProps) { + const router = useRouter() + const rollback = useRollbackFork() + const unlink = useUnlinkFork() + + const [selectedIds, setSelectedIds] = useState([]) + const [confirmUnlink, setConfirmUnlink] = useState(null) + const [confirmUndo, setConfirmUndo] = useState<{ node: ForkForestNode; run: ForkUndoableRun }>() + + const query = searchTerm.trim().toLowerCase() + const rows = useMemo( + () => + buildForkLineageRows( + nodes, + query ? (node) => node.name.toLowerCase().includes(query) : undefined + ), + [nodes, query] + ) + + /** + * Disconnect severs the edge a row hangs off, so only rows that HAVE a parent in view can be + * selected — and only where the viewer administers this side, which is all the route requires. + */ + const isRowSelectable = (row: ForkLineageRow) => row.parent !== null && row.node.viewerCanAdmin + + const selectedRows = useMemo(() => { + const armed = new Set(selectedIds) + return rows.filter((row) => isRowSelectable(row) && armed.has(row.node.id)) + }, [rows, selectedIds]) + + const bulk = useBulkAction({ + copy: DISCONNECT_FORKS_COPY, + rows: selectedRows, + // `isRowSelectable` already refused any row without a parent, so the edge is always resolvable. + perform: (row) => + unlink.mutateAsync({ + workspaceId: row.node.id, + body: { otherWorkspaceId: row.parent?.id ?? row.node.parentId ?? '' }, + }), + onSettled: () => setSelectedIds([]), + }) + + const runUnlink = async () => { + if (!confirmUnlink?.parent) return + try { + await unlink.mutateAsync({ + workspaceId: confirmUnlink.node.id, + body: { otherWorkspaceId: confirmUnlink.parent.id }, + }) + toast.success(`Disconnected "${confirmUnlink.node.name}" from "${confirmUnlink.parent.name}"`) + setConfirmUnlink(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Disconnect failed')) + } + } + + const runUndo = async () => { + if (!confirmUndo) return + const { node, run } = confirmUndo + try { + const result = await rollback.mutateAsync({ + workspaceId: node.id, + body: { otherWorkspaceId: run.otherWorkspaceId }, + }) + if (result.pendingActivations.length > 0) { + toast.warning(`Undid the last sync into "${node.name}"`, { + description: `${result.pendingActivations.length} restored deployment(s) are still activating. Undo stays available until they finish, in case a retry is needed.`, + }) + } else { + toast.success(`Undid the last sync into "${node.name}"`) + } + setConfirmUndo(undefined) + } catch (error) { + toast.error(getErrorMessage(error, 'Undo failed')) + } + } + + const columns: ForkTableColumn[] = [ + { + key: 'workspace', + header: 'Workspace', + cell: ({ node }) => ( + + ), + }, + { + key: 'workflows', + header: 'Workflows', + width: WORKFLOWS_COLUMN_WIDTH, + cell: ({ node }) => ( + + {node.deployedWorkflowCount} deployed + + ), + }, + { + key: 'mappings', + header: 'Mappings', + width: MAPPINGS_COLUMN_WIDTH, + cell: ({ node }) => { + const badge = edgeBadge(node) + if (!badge) return Root workspace + return ( + + {badge.label} + + ) + }, + }, + { + key: 'last-sync', + header: 'Last sync', + width: LAST_SYNC_COLUMN_WIDTH, + cell: ({ node }) => ( + + {node.edge?.lastSyncAt ? formatRelativeTime(node.edge.lastSyncAt) : 'Never'} + + ), + }, + { + key: 'actions', + align: 'right', + width: ACTIONS_COLUMN_WIDTH, + cell: (row) => { + const { node, parent } = row + const undoableRun = node.edge?.undoableRun ?? null + // Editing or running a sync needs admin on BOTH sides, which is what the mapping and + // promote routes enforce; offering it anywhere else would only produce a 403. + const canSync = Boolean(parent && node.viewerCanAdmin && parent.viewerCanAdmin) + return ( +
+ {canSync ? ( + onOpenEdge(node.id)}> + Sync + + ) : null} + router.push(`/workspace/${node.id}/w`), + disabled: !node.viewerAccessible, + tooltip: node.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, + }, + ...(undoableRun + ? [ + { + label: 'Undo last sync', + onSelect: () => setConfirmUndo({ node, run: undoableRun }), + disabled: !node.viewerCanAdmin, + tooltip: node.viewerCanAdmin + ? `Restores every workflow "${undoableRun.otherName}" last synced into this workspace to its prior deployed version.` + : NO_ACCESS_TOOLTIP, + }, + ] + : []), + // Disconnect stays available regardless of access to the OTHER side: severing the + // edge is an operation on this workspace, and it must remain reachable exactly + // when the other side has become unreachable. + ...(parent + ? [ + { + label: 'Disconnect', + destructive: true, + onSelect: () => setConfirmUnlink(row), + disabled: !node.viewerCanAdmin, + tooltip: node.viewerCanAdmin ? undefined : NO_ACCESS_TOOLTIP, + }, + ] + : []), + ]} + /> +
+ ) + }, + }, + ] + + return ( + <> + row.node.id} + columns={columns} + getRowRails={(row) => row.rails} + loading={loading} + selection={{ + selectedIds, + onSelectionChange: setSelectedIds, + isRowSelectable, + bulkActions: ( + + ), + }} + empty={ + query + ? `No workspaces matching “${searchTerm.trim()}”` + : 'No forks yet. Create one to start syncing deployed workflows between workspaces.' + } + /> + + { + if (!open) setConfirmUnlink(null) + }} + srTitle='Disconnect fork' + title='Disconnect fork' + text={[ + 'This permanently removes the fork relationship between ', + { text: confirmUnlink?.node.name ?? '', bold: true }, + ' and ', + { text: confirmUnlink?.parent?.name ?? '', bold: true }, + '. Both workspaces stay exactly as they are, but they no longer appear in each other’s lineage and syncing between them stops.', + ]} + confirm={{ + label: 'Disconnect', + onClick: () => void runUnlink(), + pending: unlink.isPending, + pendingLabel: 'Disconnecting...', + }} + > +
+ + + This cannot be undone. The saved mappings and sync history for this pair are deleted, + and forking again creates a brand-new workspace. + +
+
+ + { + if (!open) setConfirmUndo(undefined) + }} + srTitle='Undo last sync' + title='Undo last sync' + text={[ + 'This restores every workflow in ', + { text: confirmUndo?.node.name ?? '', bold: true }, + ' to its ', + { text: 'prior deployed version', bold: true }, + ' and removes the workflows that sync created. Continue?', + ]} + confirm={{ + label: 'Undo sync', + onClick: () => void runUndo(), + pending: rollback.isPending, + pendingLabel: 'Undoing...', + }} + > +
+ + + Resources copied in during past syncs may remain afterwards. Undo restores workflows to + their prior versions but does not remove copied resources. + +
+
+ + + + ) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-lineage/index.ts b/apps/sim/ee/workspace-forking/components/fork-lineage/index.ts new file mode 100644 index 00000000000..b1bfac96d5e --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-lineage/index.ts @@ -0,0 +1,7 @@ +export { ForkLineage } from '@/ee/workspace-forking/components/fork-lineage/fork-lineage' +export { + buildForkLineageRows, + type ForkLineageRow, + forkLineageRootId, + forkLineageRoots, +} from '@/ee/workspace-forking/components/fork-lineage/lineage-rows' diff --git a/apps/sim/ee/workspace-forking/components/fork-lineage/lineage-rows.test.ts b/apps/sim/ee/workspace-forking/components/fork-lineage/lineage-rows.test.ts new file mode 100644 index 00000000000..a530bbb75bb --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-lineage/lineage-rows.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ForkForestNode } from '@/lib/api/contracts/workspace-fork' +import { + buildForkLineageRows, + forkLineageRootId, + forkLineageRoots, +} from '@/ee/workspace-forking/components/fork-lineage/lineage-rows' + +const node = (id: string, parentId: string | null, name = id): ForkForestNode => ({ + id, + name, + color: '#33C482', + logoUrl: null, + organizationId: null, + parentId, + createdAt: '2026-01-01T00:00:00.000Z', + viewerAccessible: true, + viewerCanAdmin: true, + deployedWorkflowCount: 0, + edge: parentId ? { mapped: 0, unmapped: 0, lastSyncAt: null, undoableRun: null } : null, +}) + +/** + * A chain and a branch in one forest, in the depth-first order the server emits: + * + * root + * ├─ a + * │ └─ a1 + * └─ b + * other + */ +const FOREST: ForkForestNode[] = [ + node('root', null), + node('a', 'root'), + node('a1', 'a'), + node('b', 'root'), + node('other', null), +] + +describe('buildForkLineageRows', () => { + it('gives a root no rails and every descendant one rail per level', () => { + const rows = buildForkLineageRows(FOREST) + const railsById = new Map(rows.map((row) => [row.node.id, row.rails])) + + expect(railsById.get('root')).toEqual([]) + expect(railsById.get('a')).toEqual(['branch']) + expect(railsById.get('a1')).toEqual(['line', 'last-branch']) + expect(railsById.get('b')).toEqual(['last-branch']) + }) + + it('keeps an ancestor rail running while that ancestor still has siblings below', () => { + // `b` is drawn at `a`'s indent and comes after `a1`, so the level-0 line has to run THROUGH + // `a1`'s row to reach it. Blanking it there would leave `b` hanging off nothing. + const [, , a1] = buildForkLineageRows(FOREST) + expect(a1.node.id).toBe('a1') + expect(a1.rails[0]).toBe('line') + }) + + it('blanks an ancestor rail once that ancestor was its parent’s last child', () => { + const forest = [node('root', null), node('a', 'root'), node('a1', 'a'), node('a2', 'a')] + const railsById = new Map(buildForkLineageRows(forest).map((row) => [row.node.id, row.rails])) + + expect(railsById.get('a')).toEqual(['last-branch']) + // Nothing follows `a` at root's child level, so its column is empty under it. + expect(railsById.get('a1')).toEqual(['blank', 'branch']) + expect(railsById.get('a2')).toEqual(['blank', 'last-branch']) + }) + + it('resolves each row to its parent so an edge action knows both sides', () => { + const rows = buildForkLineageRows(FOREST) + expect(rows.find((row) => row.node.id === 'a1')?.parent?.id).toBe('a') + expect(rows.find((row) => row.node.id === 'root')?.parent).toBeNull() + }) + + it('keeps the ancestors of a match, so a filtered fork still reads as a fork', () => { + const rows = buildForkLineageRows(FOREST, (candidate) => candidate.id === 'a1') + expect(rows.map((row) => row.node.id)).toEqual(['root', 'a', 'a1']) + // The retained ancestors are now the only children at their level, so the rails re-derive. + expect(rows[1].rails).toEqual(['last-branch']) + }) + + it('returns nothing when no node matches', () => { + expect(buildForkLineageRows(FOREST, () => false)).toEqual([]) + }) +}) + +describe('forkLineageRootId', () => { + it('walks up to the root of the workspace’s own lineage', () => { + expect(forkLineageRootId(FOREST, 'a1')).toBe('root') + expect(forkLineageRootId(FOREST, 'root')).toBe('root') + }) + + it('treats a workspace whose parent is outside the forest as its own root', () => { + expect(forkLineageRootId([node('orphan', 'missing-parent')], 'orphan')).toBe('orphan') + }) + + it('returns null for a workspace the forest does not carry', () => { + expect(forkLineageRootId(FOREST, 'nope')).toBeNull() + }) +}) + +describe('forkLineageRoots', () => { + it('lists every root, including one whose parent is outside the forest', () => { + const roots = forkLineageRoots([...FOREST, node('orphan', 'missing-parent')]) + expect(roots.map((root) => root.id)).toEqual(['root', 'other', 'orphan']) + }) +}) diff --git a/apps/sim/ee/workspace-forking/components/fork-lineage/lineage-rows.ts b/apps/sim/ee/workspace-forking/components/fork-lineage/lineage-rows.ts new file mode 100644 index 00000000000..74c24141503 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-lineage/lineage-rows.ts @@ -0,0 +1,115 @@ +import type { ForkForestNode } from '@/lib/api/contracts/workspace-fork' +import type { ForkTableRail } from '@/ee/workspace-forking/components/fork-table' + +/** One rendered row of the lineage tree: a node, its tree connectors, and its parent. */ +export interface ForkLineageRow { + node: ForkForestNode + /** Tree connectors, root-first. Empty on a root row. */ + rails: ForkTableRail[] + /** The node's parent, when the forest carries it. */ + parent: ForkForestNode | null +} + +/** + * Narrow a forest to the nodes matching `matches`, keeping every ancestor of a match. + * + * A tree filtered by matches alone loses the path to them, so a search for a fork would render it + * as a root and quietly misstate the lineage. Keeping ancestors preserves the shape at the cost of + * showing rows that do not themselves match, which is the trade every file tree makes. + */ +function retainMatchesWithAncestors( + nodes: ForkForestNode[], + matches: (node: ForkForestNode) => boolean +): ForkForestNode[] { + const byId = new Map(nodes.map((node) => [node.id, node])) + const keep = new Set() + for (const node of nodes) { + if (!matches(node)) continue + let cursor: ForkForestNode | undefined = node + while (cursor && !keep.has(cursor.id)) { + keep.add(cursor.id) + cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined + } + } + return nodes.filter((node) => keep.has(node.id)) +} + +/** + * Lay a flat, depth-first forest out as table rows with tree connectors. + * + * The server already orders nodes depth-first, so sibling order here is the order they arrive in; + * all this adds is each row's rails — which ancestors still have a subtree below them, and whether + * the row itself is its parent's last child. + */ +export function buildForkLineageRows( + nodes: ForkForestNode[], + matches?: (node: ForkForestNode) => boolean +): ForkLineageRow[] { + const visible = matches ? retainMatchesWithAncestors(nodes, matches) : nodes + const byId = new Map(visible.map((node) => [node.id, node])) + + /** Parent id to its children in arrival order; `null` groups the visible roots. */ + const siblings = new Map() + for (const node of visible) { + const parentKey = node.parentId && byId.has(node.parentId) ? node.parentId : null + const group = siblings.get(parentKey) + if (group) group.push(node.id) + else siblings.set(parentKey, [node.id]) + } + + const isLastChild = (node: ForkForestNode): boolean => { + const parentKey = node.parentId && byId.has(node.parentId) ? node.parentId : null + const group = siblings.get(parentKey) ?? [] + return group[group.length - 1] === node.id + } + + /** Root-first ancestor chain, ending at the node itself. */ + const chainOf = (node: ForkForestNode): ForkForestNode[] => { + const chain: ForkForestNode[] = [] + let cursor: ForkForestNode | undefined = node + while (cursor) { + chain.unshift(cursor) + cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined + } + return chain + } + + return visible.map((node) => { + const chain = chainOf(node) + const rails: ForkTableRail[] = chain + .slice(1) + .map((step, index, own) => + index === own.length - 1 + ? isLastChild(step) + ? 'last-branch' + : 'branch' + : isLastChild(step) + ? 'blank' + : 'line' + ) + return { + node, + rails, + parent: node.parentId ? (byId.get(node.parentId) ?? null) : null, + } + }) +} + +/** The root of the lineage a workspace belongs to, or the workspace itself when it is one. */ +export function forkLineageRootId(nodes: ForkForestNode[], workspaceId: string): string | null { + const byId = new Map(nodes.map((node) => [node.id, node])) + let cursor = byId.get(workspaceId) + if (!cursor) return null + while (cursor.parentId) { + const parent = byId.get(cursor.parentId) + if (!parent) break + cursor = parent + } + return cursor.id +} + +/** Every root in the forest, for the lineage picker. */ +export function forkLineageRoots(nodes: ForkForestNode[]): ForkForestNode[] { + const byId = new Map(nodes.map((node) => [node.id, node])) + return nodes.filter((node) => !node.parentId || !byId.has(node.parentId)) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-mappings/fork-mappings.tsx b/apps/sim/ee/workspace-forking/components/fork-mappings/fork-mappings.tsx new file mode 100644 index 00000000000..bcc65ad5482 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-mappings/fork-mappings.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useMemo } from 'react' +import { Badge, ChipCombobox, TableIdentityCell } from '@sim/emcn' +import type { ForkMatrixRow, ForkMatrixWorkspace } from '@/lib/api/contracts/workspace-fork' +import { forkKindLabel } from '@/ee/workspace-forking/components/fork-kind-label' +import type { ForkMatrixEditor } from '@/ee/workspace-forking/components/fork-mappings/use-fork-matrix-editor' +import { ForkTable, type ForkTableColumn } from '@/ee/workspace-forking/components/fork-table' +import type { ForkResourceFilter } from '@/ee/workspace-forking/search-params' + +/** Wide enough for a full-length secret key, the longest label these cells hold. */ +const WORKSPACE_COLUMN_WIDTH = 240 +const RESOURCE_COLUMN_WIDTH = 280 +const KIND_COLUMN_WIDTH = 130 + +interface MatrixCellProps { + editor: ForkMatrixEditor + row: ForkMatrixRow + workspace: ForkMatrixWorkspace +} + +/** + * One workspace's value for one resource chain. + * + * The origin column states what the chain IS and never offers a picker — it is the source every + * other column maps from. Every downstream column is the child half of exactly one edge, so it + * renders that edge's target picker whenever the viewer may edit it, and states why not when they + * may not. + */ +function MatrixCell({ editor, row, workspace }: MatrixCellProps) { + const cell = row.cells[workspace.id] + + if (workspace.id === row.originWorkspaceId) { + return ( + + {cell?.label ?? row.label} + {cell?.missing ? ( + + Deleted + + ) : null} + + ) + } + + if (!cell) return Not in this lineage + + if (!editor.isEditable(row, workspace.id)) { + if (cell.resourceId === null) { + return Not mapped + } + return ( + + {cell.label} + {cell.missing ? ( + + Deleted + + ) : null} + + ) + } + + const value = editor.valueFor(row, workspace.id) + const candidates = editor.candidatesFor(workspace.id, row.kind) + const options = candidates.map((candidate) => ({ label: candidate.label, value: candidate.id })) + // A stored target the candidate list does not carry — deleted, or past the candidate cap — + // still has to render as the current selection, or the picker would silently show it as empty. + if (value && !candidates.some((candidate) => candidate.id === value)) { + options.unshift({ label: cell.label ?? value, value }) + } + + return ( + editor.setValue(row, workspace.id, next)} + placeholder='Not mapped' + searchable + searchPlaceholder='Search targets' + emptyMessage='Nothing to map to in this workspace' + /> + ) +} + +interface ForkMappingsProps { + editor: ForkMatrixEditor + loading: boolean + searchTerm: string + resourceFilter: ForkResourceFilter +} + +/** + * Every resource followed across one lineage: a row per resource, a column per workspace. + * + * This is the view that makes a chain legible — which secret each stage uses, which credential + * production is pointed at — and it edits in place, because the question and the fix are the same + * gesture. The resource column pins while the workspace columns scroll, so a wide lineage never + * loses the row it is describing. + */ +export function ForkMappings({ editor, loading, searchTerm, resourceFilter }: ForkMappingsProps) { + const query = searchTerm.trim().toLowerCase() + + const rows = useMemo( + () => + editor.rows.filter((row) => { + if (resourceFilter !== 'all' && row.kind !== resourceFilter) return false + if (!query) return true + if (row.label.toLowerCase().includes(query)) return true + return Object.values(row.cells).some((cell) => cell.label?.toLowerCase().includes(query)) + }), + [editor.rows, resourceFilter, query] + ) + + const columns: ForkTableColumn[] = [ + { + key: 'resource', + header: 'Resource', + width: RESOURCE_COLUMN_WIDTH, + sticky: true, + cell: (row) => {row.label}, + }, + { + key: 'kind', + header: 'Type', + width: KIND_COLUMN_WIDTH, + cell: (row) => {forkKindLabel(row.kind)}, + }, + ...editor.workspaces.map>((workspace) => ({ + key: workspace.id, + width: WORKSPACE_COLUMN_WIDTH, + header: ( + + ), + cell: (row) => , + })), + ] + + return ( +
+ row.key} + columns={columns} + loading={loading} + empty={ + query || resourceFilter !== 'all' + ? 'No resources match these filters' + : 'Nothing is mapped in this lineage yet. Mappings appear once a fork is created or a sync is configured.' + } + /> + {editor.truncatedWorkspaceNames.length > 0 ? ( +

+ {editor.truncatedWorkspaceNames.join(', ')} has more mapping targets than the picker + loads, so search covers only the ones shown. +

+ ) : null} +
+ ) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-mappings/index.ts b/apps/sim/ee/workspace-forking/components/fork-mappings/index.ts new file mode 100644 index 00000000000..f1f966b6fd7 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-mappings/index.ts @@ -0,0 +1,5 @@ +export { ForkMappings } from '@/ee/workspace-forking/components/fork-mappings/fork-mappings' +export { + type ForkMatrixEditor, + useForkMatrixEditor, +} from '@/ee/workspace-forking/components/fork-mappings/use-fork-matrix-editor' diff --git a/apps/sim/ee/workspace-forking/components/fork-mappings/use-fork-matrix-editor.ts b/apps/sim/ee/workspace-forking/components/fork-mappings/use-fork-matrix-editor.ts new file mode 100644 index 00000000000..dfbf6c27380 --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-mappings/use-fork-matrix-editor.ts @@ -0,0 +1,186 @@ +'use client' + +import { useMemo, useState } from 'react' +import { toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { + ForkMappingCandidate, + ForkMatrixRow, + ForkMatrixWorkspace, + GetForkMatrixResponse, +} from '@/lib/api/contracts/workspace-fork' +import { useUpdateForkMapping } from '@/ee/workspace-forking/hooks/workspace-fork' + +/** `${workspaceId}:${rowKey}` — one editable cell of the matrix. */ +const cellKey = (workspaceId: string, rowKey: string) => `${workspaceId}:${rowKey}` + +/** No candidates for a kind, shared so an empty picker never re-allocates per render. */ +const NO_CANDIDATES: ForkMappingCandidate[] = [] + +export interface ForkMatrixEditor { + workspaces: ForkMatrixWorkspace[] + rows: ForkMatrixRow[] + /** Mapping targets a cell may pick, for the workspace column and the row's kind. */ + candidatesFor: (workspaceId: string, kind: string) => ForkMappingCandidate[] + /** Effective value of a cell: the in-session edit if there is one, else what is stored. */ + valueFor: (row: ForkMatrixRow, workspaceId: string) => string + setValue: (row: ForkMatrixRow, workspaceId: string, value: string) => void + /** + * Whether a cell can be re-pointed. A cell is the child half of exactly one edge, so it needs a + * source to key the mapping on — the chain's value in the parent column — and admin on both + * sides of that edge, which is what the mapping route enforces. + */ + isEditable: (row: ForkMatrixRow, workspaceId: string) => boolean + /** Names of the workspaces whose candidate lists are capped, so their pickers are partial. */ + truncatedWorkspaceNames: string[] + dirty: boolean + saving: boolean + save: () => Promise + discard: () => void +} + +/** + * Editing state for the mappings matrix. + * + * A cell edits the edge that LANDS in its column, so a save is one request per edge that changed — + * issued sequentially against the same per-edge route the sync page uses, rather than through a + * bespoke batch endpoint. Each edge's mapping is independent, so a failure part-way leaves the + * edges that already succeeded correctly saved and reports exactly which one did not. + */ +export function useForkMatrixEditor(data: GetForkMatrixResponse | undefined): ForkMatrixEditor { + const [edits, setEdits] = useState>({}) + const updateMapping = useUpdateForkMapping() + + const workspaces = useMemo(() => data?.workspaces ?? [], [data?.workspaces]) + const rows = useMemo(() => data?.rows ?? [], [data?.rows]) + + const workspaceById = useMemo( + () => new Map(workspaces.map((entry) => [entry.id, entry])), + [workspaces] + ) + + const storedValue = (row: ForkMatrixRow, targetWorkspaceId: string) => + row.cells[targetWorkspaceId]?.resourceId ?? '' + + const valueFor = (row: ForkMatrixRow, targetWorkspaceId: string) => + edits[cellKey(targetWorkspaceId, row.key)] ?? storedValue(row, targetWorkspaceId) + + const isEditable = (row: ForkMatrixRow, targetWorkspaceId: string) => { + const column = workspaceById.get(targetWorkspaceId) + if (!column?.parentId) return false + const parent = workspaceById.get(column.parentId) + if (!parent || !column.viewerCanAdmin || !parent.viewerCanAdmin) return false + return Boolean(row.cells[column.parentId]?.resourceId) + } + + const setValue = (row: ForkMatrixRow, targetWorkspaceId: string, value: string) => { + setEdits((previous) => { + const key = cellKey(targetWorkspaceId, row.key) + // Returning a cell to its stored value un-dirties it, so Save never re-writes a no-op row. + if (value === storedValue(row, targetWorkspaceId)) { + if (!(key in previous)) return previous + const { [key]: _removed, ...rest } = previous + return rest + } + return { ...previous, [key]: value } + }) + } + + const candidatesFor = (targetWorkspaceId: string, kind: string) => + data?.candidates[targetWorkspaceId]?.[kind] ?? NO_CANDIDATES + + const truncatedWorkspaceNames = useMemo( + () => + (data?.candidatesTruncated ?? []).flatMap((id) => { + const name = workspaceById.get(id)?.name + return name ? [name] : [] + }), + [data?.candidatesTruncated, workspaceById] + ) + + const dirty = Object.keys(edits).length > 0 + + const discard = () => setEdits({}) + + const save = async () => { + if (!dirty || updateMapping.isPending) return + const rowByKey = new Map(rows.map((row) => [row.key, row])) + + /** Changed cells grouped by the edge they land in, keyed by that edge's child workspace. */ + const byChild = new Map< + string, + Array<{ + resourceType: ForkMatrixRow['resourceType'] + sourceId: string + targetId: string | null + }> + >() + for (const [key, targetId] of Object.entries(edits)) { + const separator = key.indexOf(':') + const childId = key.slice(0, separator) + const row = rowByKey.get(key.slice(separator + 1)) + const parentId = workspaceById.get(childId)?.parentId + if (!row || !parentId) continue + const sourceId = row.cells[parentId]?.resourceId + if (!sourceId) continue + const entries = byChild.get(childId) ?? [] + entries.push({ resourceType: row.resourceType, sourceId, targetId: targetId || null }) + byChild.set(childId, entries) + } + + const failures = new Map() + for (const [childId, entries] of byChild) { + const parentId = workspaceById.get(childId)?.parentId + if (!parentId) continue + try { + await updateMapping.mutateAsync({ + workspaceId: childId, + // `pull` is the orientation the matrix reads in: the parent column supplies the source, + // the child column receives the target. + body: { otherWorkspaceId: parentId, direction: 'pull', entries }, + }) + } catch (error) { + failures.set(childId, getErrorMessage(error, 'Save failed')) + } + } + + if (failures.size === 0) { + setEdits({}) + toast.success('Mappings saved') + return + } + + // Edges that succeeded keep their writes and stop being dirty; only the failed columns stay + // pending, so a retry re-sends exactly what did not land. + setEdits((previous) => + Object.fromEntries( + Object.entries(previous).filter(([key]) => failures.has(key.slice(0, key.indexOf(':')))) + ) + ) + toast.error( + failures.size === 1 + ? "Couldn't save one workspace" + : `Couldn't save ${failures.size} workspaces`, + { + description: Array.from( + failures, + ([childId, message]) => `${workspaceById.get(childId)?.name ?? childId}: ${message}` + ).join('\n'), + } + ) + } + + return { + workspaces, + rows, + candidatesFor, + valueFor, + setValue, + isEditable, + truncatedWorkspaceNames, + dirty, + saving: updateMapping.isPending, + save, + discard, + } +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.test.ts index fce97ef41ca..4203f9ba660 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.test.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest' import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' import { - forkBlockerResolution, selectVisibleClearedRefs, splitForkClearedRefs, } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list' @@ -146,24 +145,3 @@ describe('splitForkClearedRefs', () => { expect(informational).toEqual([]) }) }) - -describe('forkBlockerResolution', () => { - it('phrases each blocker reason with its actionable resolution', () => { - expect(forkBlockerResolution(referenceRef('table', 'tbl-1'))).toBe( - 'map it to a target or select it for copy' - ) - expect(forkBlockerResolution(referenceRef('mcp-server', 'srv-1'))).toBe( - 'map it to a target or select it for copy' - ) - expect(forkBlockerResolution(referenceRef('knowledge-base', 'kb-gone', 'KB', true))).toBe( - 'deleted in the source — map it to an existing knowledge base in the target' - ) - expect(forkBlockerResolution(workflowRef('wf-other', 'Workflow'))).toBe( - 'deploy "Source" in the source or remove the reference' - ) - }) - - it('returns null for non-blocking dependent entries', () => { - expect(forkBlockerResolution(dependentRef('credential', 'cred-1'))).toBeNull() - }) -}) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.ts b/apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.ts index 0e928112356..d8fe5cb4b3a 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/cleared-refs-list.ts @@ -57,36 +57,3 @@ export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): { } return { blockers, informational } } - -/** - * Human label per remap kind for the resolution copy (singular, lowercase mid-sentence). Shared - * with the Mappings section's source-deleted note so both phrase the same resolution identically. - * `credential` is reachable only from a mapping entry - credentials gate through the required - * check, never through the cleared-ref blockers. - */ -export const FORK_RESOURCE_KIND_LABEL: Record = { - table: 'table', - 'knowledge-base': 'knowledge base', - file: 'file', - 'custom-tool': 'custom tool', - skill: 'skill', - 'mcp-server': 'MCP server', - credential: 'credential', -} - -/** - * The actionable resolution line for a blocking entry, phrased for "{block} would lose {field} - * in {workflow} - {resolution}". Null for non-blocking (dependent) entries. - */ -export function forkBlockerResolution(ref: ForkClearedRef): string | null { - const reason = forkSyncBlockerReasonFor(ref) - if (!reason) return null - switch (reason) { - case 'unmapped-copyable': - return 'map it to a target or select it for copy' - case 'source-deleted': - return `deleted in the source — map it to an existing ${FORK_RESOURCE_KIND_LABEL[ref.kind] ?? 'resource'} in the target` - case 'workflow-missing': - return `deploy "${ref.sourceLabel}" in the source or remove the reference` - } -} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-edge-detail.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-edge-detail.tsx new file mode 100644 index 00000000000..13b0ad83cca --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-edge-detail.tsx @@ -0,0 +1,195 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { useRouter } from 'next/navigation' +import { useQueryState } from 'nuqs' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' +import type { SettingsAction } from '@/components/settings/settings-header' +import type { ForkForestNode } from '@/lib/api/contracts/workspace-fork' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' +import { + ARCHIVED_PREVIEW_LIMIT, + useForkSync, +} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' +import { forkDirectionParam, forkDirectionUrlKeys } from '@/ee/workspace-forking/search-params' +import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' + +interface ForkEdgeDetailProps { + /** The edge's child workspace — the side that owns the mapping, and the sync's anchor. */ + child: ForkForestNode + /** The edge's parent workspace. */ + parent: ForkForestNode + onBack: () => void +} + +/** + * One fork edge's sync page: direction, the deployed-workflow changes, the references still to + * resolve, the trigger URLs it decides, and the run itself. + * + * Anchored on the edge's CHILD workspace because that is where an edge's mapping lives, which is + * what lets the console open any edge in the lineage rather than only the one the viewer happens to + * be standing in. The Sync chip is gated until nothing blocks and every required field has a value, + * and always confirms the overwrite first — that confirm is the flow's one modal. While the mapping + * has unsaved edits the header swaps to Discard and Save, and leaving is guarded. + */ +export function ForkEdgeDetail({ child, parent, onBack }: ForkEdgeDetailProps) { + const router = useRouter() + const [directionParam, setDirection] = useQueryState(forkDirectionParam.key, { + ...forkDirectionParam.parser, + ...forkDirectionUrlKeys, + }) + const direction = directionParam ?? 'push' + + const controller = useForkSync({ + workspaceId: child.id, + otherWorkspaceId: parent.id, + otherWorkspaceName: parent.name, + direction, + enabled: true, + }) + + // Guard leaving while the mapping has unsaved edits, and feed the shared settings dirty store so + // a sidebar section switch confirms too. + const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty }) + const [confirmSyncOpen, setConfirmSyncOpen] = useState(false) + + const source = direction === 'push' ? child : parent + const target = direction === 'push' ? parent : child + + // Sync is the edge's primary action, so it is the rightmost chip. Unsaved mapping edits swap the + // whole cluster for Discard and Save until they are saved or discarded. + const actions: SettingsAction[] = controller.dirty + ? saveDiscardActions({ + dirty: controller.dirty, + saving: controller.saving, + onSave: controller.save, + onDiscard: controller.discard, + }) + : [ + { + text: `Open ${target.name}`, + onSelect: () => router.push(`/workspace/${target.id}/w`), + disabled: !target.viewerAccessible, + tooltip: target.viewerAccessible ? undefined : "You don't have access to this workspace", + }, + { + id: 'sync', + text: controller.submitting ? 'Syncing...' : 'Sync', + variant: 'primary' as const, + onSelect: () => setConfirmSyncOpen(true), + disabled: controller.syncDisabled, + tooltip: controller.syncDisabled + ? controller.syncDisabledReason + : `Overwrites ${target.name} with the deployed workflows in ${source.name}`, + }, + ] + + return ( + <> + + guard.guardBack(() => { + void setDirection(null) + onBack() + }), + }} + title={`${source.name} → ${target.name}`} + description={`Move deployed workflows from ${source.name} into ${target.name}.`} + actions={actions} + > + void setDirection(next)} + /> + + + + + { + setConfirmSyncOpen(false) + void controller.sync() + }, + pending: controller.submitting, + pendingLabel: 'Syncing...', + }} + > + {controller.archivedWorkflowNames.length > 0 ? ( +
+

+ Archived in {target.name}, because the source no longer has them: +

+ {controller.archivedWorkflowNames + .slice(0, ARCHIVED_PREVIEW_LIMIT) + .map((name, index) => ( +
+ {name} +
+ ))} + {controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? ( +
+ and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more +
+ ) : null} +
+ ) : null} + {/* A dead trigger URL is only discoverable after the fact, when the external caller goes + quiet, so it belongs in the confirm beside the other irreversible consequences. */} + {controller.triggerUrlChanges.length > 0 ? ( +
+

+ {controller.triggerUrlChanges.length === 1 ? 'A webhook URL' : 'Webhook URLs'} in{' '} + {target.name} will stop being served, so anything calling{' '} + {controller.triggerUrlChanges.length === 1 ? 'it' : 'them'} breaks until you + re-register: +

+ {controller.triggerUrlChanges.slice(0, ARCHIVED_PREVIEW_LIMIT).map((change) => ( + // Naming the URL, not just its workflow: several URLs in one workflow would render as + // identical lines, and this confirm is the last point before they stop serving. +
+ {change.workflowName} + + {buildWebhookTriggerUrl(change.path)} + +
+ ))} + {controller.triggerUrlChanges.length > ARCHIVED_PREVIEW_LIMIT ? ( +
+ and {controller.triggerUrlChanges.length - ARCHIVED_PREVIEW_LIMIT} more +
+ ) : null} +
+ ) : null} +
+ + ) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index a88d06c0d84..838349f2dc8 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -1,18 +1,7 @@ 'use client' -import { type Dispatch, Fragment, type SetStateAction, useMemo, useState } from 'react' -import { - Badge, - ChevronDown, - Chip, - ChipCombobox, - ChipSwitch, - CollapsibleCard, - cn, - FieldDivider, - Label, - Tooltip, -} from '@sim/emcn' +import { type Dispatch, type SetStateAction, useMemo, useState } from 'react' +import { Badge, Chip, ChipCombobox, ChipSwitch, CollapsibleCard, Label, Tooltip } from '@sim/emcn' import { ArrowRight } from '@sim/emcn/icons' import type { ForkCopyableUnmapped, @@ -23,14 +12,11 @@ import type { } from '@/lib/api/contracts/workspace-fork' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { forkKindLabel } from '@/ee/workspace-forking/components/fork-kind-label' import { FileKindRow, ResourceKindRow, } from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker' -import { - FORK_RESOURCE_KIND_LABEL, - forkBlockerResolution, -} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list' import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation' import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector' import { @@ -39,19 +25,17 @@ import { effectiveDependentValue, } from '@/ee/workspace-forking/components/fork-sync/dependent-value' import type { - ForkKindSummary, - ForkMappingGroup, - ForkSyncController, -} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' + ForkResolveItem, + ForkResolveStatus, +} from '@/ee/workspace-forking/components/fork-sync/resolve-items' +import type { ForkSyncController } from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' -import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers' import type { SelectorKey } from '@/hooks/selectors/types' import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' /** - * Copyable kinds as expandable rows in the "Copy resources" section, ordered + labeled to match - * the fork modal's resource picker exactly. Files nest in a folder ▸ file tree; every other kind - * is a flat list. + * Copyable kinds as expandable rows in the extra-resources picker, ordered and labelled to match + * the fork modal exactly. Files nest in a folder tree; every other kind stays flat. */ const COPYABLE_KIND_SECTIONS: ReadonlyArray<{ kind: ForkCopyableUnmapped['kind'] @@ -65,25 +49,34 @@ const COPYABLE_KIND_SECTIONS: ReadonlyArray<{ { kind: 'mcp-server', label: 'MCP servers' }, ] -/** - * Sentinel option value for the "New copy" entry - the displayed resolution while a copyable - * is copy-selected, and the way back to the copy flow after mapping. Handled via onSelect, - * never sent. - */ +/** Sentinel for "copy this resource into the target" — handled by `onSelect`, never sent. */ const NEW_COPY_VALUE = '__new_copy__' -/** - * Sentinel option value for "New URL" - the trigger mints a fresh public URL instead of taking - * over a retiring one. Sent as `adoptPath: null`. - */ +/** Sentinel for "accept losing this reference" — offered only where the source is already gone. */ +const DROP_REFERENCE_VALUE = '__drop_reference__' + +/** Sentinel for "mint a fresh public URL" — sent as `adoptPath: null`. */ const NEW_TRIGGER_URL_VALUE = '__new_trigger_url__' /** - * Fixed target-picker width so every mapping row's control lines up as one column (mirrors - * General). Wide enough to hold a full-length secret key - these are the longest labels the - * picker shows, and clipping them is what makes two same-prefixed keys indistinguishable. + * Fixed control width so every row's picker lines up as one column. Wide enough to hold a + * full-length secret key, the longest label these pickers show, since clipping one is what makes + * two same-prefixed keys indistinguishable. */ -const MAPPING_TARGET_TRIGGER_CLASS = 'w-[380px] flex-shrink-0' +const RESOLVE_CONTROL_CLASS = 'w-[320px] flex-shrink-0' + +/** Badge copy and colour per resolve status. */ +const RESOLVE_BADGE: Record< + ForkResolveStatus, + { label: string; variant: 'red' | 'amber' | 'green' | 'gray-secondary' } +> = { + blocking: { label: 'Blocking', variant: 'red' }, + 'needs-setup': { label: 'Needs setup', variant: 'amber' }, + 'will-clear': { label: 'Will clear', variant: 'amber' }, + dropped: { label: 'Dropped', variant: 'gray-secondary' }, + copied: { label: 'Copy', variant: 'green' }, + mapped: { label: 'Mapped', variant: 'green' }, +} interface DependentBlock { targetBlockId: string @@ -98,8 +91,8 @@ interface WorkflowDependents { } /** - * Bucket an entry's dependents per workflow, then per block within it - the - * workflow → block hierarchy the workflow cards render from. + * Bucket an entry's dependents per workflow, then per block within it — the workflow → block + * hierarchy the workflow cards render from. */ function groupDependentsByWorkflow( workflows: ForkResourceUsage['workflows'], @@ -155,7 +148,6 @@ function applyDependentRepick( ) { setReconfig((prev) => { const nextState = { ...prev, [dependentKey(field)]: value } - // A changed parent invalidates its children's stale re-picks. const providedKey = field.providesContextKey if (providedKey) { for (const sibling of blockFields) { @@ -182,12 +174,11 @@ interface DependentSelectorProps { } /** - * One depends-on field's selector. Under a MAPPED parent it browses the TARGET parent - * (pre-filled from the stored value, blank after a parent change) and is disabled until the - * parent target is set. Under a COPY-resolved parent it browses the SOURCE parent (the copy - * will contain exactly those children), pre-filled with the source reference. Either way it - * stays disabled until every chained in-block parent has a value, and a re-pick invalidates - * chained children. + * One depends-on field's selector. Under a MAPPED parent it browses the TARGET parent (pre-filled + * from the stored value, blank after a parent change) and stays disabled until the parent target + * is set. Under a COPY-resolved parent it browses the SOURCE parent — the copy will contain + * exactly those children — pre-filled with the source reference. Either way it waits for every + * chained in-block parent, and a re-pick invalidates chained children. */ function DependentSelector({ field, @@ -205,13 +196,11 @@ function DependentSelector({ ? effectiveCopyDependentValue(f, reconfig) : effectiveDependentValue(f, reconfig, parentChanged) const { providedValues, providedContextKeys } = blockChainState(block, effectiveValue) - // Disabled until every in-block parent it depends on has a value, so a child never queries - // a stale upstream value. const ready = field.consumesContextKeys.every( (key) => !providedContextKeys.has(key) || providedValues[key] !== undefined ) - // A copy-resolved parent has no target id until the sync runs - scope to the SOURCE parent - // instead (its children are what the copy brings), keeping the selector fully editable. + // A copy-resolved parent has no target id until the sync runs — scope to the SOURCE parent + // instead, whose children are what the copy brings, keeping the selector fully editable. const parentValue = copying ? field.parentSourceId : target return ( a.localeCompare(b)) + const renderField = (field: ForkDependentReconfig) => ( +
+ + +
+ ) + return (
- {topLevel.map((field) => ( -
- - -
- ))} + {topLevel.map(renderField)} {toolGroups.map(([toolName, fields]) => (
{toolName} - {fields.map((field) => ( -
- - -
- ))} + {fields.map(renderField)}
))}
@@ -336,182 +304,178 @@ function DependentWorkflowCard({ ) } -interface MappingEntryProps { +interface ResolveTargetPickerProps { controller: ForkSyncController - group: ForkMappingGroup + item: ForkResolveItem entry: ForkMappingEntry } /** - * One mapping entry: the source ↔ target picker row (with a "Copy instead" entry for copy - * candidates and per-source taken-target disabling on push), then one collapsible card per - * workflow the resource is used in, holding that workflow's dependent field selectors. - * Workflows with nothing to configure are named in a muted note so the usage stays visible. + * The one control that resolves a reference: map it to something in the target, copy it across, or + * — where the source resource is already gone — accept losing it. + * + * These are alternatives to each other, so they belong in one list rather than in a picker beside a + * button beside a checkbox, which is how the same three choices used to be spread across three + * sections of this page. */ -function MappingEntry({ controller, group, entry }: MappingEntryProps) { +function ResolveTargetPicker({ controller, item, entry }: ResolveTargetPickerProps) { + const key = forkRefKey(entry) const target = controller.targetFor(entry) - const takenOwners = controller.takenOwnersFor(entry, group.items) - const parentChanged = controller.parentChangedFor(entry) - const entryRefKey = forkRefKey(entry) - const copying = controller.copyingKeys.has(entryRefKey) - - const usages = controller.usagesForEntry(entry) - const dependents = controller.dependentsForEntry(entry) - // Group once per (usages, dependents) change - both keep stable references from the - // controller's memoized maps, so this skips recompute across the page's frequent re-renders. - const workflows = useMemo( - () => groupDependentsByWorkflow(usages, dependents), - [usages, dependents] - ) - const configurable = workflows.filter((workflow) => workflow.blocks.length > 0) - const usedOnly = workflows.filter((workflow) => workflow.blocks.length === 0) + const copying = controller.copyingKeys.has(key) + const copyable = controller.copyableKeys.has(key) + const takenOwners = controller.takenOwnersFor(entry) + + if (item.status === 'dropped') { + return ( + controller.toggleDroppedRef(entry.kind, entry.sourceId, false)} + > + Undo drop + + ) + } return ( -
-
-
- -
-
-
- {entry.sourceDeleted ? ( -

- Deleted in the source — its name can't be shown. Map it to an existing{' '} - {FORK_RESOURCE_KIND_LABEL[entry.kind] ?? 'resource'} in the target, or fix the reference - in the source and redeploy. -

- ) : null} - {entry.candidatesTruncated ? ( -

- Too many targets to list them all — search covers only the ones shown. -

- ) : null} -
- {configurable.map((workflow) => ( - - ))} - {usedOnly.length > 0 ? ( -

- Also used in {usedOnly.map((workflow) => workflow.workflowName).join(', ')} — nothing to - configure there. -

- ) : null} -
+
`. */ + width?: number + /** + * Pins the column while the card scrolls horizontally. Only meaningful on the leading column — + * the mappings matrix keeps its resource names visible while the workspace columns scroll. + */ + sticky?: boolean +} + +/** + * One level of a row's tree connector, root-first: + * - `line` — an ancestor whose subtree continues past this row, so its drop line runs full height + * - `blank` — an ancestor whose subtree has ended, so the level is empty + * - `branch` — this row's own connector, with siblings still to come + * - `last-branch` — this row's own connector as the final sibling, so the drop line stops at it + */ +export type ForkTableRail = 'line' | 'blank' | 'branch' | 'last-branch' + +/** One level of the tree connector, drawn to bleed through the cell's vertical padding. */ +function ForkTreeRail({ rail }: { rail: ForkTableRail }) { + return ( + + {rail === 'blank' ? null : ( + + )} + {rail === 'branch' || rail === 'last-branch' ? ( + + ) : null} + + ) +} + +/** Controlled multi-select: a checkbox per row and a select-all band carrying the count. */ +export interface ForkTableSelection { + selectedIds: string[] + onSelectionChange: (selectedIds: string[]) => void + /** Rendered right-aligned in the select-all band, whenever the band is. */ + bulkActions?: ReactNode + /** + * Whether a row may be selected. Rows that fail this render NO checkbox and are excluded from + * select-all and from every count, so select-all can still reach "all" and toggle back off. + */ + isRowSelectable?: (row: T) => boolean +} + +export interface ForkTableProps { + rows: T[] + getRowId: (row: T) => string + columns: ForkTableColumn[] + selection?: ForkTableSelection + /** + * Tree connectors for a row, root-first. Rows are rendered exactly as given, so the caller + * flattens its own tree depth-first and describes each row's rails. + */ + getRowRails?: (row: T) => ForkTableRail[] + /** Replaces the rows with skeletons — never an empty state, which would read as "nothing here". */ + loading?: boolean + /** Rendered in place of the rows when there are none. */ + empty?: ReactNode + 'aria-label': string + /** Layout and sizing only — the table owns its chrome. */ + className?: string +} + +/** + * The Forks console's data table. + * + * Chrome is the emcn `Table`'s, literal for literal (see `fork-table-chrome.ts`), but the console + * needs two behaviours the shared component deliberately does not carry: tree rails on a row, and a + * pinned leading column for the mappings matrix. Rather than widen a platform primitive for one + * caller, the console draws its own card from the same tokens. + * + * Like the shared table it owns presentation and selection bookkeeping and nothing else — ordering, + * filtering, and every cell stay with the caller. + */ +export function ForkTable({ + rows, + getRowId, + columns, + selection, + getRowRails, + loading = false, + empty, + 'aria-label': ariaLabel, + className, +}: ForkTableProps) { + const rowIds = rows.map(getRowId) + const selectedIdSet = new Set(selection?.selectedIds ?? []) + const isRowSelectable = selection?.isRowSelectable + const selectableIds = isRowSelectable + ? rowIds.filter((_, index) => isRowSelectable(rows[index])) + : rowIds + const selectedCount = selectableIds.reduce( + (count, id) => (selectedIdSet.has(id) ? count + 1 : count), + 0 + ) + const allSelected = selectableIds.length > 0 && selectedCount === selectableIds.length + + const columnCount = columns.length + (selection ? 1 : 0) + const hasColumnHeaders = columns.some((column) => column.header !== undefined) + + const toggleAll = () => { + if (!selection) return + if (allSelected) { + const visible = new Set(selectableIds) + selection.onSelectionChange(selection.selectedIds.filter((id) => !visible.has(id))) + return + } + const next = [...selection.selectedIds] + for (const id of selectableIds) { + if (!selectedIdSet.has(id)) next.push(id) + } + selection.onSelectionChange(next) + } + + const toggleRow = (id: string) => { + if (!selection) return + selection.onSelectionChange( + selectedIdSet.has(id) + ? selection.selectedIds.filter((selectedId) => selectedId !== id) + : [...selection.selectedIds, id] + ) + } + + /** A pinned cell needs its own fill, or the scrolled columns show through it. */ + const stickyClass = (column: ForkTableColumn, fill: string) => + column.sticky ? cn('sticky left-0 z-[1]', fill) : undefined + + return ( +
+
+ + {selection ? : null} + {columns.map((column) => ( + + ))} + + + + {selection ? ( + + + + + ) : null} + {hasColumnHeaders ? ( + + {selection ? + ))} + + ) : null} + + + + {loading ? ( + Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ( + + + + )) + ) : rows.length === 0 && empty !== undefined ? ( + + + + ) : ( + rows.map((row, index) => { + const id = rowIds[index] + const rails = getRowRails?.(row) + const selectable = isRowSelectable ? isRowSelectable(row) : true + + return ( + + {selection ? ( + + ) : null} + {columns.map((column, columnIndex) => ( + + ))} + + ) + }) + )} + +
+ 0 ? 'indeterminate' : false} + onCheckedChange={toggleAll} + /> + +
+ + {selectedCount > 0 + ? `${selectedCount} selected of ${selectableIds.length}` + : `Select all (${selectableIds.length})`} + + {selection.bulkActions ? ( +
{selection.bulkActions}
+ ) : null} +
+
: null} + {columns.map((column) => ( + + {column.header} +
+ +
+ {empty} +
+ {selectable ? ( + toggleRow(id)} + /> + ) : null} + + {rails && columnIndex === 0 ? ( + + {rails.map((rail, railIndex) => ( + // Rails are positional by construction — one per ancestor level, in + // order — so the index IS their identity. + + ))} + + {column.cell(row)} + + + ) : ( + column.cell(row) + )} +
+
+ ) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-table/index.ts b/apps/sim/ee/workspace-forking/components/fork-table/index.ts new file mode 100644 index 00000000000..26a60414b0f --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-table/index.ts @@ -0,0 +1,8 @@ +export { + ForkTable, + type ForkTableColumn, + type ForkTableRail, +} from '@/ee/workspace-forking/components/fork-table/fork-table' +export { FORK_TABLE_STACK_CLASS } from '@/ee/workspace-forking/components/fork-table/fork-table-chrome' +export { ForkTableTabs } from '@/ee/workspace-forking/components/fork-table/fork-table-tabs' +export { ForkTableToolbar } from '@/ee/workspace-forking/components/fork-table/fork-table-toolbar' diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx index 61026ca0ffb..0ddf1763892 100644 --- a/apps/sim/ee/workspace-forking/components/forks.tsx +++ b/apps/sim/ee/workspace-forking/components/forks.tsx @@ -1,309 +1,103 @@ 'use client' -import { useState } from 'react' -import { ChipConfirmModal, toast } from '@sim/emcn' -import { ArrowLeft, Plus, TriangleAlert } from '@sim/emcn/icons' +import { useMemo, useState } from 'react' +import { ChipDropdown } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' -import { useParams, useRouter } from 'next/navigation' -import { useQueryState } from 'nuqs' +import { useParams } from 'next/navigation' +import { useQueryState, useQueryStates } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' -import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork' import { isBillingEnabled } from '@/lib/core/config/env-flags' -import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { - forkIdParam, - forkIdUrlKeys, - forkSyncDirectionParam, - forkSyncDirectionUrlKeys, - forkViewParam, - forkViewUrlKeys, -} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' -import { - type RowAction, - RowActionsMenu, -} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel' import { ForkExcludedWorkflows } from '@/ee/workspace-forking/components/fork-excluded-workflows/fork-excluded-workflows' -import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' +import { forkKindLabel } from '@/ee/workspace-forking/components/fork-kind-label' +import { + ForkLineage, + forkLineageRootId, + forkLineageRoots, +} from '@/ee/workspace-forking/components/fork-lineage' +import { ForkMappings, useForkMatrixEditor } from '@/ee/workspace-forking/components/fork-mappings' +import { ForkEdgeDetail } from '@/ee/workspace-forking/components/fork-sync/fork-edge-detail' import { - ARCHIVED_PREVIEW_LIMIT, - useForkSync, -} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' + FORK_TABLE_STACK_CLASS, + ForkTableTabs, + ForkTableToolbar, +} from '@/ee/workspace-forking/components/fork-table' import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal' import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available' +import { useForkForest, useForkMatrix } from '@/ee/workspace-forking/hooks/workspace-fork' import { - useForkLineage, - useRollbackFork, - useUnlinkFork, -} from '@/ee/workspace-forking/hooks/workspace-fork' -import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace' + FORK_EVENT_FILTERS, + FORK_RESOURCE_FILTERS, + type ForkTab, + forkEdgeIdParam, + forkEdgeIdUrlKeys, + forkFilterParsers, + forkFilterUrlKeys, + forkRootIdParam, + forkTabParam, + forkTabUrlKeys, +} from '@/ee/workspace-forking/search-params' +import { useWorkspaceCreationPolicy } from '@/hooks/queries/workspace' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' - -/** Explains a disabled lineage action whose target workspace the viewer cannot open. */ -const NO_ACCESS_TOOLTIP = "You don't have access to this workspace" - -/** Lineage partner names by id (the parent + this workspace's forks), for the Activity view. */ -function lineagePartnerNames( - parent: ForkLineageNodeApi | null, - forks: ForkLineageChildApi[] -): ReadonlyMap { - const names = new Map() - if (parent) names.set(parent.id, parent.name) - for (const fork of forks) names.set(fork.id, fork.name) - return names -} - -interface ForkListRowProps { - name: string - /** Entries for the row's `...` menu (Edit mappings / Open workspace / Disconnect). */ - actions: RowAction[] -} -function ForkListRow({ name, actions }: ForkListRowProps) { - return ( -
- -
- -
-
- ) +const TAB_ITEMS: ReadonlyArray<{ id: ForkTab; label: string }> = [ + { id: 'lineage', label: 'Lineage' }, + { id: 'mappings', label: 'Mappings' }, + { id: 'excluded', label: 'Excluded' }, + { id: 'activity', label: 'Activity' }, +] + +const RESOURCE_FILTER_OPTIONS = FORK_RESOURCE_FILTERS.map((value) => ({ + value, + label: value === 'all' ? 'All resources' : forkKindLabel(value), +})) + +const EVENT_FILTER_LABELS: Record<(typeof FORK_EVENT_FILTERS)[number], string> = { + all: 'All events', + fork_content_copy: 'Forks', + fork_sync: 'Syncs', + fork_rollback: 'Rollbacks', } -interface ForkSyncDetailViewProps { - title: string - workspaceId: string - /** The other side of the edge being synced (this workspace's parent). */ - otherWorkspaceId: string - otherWorkspaceName: string - onBack: () => void - /** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */ - actions: SettingsAction[] -} - -/** - * The parent edge's sync page (reached from the parent row): direction, deployed-workflow - * changes, per-kind mappings (each an expandable row whose status badge is the summary), - * copy resources, and blocking references, all as page sections. - * The header's Sync chip is gated until zero blockers + required mappings + reconfigure are - * complete, and always confirms the overwrite first — that confirm is the flow's one modal. - * While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded; - * Sync itself persists the effective mapping as part of the run. - */ -function ForkSyncDetailView({ - title, - workspaceId, - otherWorkspaceId, - otherWorkspaceName, - onBack, - actions, -}: ForkSyncDetailViewProps) { - // Sync direction is shareable view state: a copied link opens the same side of the sync. - const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, { - ...forkSyncDirectionParam.parser, - ...forkSyncDirectionUrlKeys, - }) - - const controller = useForkSync({ - workspaceId, - otherWorkspaceId, - otherWorkspaceName, - direction, - enabled: true, - }) - - // Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed - // the shared settings dirty store so a sidebar section switch confirms too. - const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty }) - - const [confirmSyncOpen, setConfirmSyncOpen] = useState(false) - - // Sync is the edge's primary action, so it's the rightmost/black chip; the caller's - // Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster - // for Discard/Save until they're saved or discarded. - const panelActions: SettingsAction[] = controller.dirty - ? saveDiscardActions({ - dirty: controller.dirty, - saving: controller.saving, - onSave: controller.save, - onDiscard: controller.discard, - }) - : [ - ...actions, - { - text: controller.submitting ? 'Working...' : 'Sync', - variant: 'primary' as const, - onSelect: () => setConfirmSyncOpen(true), - disabled: controller.syncDisabled, - tooltip: controller.syncDisabled - ? controller.syncDisabledReason - : `Push to or pull from ${otherWorkspaceName}`, - }, - ] - - const targetWorkspaceName = controller.targetWorkspaceName - - return ( - <> - - guard.guardBack(() => { - void setDirection(null) - onBack() - }), - }} - title={title} - actions={panelActions} - > - void setDirection(next)} - /> - - - - - { - setConfirmSyncOpen(false) - void controller.sync() - }, - pending: controller.submitting, - pendingLabel: 'Syncing...', - }} - > - {controller.archivedWorkflowNames.length > 0 ? ( -
-

- Will be archived in {targetWorkspaceName} (deleted in the source): -

- {controller.archivedWorkflowNames - .slice(0, ARCHIVED_PREVIEW_LIMIT) - .map((name, index) => ( -
- {name} -
- ))} - {controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? ( -
- and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more -
- ) : null} -
- ) : null} - {/* A dead trigger URL is only discoverable after the fact, when the external caller goes - quiet - so it belongs in the confirm, next to the other irreversible consequences. */} - {controller.triggerUrlChanges.length > 0 ? ( -
-

- {controller.triggerUrlChanges.length === 1 ? 'A webhook URL' : 'Webhook URLs'} in{' '} - {targetWorkspaceName} will stop being served — anything calling{' '} - {controller.triggerUrlChanges.length === 1 ? 'it' : 'them'} breaks until you - re-register: -

- {controller.triggerUrlChanges.slice(0, ARCHIVED_PREVIEW_LIMIT).map((change) => ( - // Naming the URL, not just its workflow: several URLs in one workflow would render - // as identical lines, and this confirm is the last point before they stop serving. -
- {change.workflowName} - - {buildWebhookTriggerUrl(change.path)} - -
- ))} - {controller.triggerUrlChanges.length > ARCHIVED_PREVIEW_LIMIT ? ( -
- and {controller.triggerUrlChanges.length - ARCHIVED_PREVIEW_LIMIT} more -
- ) : null} -
- ) : null} -
- - ) -} +const EVENT_FILTER_OPTIONS = FORK_EVENT_FILTERS.map((value) => ({ + value, + label: EVENT_FILTER_LABELS[value], +})) -interface ForkActivityDetailViewProps { - workspaceId: string - /** Lineage partner names by id, for phrasing rows recorded on the other side of an edge. */ - workspaceNames: ReadonlyMap - onBack: () => void - /** Header actions (e.g. the destructive Rollback chip while the last sync is undoable). */ - actions?: SettingsAction[] -} +/** Wide enough to hold "All resources" without the trigger resizing per selection. */ +const FILTER_TRIGGER_WIDTH = 'w-[170px]' /** - * Workspace-scoped activity: every fork, sync, and rollback involving this workspace - * (both sides of each edge), reached from the page header's "See activity" action. + * What the search box narrows, per tab. Activity has none: its feed is keyset-paginated, so a box + * that only filtered the pages already loaded would silently miss everything older. */ -function ForkActivityDetailView({ - workspaceId, - workspaceNames, - onBack, - actions, -}: ForkActivityDetailViewProps) { - return ( - - - - ) +const SEARCH_PLACEHOLDER: Partial> = { + lineage: 'Search workspaces', + mappings: 'Search resources', + excluded: 'Search workflows', } /** - * Forks settings page. The workspace's single parent (if it's a fork) sits in its own - * "Parent" section, above the "Forks" list of child forks. The parent row's `...` menu - * has Edit mappings (the child owns its edge's re-picks), Open workspace, and - * Disconnect; fork rows offer Open workspace and Disconnect only. Activity is - * workspace-scoped and lives behind the header's "See activity" action (including - * Rollback when the last sync into this workspace is undoable). Sync lives on the - * parent's sync detail page. - * Forking and sync rewrite workflow state and deployments en masse, so the page is - * workspace-admin only and gated on the workspace's fork entitlement - every fork route - * re-checks both; the server remains the boundary. + * The Forks console. + * + * One surface for every fork lineage the viewer can reach: the tree of workspaces, the resource + * mappings across a whole lineage, each workspace's exclusion list, and the history of every fork, + * sync, and rollback. Opening an edge from the tree drills into its sync page. + * + * Forking and syncing rewrite workflow state and deployments en masse, so the console is + * workspace-admin only and gated on the workspace's fork entitlement. Every fork route re-checks + * both, and each row carries its own permission flags — the server remains the boundary. */ export function Forks() { const params = useParams() - const router = useRouter() const workspaceId = params.workspaceId as string const { canAdmin, isLoading: permissionsLoading } = useUserPermissionsContext() @@ -311,80 +105,64 @@ export function Forks() { useForkingAvailability(workspaceId) const canUseForking = forkingAvailable && canAdmin - const { data: workspaces } = useWorkspacesQuery() const { data: creationPolicy } = useWorkspaceCreationPolicy() const { navigateToSettings } = useSettingsNavigation() - const lineage = useForkLineage(workspaceId, canUseForking) - const rollback = useRollbackFork() - const unlink = useUnlinkFork() const [searchTerm, setSearchTerm] = useSettingsSearch() const [isForkModalOpen, setIsForkModalOpen] = useState(false) - const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false) - const [confirmUnlink, setConfirmUnlink] = useState<{ id: string; name: string } | null>(null) - - const [selectedForkId, setSelectedForkId] = useQueryState(forkIdParam.key, { - ...forkIdParam.parser, - ...forkIdUrlKeys, + const [tab, setTab] = useQueryState(forkTabParam.key, { + ...forkTabParam.parser, + ...forkTabUrlKeys, }) - const [forkView, setForkView] = useQueryState(forkViewParam.key, { - ...forkViewParam.parser, - ...forkViewUrlKeys, + const [edgeId, setEdgeId] = useQueryState(forkEdgeIdParam.key, { + ...forkEdgeIdParam.parser, + ...forkEdgeIdUrlKeys, }) + const [rootId, setRootId] = useQueryState(forkRootIdParam.key, forkRootIdParam.parser) + const [filters, setFilters] = useQueryStates(forkFilterParsers, forkFilterUrlKeys) + + const forest = useForkForest(workspaceId, canUseForking) + const nodes = useMemo(() => forest.data?.nodes ?? [], [forest.data?.nodes]) + const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]) + /** Names by id, so the activity feed can phrase a row recorded on the other side of an edge. */ + const workspaceNames = useMemo(() => new Map(nodes.map((node) => [node.id, node.name])), [nodes]) + + /** + * Derived from the loaded forest rather than duplicated into state. A stale id — a disconnected + * edge restored from history, or an old link — resolves to nothing and falls back to the console; + * the lingering param is harmless and the next selection overwrites it. + */ + const edgeChild = edgeId ? nodeById.get(edgeId) : undefined + const edgeParent = edgeChild?.parentId ? nodeById.get(edgeChild.parentId) : undefined + + /** The lineage the matrix lays out: the caller's pick, else the one this workspace belongs to. */ + const activeRootId = rootId ?? forkLineageRootId(nodes, workspaceId) ?? null + const matrix = useForkMatrix( + workspaceId, + activeRootId ?? undefined, + canUseForking && tab === 'mappings' + ) + const matrixEditor = useForkMatrixEditor(matrix.data) - const workspaceName = workspaces?.find((workspace) => workspace.id === workspaceId)?.name - const canFork = creationPolicy?.canCreate ?? true - const parent = lineage.data?.parent ?? null - const forks = lineage.data?.children ?? [] - const undoableRun = lineage.data?.undoableRun ?? null - const gateLoading = availabilityLoading || permissionsLoading - - // Rollback undoes the last sync INTO this workspace, restoring each affected - // workflow to its prior deployed version. - const runRollback = async () => { - if (!undoableRun) return - try { - const result = await rollback.mutateAsync({ - workspaceId, - body: { otherWorkspaceId: undoableRun.otherWorkspaceId }, - }) - if (result.pendingActivations.length > 0) { - toast.warning(`Undid sync from "${undoableRun.otherName}"`, { - description: `${result.pendingActivations.length} restored deployment(s) are still activating. Undo stays available until they finish, in case a retry is needed.`, - }) - } else { - toast.success(`Undid sync from "${undoableRun.otherName}"`) - } - setConfirmRollbackOpen(false) - } catch (err) { - toast.error(getErrorMessage(err, 'Undo failed')) - } - } - - const openForkWorkspace = (forkId: string) => { - router.push(`/workspace/${forkId}/w`) - } + // Called unconditionally, before every early-return gate: a hook placed after one is skipped on + // gated renders and crashes. + const guard = useSettingsUnsavedGuard({ isDirty: matrixEditor.dirty }) - const openForkMappings = (forkId: string) => { - void setSelectedForkId(forkId) - } + const roots = useMemo(() => forkLineageRoots(nodes), [nodes]) + const rootOptions = useMemo( + () => roots.map((root) => ({ value: root.id, label: root.name })), + [roots] + ) - /** Permanently dissolve the edge with the confirmed workspace; both workspaces remain. */ - const runUnlink = async () => { - if (!confirmUnlink) return - try { - await unlink.mutateAsync({ - workspaceId, - body: { otherWorkspaceId: confirmUnlink.id }, - }) - toast.success(`Disconnected "${confirmUnlink.name}"`) - setConfirmUnlink(null) - } catch (err) { - toast.error(getErrorMessage(err, 'Disconnect failed')) - } - } + /** Workspaces whose exclusion list the viewer may edit, for the Excluded tab's picker. */ + const excludableWorkspaces = useMemo(() => nodes.filter((node) => node.viewerCanAdmin), [nodes]) + const excludedWorkspaceId = excludableWorkspaces.some((node) => node.id === workspaceId) + ? workspaceId + : (excludableWorkspaces[0]?.id ?? workspaceId) + const [excludedTarget, setExcludedTarget] = useState(null) + const activeExcludedId = excludedTarget ?? excludedWorkspaceId - if (gateLoading) { + if (availabilityLoading || permissionsLoading) { return } @@ -400,218 +178,174 @@ export function Forks() { ) } - const searchLower = searchTerm.trim().toLowerCase() - const parentVisible = - parent !== null && (!searchLower || parent.name.toLowerCase().includes(searchLower)) - const filteredForks = forks.filter((fork) => fork.name.toLowerCase().includes(searchLower)) - - // The sync detail exists only for the PARENT edge: sync (and the mapping re-picks it - // persists) belongs to the child workspace configuring how it maps its parent's - // resources, so a parent browsing its forks gets no detail for them (a stale fork-id - // deep link falls back to the list). Fork rows offer Open workspace / Disconnect only. - const showParentDetail = Boolean(selectedForkId && parent && parent.id === selectedForkId) - - // Open workspace sits left of the detail view's primary Sync chip, which the sync - // page owns (it carries the gating). Rollback lives on the Activity view only. - const parentHeaderActions: SettingsAction[] = parent - ? [ + if (edgeChild && edgeParent) { + return ( + void setEdgeId(null, { history: 'replace' })} + /> + ) + } + + if (forest.isError) { + return ( + + + {getErrorMessage(forest.error, 'Failed to load fork lineages')} + + + ) + } + + const searchPlaceholder = SEARCH_PLACEHOLDER[tab] + + const openTab = (next: ForkTab) => { + guard.guardBack(() => { + // The matrix editor outlives the tab that hosts it, so leaving has to drop its edits too — + // otherwise Save and Discard would follow the user onto a tab that cannot explain them. + matrixEditor.discard() + // The search box means something different per tab, so it never carries across. + setSearchTerm('') + void setTab(next) + }) + } + + // Saving the matrix is the console's only editable state, so its Save and Discard replace the + // header cluster exactly while it is dirty. + const actions: SettingsAction[] = matrixEditor.dirty + ? saveDiscardActions({ + dirty: matrixEditor.dirty, + saving: matrixEditor.saving, + onSave: () => void matrixEditor.save(), + onDiscard: matrixEditor.discard, + }) + : [ { - text: 'Open workspace', - onSelect: () => openForkWorkspace(parent.id), - disabled: !parent.viewerAccessible, - tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, + text: 'Create fork', + icon: Plus, + variant: 'primary', + onSelect: () => setIsForkModalOpen(true), }, ] - : [] - return ( - <> - {showParentDetail && parent ? ( - void setSelectedForkId(null, { history: 'replace' })} - actions={parentHeaderActions} + const filterControls = + tab === 'mappings' ? ( + <> + void setRootId(value)} /> - ) : forkView === 'activity' ? ( - void setForkView(null, { history: 'replace' })} - actions={ - undoableRun - ? [ - { - text: 'Rollback', - variant: 'destructive', - onSelect: () => setConfirmRollbackOpen(true), - disabled: rollback.isPending, - tooltip: `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`, - }, - ] - : undefined + + setFilters({ + resource: FORK_RESOURCE_FILTERS.find((entry) => entry === value) ?? null, + }) } /> - ) : ( - void setForkView('activity') }, - { - text: 'Create fork', - icon: Plus, - variant: 'primary', - onSelect: () => setIsForkModalOpen(true), - }, - ]} - > - {lineage.isError ? ( -
-

- {getErrorMessage(lineage.error, 'Failed to load forks')} -

-
- ) : lineage.isLoading ? null : ( -
- {parentVisible && parent !== null && ( - - openForkMappings(parent.id), - disabled: !parent.viewerAccessible, - tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, - }, - { - label: 'Open workspace', - onSelect: () => openForkWorkspace(parent.id), - disabled: !parent.viewerAccessible, - tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, - }, - // Disconnect stays enabled regardless of access: severing the edge is a - // current-workspace operation (admin on the acting side only), and must - // remain reachable exactly when the other side is inaccessible. - { - label: 'Disconnect', - destructive: true, - onSelect: () => setConfirmUnlink({ id: parent.id, name: parent.name }), - }, - ]} - /> - - )} - - {filteredForks.length > 0 ? ( -
- {filteredForks.map((fork) => ( - openForkWorkspace(fork.id), - disabled: !fork.viewerAccessible, - tooltip: fork.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, - }, - { - label: 'Disconnect', - destructive: true, - onSelect: () => setConfirmUnlink({ id: fork.id, name: fork.name }), - }, - ]} - /> - ))} -
- ) : ( - - {searchTerm.trim() - ? `No forks found matching "${searchTerm}"` - : 'No forks yet — click "Create fork" above to get started'} - - )} -
- - - -
- )} -
- )} + + ) : tab === 'excluded' ? ( + ({ value: node.id, label: node.name }))} + matchTriggerWidth={false} + className={FILTER_TRIGGER_WIDTH} + aria-label='Workspace' + onChange={setExcludedTarget} + /> + ) : tab === 'activity' ? ( + + setFilters({ event: FORK_EVENT_FILTERS.find((entry) => entry === value) ?? null }) + } + /> + ) : undefined + + return ( + <> + +
+ + + + {tab === 'lineage' ? ( + void setEdgeId(childWorkspaceId)} + /> + ) : null} + + {tab === 'mappings' ? ( + activeRootId ? ( + + ) : ( + + No lineages yet. Create a fork to start mapping resources across workspaces. + + ) + ) : null} + + {tab === 'excluded' ? ( + + ) : null} + + {tab === 'activity' ? ( + + ) : null} +
+
+ + { if (isBillingEnabled) navigateToSettings({ section: 'billing' }) }} /> - - { - if (!open) setConfirmUnlink(null) - }} - srTitle='Disconnect fork' - title='Disconnect fork' - text={[ - 'This permanently removes the fork relationship with ', - { text: confirmUnlink?.name ?? '', bold: true }, - ". Both workspaces stay exactly as they are, but they will no longer appear in each other's fork lists, and syncing between them stops.", - ]} - confirm={{ - label: 'Disconnect', - onClick: () => void runUnlink(), - pending: unlink.isPending, - pendingLabel: 'Disconnecting...', - }} - > -
- - - This cannot be undone — the saved mappings and sync history for this pair are deleted, - and forking again creates a brand-new workspace. - -
-
- - void runRollback(), - pending: rollback.isPending, - pendingLabel: 'Rolling back...', - }} - > -
- - - Resources copied into this workspace during syncs may remain afterward — rollback - restores workflows to their prior versions but does not remove copied resources. - -
-
) } diff --git a/apps/sim/ee/workspace-forking/hooks/background-work.ts b/apps/sim/ee/workspace-forking/hooks/background-work.ts index 04ff26407f6..44bf0cc715d 100644 --- a/apps/sim/ee/workspace-forking/hooks/background-work.ts +++ b/apps/sim/ee/workspace-forking/hooks/background-work.ts @@ -2,6 +2,7 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type BackgroundWorkItem, + type ForkActivityFilter, type GetWorkspaceBackgroundWorkResponse, getWorkspaceBackgroundWorkContract, } from '@/lib/api/contracts/workspace-fork' @@ -12,7 +13,8 @@ export const backgroundWorkKeys = { // under the old key was an array, and an infinite query reading such a cache entry // renders as empty. A shape change must always re-key. lists: () => [...backgroundWorkKeys.all, 'list', 'infinite'] as const, - list: (workspaceId?: string) => [...backgroundWorkKeys.lists(), workspaceId ?? ''] as const, + list: (workspaceId?: string, kind: ForkActivityFilter = 'all') => + [...backgroundWorkKeys.lists(), workspaceId ?? '', kind] as const, } export const BACKGROUND_WORK_STALE_TIME = 5_000 @@ -22,12 +24,13 @@ const BACKGROUND_WORK_PAGE_SIZE = '50' async function fetchWorkspaceBackgroundWork( workspaceId: string, + kind: ForkActivityFilter, cursor?: string, signal?: AbortSignal ): Promise { return requestJson(getWorkspaceBackgroundWorkContract, { params: { id: workspaceId }, - query: { cursor, limit: BACKGROUND_WORK_PAGE_SIZE }, + query: { cursor, limit: BACKGROUND_WORK_PAGE_SIZE, kind }, signal, }) } @@ -44,11 +47,11 @@ const isActive = (item: BackgroundWorkItem) => * loaded page sequentially with fresh cursors, so pagination stays consistent. * Refetch on focus catches changes after the tab was away. */ -export function useWorkspaceBackgroundWork(workspaceId?: string) { +export function useWorkspaceBackgroundWork(workspaceId?: string, kind: ForkActivityFilter = 'all') { return useInfiniteQuery({ - queryKey: backgroundWorkKeys.list(workspaceId), + queryKey: backgroundWorkKeys.list(workspaceId, kind), queryFn: ({ pageParam, signal }) => - fetchWorkspaceBackgroundWork(workspaceId as string, pageParam, signal), + fetchWorkspaceBackgroundWork(workspaceId as string, kind, pageParam, signal), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, enabled: Boolean(workspaceId), diff --git a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts index 614b59a333c..4b1f17dfe5a 100644 --- a/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts +++ b/apps/sim/ee/workspace-forking/hooks/workspace-fork.ts @@ -4,8 +4,9 @@ import { type ForkWorkspaceBody, forkWorkspaceContract, getForkDiffContract, - getForkLineageContract, + getForkForestContract, getForkMappingContract, + getForkMatrixContract, getForkResourcesContract, type PromoteForkBody, promoteForkContract, @@ -30,11 +31,14 @@ export type ForkDirection = 'push' | 'pull' export const forkKeys = { all: ['workspace-fork'] as const, - lineages: () => [...forkKeys.all, 'lineage'] as const, - lineage: (workspaceId?: string) => [...forkKeys.lineages(), workspaceId ?? ''] as const, + forests: () => [...forkKeys.all, 'forest'] as const, + forest: (workspaceId?: string) => [...forkKeys.forests(), workspaceId ?? ''] as const, mappings: () => [...forkKeys.all, 'mapping'] as const, mapping: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) => [...forkKeys.mappings(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const, + matrices: () => [...forkKeys.all, 'matrix'] as const, + matrix: (workspaceId?: string, rootId?: string) => + [...forkKeys.matrices(), workspaceId ?? '', rootId ?? ''] as const, diffs: () => [...forkKeys.all, 'diff'] as const, diff: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) => [...forkKeys.diffs(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const, @@ -43,8 +47,9 @@ export const forkKeys = { } export const WORKSPACE_FORK_RESOURCES_STALE_TIME = 30 * 1000 -export const WORKSPACE_FORK_LINEAGE_STALE_TIME = 30 * 1000 +export const WORKSPACE_FORK_FOREST_STALE_TIME = 30 * 1000 export const WORKSPACE_FORK_MAPPING_STALE_TIME = 15 * 1000 +export const WORKSPACE_FORK_MATRIX_STALE_TIME = 15 * 1000 export const WORKSPACE_FORK_DIFF_STALE_TIME = 10 * 1000 export function useForkResources(workspaceId?: string, enabled = true) { @@ -57,13 +62,30 @@ export function useForkResources(workspaceId?: string, enabled = true) { }) } -export function useForkLineage(workspaceId?: string, enabled = true) { +/** Every fork lineage the viewer can reach from this workspace, as flat depth-first rows. */ +export function useForkForest(workspaceId?: string, enabled = true) { return useQuery({ - queryKey: forkKeys.lineage(workspaceId), + queryKey: forkKeys.forest(workspaceId), queryFn: ({ signal }) => - requestJson(getForkLineageContract, { params: { id: workspaceId as string }, signal }), + requestJson(getForkForestContract, { params: { id: workspaceId as string }, signal }), enabled: Boolean(workspaceId) && enabled, - staleTime: WORKSPACE_FORK_LINEAGE_STALE_TIME, + staleTime: WORKSPACE_FORK_FOREST_STALE_TIME, + placeholderData: keepPreviousData, + }) +} + +/** Resource chains across one lineage, with the targets each cell may be re-pointed at. */ +export function useForkMatrix(workspaceId?: string, rootId?: string, enabled = true) { + return useQuery({ + queryKey: forkKeys.matrix(workspaceId, rootId), + queryFn: ({ signal }) => + requestJson(getForkMatrixContract, { + params: { id: workspaceId as string }, + query: { rootId: rootId as string }, + signal, + }), + enabled: Boolean(workspaceId && rootId) && enabled, + staleTime: WORKSPACE_FORK_MATRIX_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -96,7 +118,8 @@ export function useForkWorkspace() { queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() }) }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: forkKeys.lineages() }) + queryClient.invalidateQueries({ queryKey: forkKeys.forests() }) + queryClient.invalidateQueries({ queryKey: forkKeys.matrices() }) queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() }) }, }) @@ -129,6 +152,7 @@ export function useUpdateForkMapping() { requestJson(updateForkMappingContract, { params: { id: vars.workspaceId }, body: vars.body }), onSettled: () => { queryClient.invalidateQueries({ queryKey: forkKeys.mappings() }) + queryClient.invalidateQueries({ queryKey: forkKeys.matrices() }) queryClient.invalidateQueries({ queryKey: forkKeys.diffs() }) }, }) @@ -162,8 +186,9 @@ export function usePromoteFork() { onSettled: () => { // A sync changes lineage (undoable run), mappings, and the diff - not the // workspace's copyable resource inventory, so leave `resources` cached. - queryClient.invalidateQueries({ queryKey: forkKeys.lineages() }) + queryClient.invalidateQueries({ queryKey: forkKeys.forests() }) queryClient.invalidateQueries({ queryKey: forkKeys.mappings() }) + queryClient.invalidateQueries({ queryKey: forkKeys.matrices() }) queryClient.invalidateQueries({ queryKey: forkKeys.diffs() }) queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() }) // A sync rewrites the target workflows' drafts AND redeploys them. The promote @@ -184,8 +209,9 @@ export function useUnlinkFork() { onSettled: () => { // Unlink dissolves the edge: lineage loses the row, and the edge's mappings/diff // no longer exist. Workflows and deployments are untouched. - queryClient.invalidateQueries({ queryKey: forkKeys.lineages() }) + queryClient.invalidateQueries({ queryKey: forkKeys.forests() }) queryClient.invalidateQueries({ queryKey: forkKeys.mappings() }) + queryClient.invalidateQueries({ queryKey: forkKeys.matrices() }) queryClient.invalidateQueries({ queryKey: forkKeys.diffs() }) queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() }) }, @@ -200,8 +226,9 @@ export function useRollbackFork() { onSettled: () => { // Rollback changes lineage, mappings, and the diff - not the copyable resource // inventory, so leave `resources` cached (mirrors usePromoteFork). - queryClient.invalidateQueries({ queryKey: forkKeys.lineages() }) + queryClient.invalidateQueries({ queryKey: forkKeys.forests() }) queryClient.invalidateQueries({ queryKey: forkKeys.mappings() }) + queryClient.invalidateQueries({ queryKey: forkKeys.matrices() }) queryClient.invalidateQueries({ queryKey: forkKeys.diffs() }) queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() }) // Rollback restores the target workflows' drafts + reactivates a prior deployment, diff --git a/apps/sim/ee/workspace-forking/lib/background-work/store.test.ts b/apps/sim/ee/workspace-forking/lib/background-work/store.test.ts index 04313635abe..bb94f9c06b7 100644 --- a/apps/sim/ee/workspace-forking/lib/background-work/store.test.ts +++ b/apps/sim/ee/workspace-forking/lib/background-work/store.test.ts @@ -151,12 +151,12 @@ describe('listSurfacedBackgroundWork', () => { const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition expect(rowsWhere.type).toBe('and') - expect(rowsWhere.conditions).toHaveLength(3) + expect(rowsWhere.conditions).toHaveLength(4) // The cursor timestamp is bound as a `::timestamp`-cast SQL fragment so the // comparison happens at full microsecond precision in Postgres. const expectedTimestampFragment = expect.objectContaining({ values: [cursorTimestamp] }) - const keyset = (rowsWhere.conditions as MockCondition[])[2] + const keyset = (rowsWhere.conditions as MockCondition[])[3] expect(keyset).toEqual( expect.objectContaining({ type: 'or', @@ -189,8 +189,8 @@ describe('listSurfacedBackgroundWork', () => { await listSurfacedBackgroundWork(executor, 'ws-1', { cursor }) const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition - expect(rowsWhere.conditions).toHaveLength(3) - const keyset = (rowsWhere.conditions as MockCondition[])[2] + expect(rowsWhere.conditions).toHaveLength(4) + const keyset = (rowsWhere.conditions as MockCondition[])[3] expect((keyset.conditions as MockCondition[])[0]).toEqual( expect.objectContaining({ type: 'lt', @@ -232,7 +232,7 @@ describe('listSurfacedBackgroundWork', () => { }) const rowsWhere = dbChainMockFns.where.mock.calls[3][0] as MockCondition - const keyset = (rowsWhere.conditions as MockCondition[])[2] + const keyset = (rowsWhere.conditions as MockCondition[])[3] expect(keyset.conditions?.[1]).toEqual( expect.objectContaining({ type: 'and', @@ -261,7 +261,7 @@ describe('listSurfacedBackgroundWork', () => { await listSurfacedBackgroundWork(executor, 'ws-1', { cursor }) const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition - expect(rowsWhere.conditions).toHaveLength(2) + expect(rowsWhere.conditions).toHaveLength(3) }) it('ignores a cursor with an out-of-range time and serves the first page', async () => { @@ -271,7 +271,7 @@ describe('listSurfacedBackgroundWork', () => { await listSurfacedBackgroundWork(executor, 'ws-1', { cursor }) const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition - expect(rowsWhere.conditions).toHaveLength(2) + expect(rowsWhere.conditions).toHaveLength(3) }) it('ignores an undecodable cursor and serves the first page', async () => { @@ -280,7 +280,7 @@ describe('listSurfacedBackgroundWork', () => { await listSurfacedBackgroundWork(executor, 'ws-1', { cursor: 'not-base64-json' }) const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition - expect(rowsWhere.conditions).toHaveLength(2) + expect(rowsWhere.conditions).toHaveLength(3) }) it('clamps the requested limit to the server-side cap', async () => { @@ -305,13 +305,35 @@ describe('listSurfacedBackgroundWork', () => { }) }) + it('restricts the feed to fork kinds, so a deploy job never lands in a fork history', async () => { + mockChildrenLookup([]) + await listSurfacedBackgroundWork(executor, 'ws-1') + + const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition + const kinds = (rowsWhere.conditions as MockCondition[])[2] + expect(kinds).toEqual({ + type: 'inArray', + column: 'kind', + values: ['fork_content_copy', 'fork_sync', 'fork_rollback'], + }) + }) + + it('narrows to one kind when the caller asks for it', async () => { + mockChildrenLookup([]) + await listSurfacedBackgroundWork(executor, 'ws-1', { kinds: ['fork_sync'] }) + + const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition + const kinds = (rowsWhere.conditions as MockCondition[])[2] + expect(kinds).toEqual({ type: 'inArray', column: 'kind', values: ['fork_sync'] }) + }) + it('matches rows keyed to the workspace, to it as fork child, and to it as edge partner', async () => { mockChildrenLookup([]) await listSurfacedBackgroundWork(executor, 'ws-1') const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition expect(rowsWhere.type).toBe('and') - expect(rowsWhere.conditions).toHaveLength(2) + expect(rowsWhere.conditions).toHaveLength(3) const [involves, statuses] = rowsWhere.conditions as [MockCondition, MockCondition] expect(involves.type).toBe('or') diff --git a/apps/sim/ee/workspace-forking/lib/background-work/store.ts b/apps/sim/ee/workspace-forking/lib/background-work/store.ts index facb4431fbd..7c25262891b 100644 --- a/apps/sim/ee/workspace-forking/lib/background-work/store.ts +++ b/apps/sim/ee/workspace-forking/lib/background-work/store.ts @@ -39,6 +39,14 @@ const SURFACED_STATUSES: BackgroundWorkStatusValue[] = [ 'failed', ] +/** + * The kinds the Forks console's Activity view is a record of. `deployment_side_effects` shares + * this table but is not a fork event: it is keyed to the workspace like every other row, so + * without this filter a deploy would appear in a fork's history with no fork to attribute it to. + */ +export const FORK_ACTIVITY_KINDS = ['fork_content_copy', 'fork_sync', 'fork_rollback'] as const +export type ForkActivityKind = (typeof FORK_ACTIVITY_KINDS)[number] + /** Default page size for the workspace's Activity tab (mirrors the audit log's). */ const BACKGROUND_WORK_PAGE_SIZE = 50 @@ -301,7 +309,7 @@ export interface BackgroundWorkPage { export async function listSurfacedBackgroundWork( executor: DbOrTx, workspaceId: string, - options?: { cursor?: string; limit?: number } + options?: { cursor?: string; limit?: number; kinds?: readonly ForkActivityKind[] } ): Promise { const limit = Math.min( Math.max(options?.limit ?? BACKGROUND_WORK_PAGE_SIZE, 1), @@ -328,7 +336,11 @@ export async function listSurfacedBackgroundWork( : []) ) - const conditions = [involvesWorkspace, inArray(backgroundWorkStatus.status, SURFACED_STATUSES)] + const conditions = [ + involvesWorkspace, + inArray(backgroundWorkStatus.status, SURFACED_STATUSES), + inArray(backgroundWorkStatus.kind, [...(options?.kinds ?? FORK_ACTIVITY_KINDS)]), + ] if (options?.cursor) { const cursorCondition = buildCursorCondition(options.cursor) if (cursorCondition) conditions.push(cursorCondition) diff --git a/apps/sim/ee/workspace-forking/lib/lineage/forest.ts b/apps/sim/ee/workspace-forking/lib/lineage/forest.ts new file mode 100644 index 00000000000..eb9cdee2393 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/lineage/forest.ts @@ -0,0 +1,308 @@ +import { db } from '@sim/db' +import { + workflow, + workspace, + workspaceForkPromoteRun, + workspaceForkResourceMap, +} from '@sim/db/schema' +import { and, eq, inArray, isNull, notInArray, sql } from 'drizzle-orm' +import type { ForkForestNode, ForkUndoableRun } from '@/lib/api/contracts/workspace-fork' +import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' + +/** + * Ceiling on the fork graph one request will walk. Fork trees are shallow by construction — a + * workspace has at most one parent — so this only ever trips on a pathological org, where + * truncating beats timing out the settings page. + */ +const MAX_FOREST_NODES = 400 + +/** Ceiling on BFS rounds, so a cycle introduced by bad data cannot spin the walk forever. */ +const MAX_FOREST_HOPS = 32 + +/** + * Resource types that are never user-mappable, excluded from the edge's mapping counts: workflow + * and workflow-publishing-server rows are system-managed identity, and a document rides its + * parent knowledge base rather than being mapped on its own. + */ +const NON_MAPPABLE_RESOURCE_TYPES = [ + 'workflow', + 'workflow_mcp_server', + 'knowledge_document', +] as const + +interface ForestRow { + id: string + name: string + color: string | null + logoUrl: string | null + organizationId: string | null + parentId: string | null + createdAt: Date +} + +const WORKSPACE_COLUMNS = { + id: workspace.id, + name: workspace.name, + color: workspace.color, + logoUrl: workspace.logoUrl, + organizationId: workspace.organizationId, + parentId: workspace.forkedFromWorkspaceId, + createdAt: workspace.createdAt, +} as const + +/** + * Every live workspace connected to `seedIds` through fork edges, walking BOTH directions: + * a seed's ancestors (so a fork sees the parent it came from) and its descendants (so a parent + * sees the whole tree below it), transitively. + * + * Nodes the viewer cannot access are included deliberately — a chain broken by one inaccessible + * link would render as two unrelated trees, and the row is what makes Disconnect reachable when + * the other side is gone. + */ +async function walkForkGraph(seedIds: string[]): Promise> { + const nodes = new Map() + let frontier = seedIds + + for (let hop = 0; hop < MAX_FOREST_HOPS && frontier.length > 0; hop++) { + const pending = frontier.filter((id) => !nodes.has(id)) + if (pending.length === 0) break + + const rows = await db + .select(WORKSPACE_COLUMNS) + .from(workspace) + .where(and(inArray(workspace.id, pending), isNull(workspace.archivedAt))) + for (const row of rows) nodes.set(row.id, row) + if (rows.length === 0 || nodes.size >= MAX_FOREST_NODES) break + + const children = await db + .select({ id: workspace.id }) + .from(workspace) + .where( + and( + inArray( + workspace.forkedFromWorkspaceId, + rows.map((row) => row.id) + ), + isNull(workspace.archivedAt) + ) + ) + + const next = new Set() + for (const row of rows) { + if (row.parentId && !nodes.has(row.parentId)) next.add(row.parentId) + } + for (const child of children) { + if (!nodes.has(child.id)) next.add(child.id) + } + frontier = Array.from(next) + } + + return nodes +} + +/** Stored-mapping tallies per edge, keyed by the edge's child workspace id. */ +async function loadEdgeMappingCounts( + childIds: string[] +): Promise> { + if (childIds.length === 0) return new Map() + const rows = await db + .select({ + childWorkspaceId: workspaceForkResourceMap.childWorkspaceId, + mapped: sql`count(*) filter (where ${workspaceForkResourceMap.childResourceId} is not null)`, + unmapped: sql`count(*) filter (where ${workspaceForkResourceMap.childResourceId} is null)`, + }) + .from(workspaceForkResourceMap) + .where( + and( + inArray(workspaceForkResourceMap.childWorkspaceId, childIds), + notInArray(workspaceForkResourceMap.resourceType, [...NON_MAPPABLE_RESOURCE_TYPES]) + ) + ) + .groupBy(workspaceForkResourceMap.childWorkspaceId) + + return new Map( + rows.map((row) => [ + row.childWorkspaceId, + { mapped: Number(row.mapped), unmapped: Number(row.unmapped) }, + ]) + ) +} + +/** + * The newest promote run per edge and per target, in one read. + * + * An edge's runs are exactly the rows carrying its child id (a push from the child targets the + * parent, a pull into the child targets the child), which is what makes `lastSyncAt` an edge fact + * while the undo point stays a target fact. + */ +async function loadPromoteRuns(nodeIds: string[]): Promise<{ + lastSyncByChild: Map + undoByTarget: Map +}> { + const lastSyncByChild = new Map() + const undoByTarget = new Map() + if (nodeIds.length === 0) return { lastSyncByChild, undoByTarget } + + const rows = await db + .select({ + childWorkspaceId: workspaceForkPromoteRun.childWorkspaceId, + targetWorkspaceId: workspaceForkPromoteRun.targetWorkspaceId, + sourceWorkspaceId: workspaceForkPromoteRun.sourceWorkspaceId, + direction: workspaceForkPromoteRun.direction, + createdAt: workspaceForkPromoteRun.createdAt, + }) + .from(workspaceForkPromoteRun) + .where(inArray(workspaceForkPromoteRun.childWorkspaceId, nodeIds)) + .orderBy(workspaceForkPromoteRun.createdAt) + + // Ascending order, so each later row simply overwrites — the last write per key wins, which is + // the newest run for that edge and for that target. + for (const row of rows) { + lastSyncByChild.set(row.childWorkspaceId, row.createdAt) + undoByTarget.set(row.targetWorkspaceId, { + sourceWorkspaceId: row.sourceWorkspaceId, + direction: row.direction, + }) + } + return { lastSyncByChild, undoByTarget } +} + +/** Deployed, unarchived workflow counts per workspace — the only workflows forking ever carries. */ +async function loadDeployedWorkflowCounts(nodeIds: string[]): Promise> { + if (nodeIds.length === 0) return new Map() + const rows = await db + .select({ workspaceId: workflow.workspaceId, total: sql`count(*)` }) + .from(workflow) + .where( + and( + inArray(workflow.workspaceId, nodeIds), + eq(workflow.isDeployed, true), + isNull(workflow.archivedAt) + ) + ) + .groupBy(workflow.workspaceId) + + return new Map( + rows.flatMap((row) => (row.workspaceId ? [[row.workspaceId, Number(row.total)]] : [])) + ) +} + +/** Roots first, then each subtree depth-first, siblings newest-first (matching the fork list). */ +function orderDepthFirst(nodes: Map): ForestRow[] { + const childrenByParent = new Map() + for (const node of nodes.values()) { + // A parent outside the walked set makes this node a root of what the viewer can see. + const parentKey = node.parentId && nodes.has(node.parentId) ? node.parentId : null + const siblings = childrenByParent.get(parentKey) + if (siblings) siblings.push(node) + else childrenByParent.set(parentKey, [node]) + } + for (const siblings of childrenByParent.values()) { + siblings.sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() || a.name.localeCompare(b.name) + ) + } + + const ordered: ForestRow[] = [] + const visit = (node: ForestRow) => { + ordered.push(node) + for (const child of childrenByParent.get(node.id) ?? []) visit(child) + } + for (const root of childrenByParent.get(null) ?? []) visit(root) + return ordered +} + +interface ForkForestParams { + /** The workspace the console is open in. Always listed, even with no fork edges of its own. */ + anchorWorkspaceId: string + viewerId: string + /** Workspaces the viewer administers, seeding the walk. */ + manageableWorkspaceIds: string[] +} + +/** + * Every fork lineage the viewer can reach from the workspace they are standing in, as flat + * depth-first rows. + * + * Seeded from the workspaces the viewer administers rather than from the anchor alone: forking is + * an admin operation, so those are exactly the trees they can act on, and the console's whole + * purpose is to show them together instead of one workspace at a time. A workspace with no fork + * edges is dropped — it has nothing to say on this page — except the anchor, which always appears + * so the viewer can see where they are standing. + */ +export async function getForkForest(params: ForkForestParams): Promise { + const { anchorWorkspaceId, viewerId, manageableWorkspaceIds } = params + + const seeds = manageableWorkspaceIds.includes(anchorWorkspaceId) + ? manageableWorkspaceIds + : [anchorWorkspaceId, ...manageableWorkspaceIds] + const walked = await walkForkGraph(seeds) + + const parentIds = new Set() + for (const node of walked.values()) { + if (node.parentId) parentIds.add(node.parentId) + } + // A workspace earns a row by participating in a fork edge — as a child, or as the parent of a + // node in the walk. The anchor is exempt so the page is never blank for a fork-free workspace. + for (const [id, node] of walked) { + if (id === anchorWorkspaceId || node.parentId !== null || parentIds.has(id)) continue + walked.delete(id) + } + + const ordered = orderDepthFirst(walked) + const nodeIds = ordered.map((node) => node.id) + const childIds = ordered.flatMap((node) => (node.parentId ? [node.id] : [])) + + const [mappingCounts, promoteRuns, deployedCounts] = await Promise.all([ + loadEdgeMappingCounts(childIds), + loadPromoteRuns(nodeIds), + loadDeployedWorkflowCounts(nodeIds), + ]) + + const manageable = new Set(manageableWorkspaceIds) + // Admin already implies access, so only the nodes the viewer does NOT administer need a + // permission read — usually the inaccessible links a chain was widened to include. + const accessChecks = await Promise.all( + ordered.map(async (node) => { + if (manageable.has(node.id)) return true + const permission = await getEffectiveWorkspacePermission(viewerId, node) + return permission !== null + }) + ) + + const nameById = new Map(ordered.map((node) => [node.id, node.name])) + + return ordered.map((node, index) => { + const undo = promoteRuns.undoByTarget.get(node.id) + const undoableRun: ForkUndoableRun | null = undo + ? { + otherWorkspaceId: undo.sourceWorkspaceId, + otherName: nameById.get(undo.sourceWorkspaceId) ?? 'workspace', + direction: undo.direction, + } + : null + const counts = mappingCounts.get(node.id) ?? { mapped: 0, unmapped: 0 } + const lastSync = promoteRuns.lastSyncByChild.get(node.id) ?? null + + return { + id: node.id, + name: node.name, + color: node.color, + logoUrl: node.logoUrl, + organizationId: node.organizationId, + parentId: node.parentId && walked.has(node.parentId) ? node.parentId : null, + createdAt: node.createdAt.toISOString(), + viewerAccessible: accessChecks[index], + viewerCanAdmin: manageable.has(node.id), + deployedWorkflowCount: deployedCounts.get(node.id) ?? 0, + edge: node.parentId + ? { + mapped: counts.mapped, + unmapped: counts.unmapped, + lastSyncAt: lastSync ? lastSync.toISOString() : null, + undoableRun, + } + : null, + } + }) +} diff --git a/apps/sim/ee/workspace-forking/lib/lineage/lineage.ts b/apps/sim/ee/workspace-forking/lib/lineage/lineage.ts index c7449df0082..adceb6b8032 100644 --- a/apps/sim/ee/workspace-forking/lib/lineage/lineage.ts +++ b/apps/sim/ee/workspace-forking/lib/lineage/lineage.ts @@ -1,18 +1,8 @@ import { db } from '@sim/db' import { workspace } from '@sim/db/schema' -import { and, desc, eq, isNull, sql } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -export interface ForkLineageNode { - id: string - name: string - organizationId: string | null -} - -export interface ForkLineageChild extends ForkLineageNode { - createdAt: Date -} - export interface ForkEdge { childWorkspaceId: string parentWorkspaceId: string @@ -22,7 +12,7 @@ export interface ForkEdge { * The parent workspace id a fork was created from, or null when the workspace * is not a fork (or has been archived). */ -export async function getForkParentId(workspaceId: string): Promise { +async function getForkParentId(workspaceId: string): Promise { const [row] = await db .select({ parentId: workspace.forkedFromWorkspaceId }) .from(workspace) @@ -31,39 +21,6 @@ export async function getForkParentId(workspaceId: string): Promise { - const parentId = await getForkParentId(workspaceId) - if (!parentId) return null - const [row] = await db - .select({ - id: workspace.id, - name: workspace.name, - organizationId: workspace.organizationId, - }) - .from(workspace) - .where(and(eq(workspace.id, parentId), isNull(workspace.archivedAt))) - .limit(1) - return row ?? null -} - -/** - * The live (non-archived) forks created from this workspace, newest first, for - * the Forks settings page's read-only fork list. - */ -export async function getForkChildren(workspaceId: string): Promise { - return db - .select({ - id: workspace.id, - name: workspace.name, - organizationId: workspace.organizationId, - createdAt: workspace.createdAt, - }) - .from(workspace) - .where(and(eq(workspace.forkedFromWorkspaceId, workspaceId), isNull(workspace.archivedAt))) - .orderBy(desc(workspace.createdAt)) -} - /** * Resolve the strict fork edge between two workspaces, identifying which is the * child (the one whose `forkedFromWorkspaceId` points at the other). Returns diff --git a/apps/sim/ee/workspace-forking/lib/mapping/matrix.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/matrix.test.ts new file mode 100644 index 00000000000..a92ebca0437 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/mapping/matrix.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildForkMatrixChains, + type ForkMatrixColumn, + type ForkMatrixMappingRow, +} from '@/ee/workspace-forking/lib/mapping/matrix' + +/** sandbox → uat → prod, the shape a staged lineage takes. */ +const CHAIN: ForkMatrixColumn[] = [ + { id: 'sb', parentId: null }, + { id: 'uat', parentId: 'sb' }, + { id: 'prod', parentId: 'uat' }, +] + +const row = ( + childWorkspaceId: string, + parentResourceId: string, + childResourceId: string | null, + resourceType: ForkMatrixMappingRow['resourceType'] = 'env_var' +): ForkMatrixMappingRow => ({ childWorkspaceId, resourceType, parentResourceId, childResourceId }) + +describe('buildForkMatrixChains', () => { + it('composes per-edge rows into one chain across the lineage', () => { + const chains = buildForkMatrixChains(CHAIN, [ + row('uat', 'API_KEY', 'API_KEY_UAT'), + row('prod', 'API_KEY_UAT', 'API_KEY_PROD'), + ]) + + expect(chains).toHaveLength(1) + expect(chains[0].originWorkspaceId).toBe('sb') + expect(Object.fromEntries(chains[0].steps)).toEqual({ + sb: 'API_KEY', + uat: 'API_KEY_UAT', + prod: 'API_KEY_PROD', + }) + }) + + it('starts a chain at the shallowest workspace that knows the resource', () => { + // Nothing maps INTO `LOCAL_ONLY` in uat, so the chain begins there rather than being grafted + // onto whatever sandbox happens to call a resource by the same name. + const chains = buildForkMatrixChains(CHAIN, [row('prod', 'LOCAL_ONLY', 'LOCAL_PROD')]) + + expect(chains).toHaveLength(1) + expect(chains[0].originWorkspaceId).toBe('uat') + expect(chains[0].steps.get('sb')).toBeUndefined() + }) + + it('keeps two same-named resources apart when they start in different workspaces', () => { + const chains = buildForkMatrixChains(CHAIN, [ + row('uat', 'TOKEN', 'TOKEN_UAT'), + row('prod', 'OTHER', 'OTHER_PROD'), + ]) + expect(chains.map((chain) => chain.originResourceId).sort()).toEqual(['OTHER', 'TOKEN']) + }) + + it('gives a downstream workspace an empty cell when no row exists yet', () => { + const chains = buildForkMatrixChains(CHAIN, [row('uat', 'API_KEY', 'API_KEY_UAT')]) + // uat resolved, so prod still deserves a cell — that is where the mapping gets created. + expect(chains[0].steps.get('prod')).toBeNull() + }) + + it('stops descending once a chain maps to nothing, since there is no source to key on', () => { + const chains = buildForkMatrixChains(CHAIN, [row('uat', 'API_KEY', null)]) + expect(chains[0].steps.get('uat')).toBeNull() + expect(chains[0].steps.has('prod')).toBe(false) + }) + + it('spans a branch, giving one chain a cell in every fork of the same parent', () => { + const branched: ForkMatrixColumn[] = [ + { id: 'root', parentId: null }, + { id: 'left', parentId: 'root' }, + { id: 'right', parentId: 'root' }, + ] + const chains = buildForkMatrixChains(branched, [ + row('left', 'KEY', 'KEY_LEFT'), + row('right', 'KEY', 'KEY_RIGHT'), + ]) + + expect(chains).toHaveLength(1) + expect(Object.fromEntries(chains[0].steps)).toEqual({ + root: 'KEY', + left: 'KEY_LEFT', + right: 'KEY_RIGHT', + }) + }) + + it('keeps chains of different resource types separate even on the same ids', () => { + const chains = buildForkMatrixChains(CHAIN, [ + row('uat', 'shared', 'shared-uat', 'env_var'), + row('uat', 'shared', 'shared-uat-table', 'table'), + ]) + expect(chains).toHaveLength(2) + expect(chains.map((chain) => chain.resourceType).sort()).toEqual(['env_var', 'table']) + }) + + it('ignores rows whose child workspace is not a column of this matrix', () => { + const chains = buildForkMatrixChains( + [{ id: 'sb', parentId: null }], + [row('elsewhere', 'API_KEY', 'API_KEY_X')] + ) + expect(chains).toEqual([]) + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/mapping/matrix.ts b/apps/sim/ee/workspace-forking/lib/mapping/matrix.ts new file mode 100644 index 00000000000..f04b4c56d68 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/mapping/matrix.ts @@ -0,0 +1,258 @@ +import { db } from '@sim/db' +import { workspaceForkResourceMap } from '@sim/db/schema' +import { and, inArray, notInArray } from 'drizzle-orm' +import type { + ForkMappableResourceType, + ForkMappingCandidate, + ForkMatrixCell, + ForkMatrixRow, +} from '@/lib/api/contracts/workspace-fork' +import { forkMappableResourceTypeSchema } from '@/lib/api/contracts/workspace-fork' +import { resourceTypeToForkKind } from '@/ee/workspace-forking/lib/mapping/mapping-store' +import { + CANDIDATE_LIMIT, + listForkResourceCandidates, + loadForkResourceLabels, +} from '@/ee/workspace-forking/lib/mapping/resources' +import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references' + +/** + * Ceiling on the mapping rows one matrix request reads. A lineage's rows scale with the distinct + * resources its workflows reference — tens to low hundreds per edge — so the cap only trips on an + * outlier, where a truncated matrix beats a timed-out page. + */ +const MAX_MATRIX_MAPPING_ROWS = 8000 + +/** Resource types that are never user-mappable, so they never become a matrix row. */ +const NON_MAPPABLE_RESOURCE_TYPES = [ + 'workflow', + 'workflow_mcp_server', + 'knowledge_document', +] as const + +const MAPPABLE_RESOURCE_TYPES: ReadonlySet = new Set(forkMappableResourceTypeSchema.options) + +/** One workspace in the matrix's column order, with the edge it hangs off. */ +export interface ForkMatrixColumn { + id: string + parentId: string | null +} + +/** One edge's mapping row, oriented parent resource to child resource. */ +export interface ForkMatrixMappingRow { + childWorkspaceId: string + resourceType: ForkMappableResourceType + parentResourceId: string + childResourceId: string | null +} + +/** One resource followed down a lineage. */ +export interface ForkMatrixChain { + resourceType: ForkMappableResourceType + /** The shallowest workspace that knows this resource — nothing maps into it. */ + originWorkspaceId: string + originResourceId: string + /** Resource id per workspace the chain reaches; null where the edge maps to nothing yet. */ + steps: Map +} + +/** `${workspaceId}:${resourceType}:${resourceId}` — a resource's position in the lineage. */ +const positionKey = (workspaceId: string, resourceType: string, resourceId: string) => + `${workspaceId}:${resourceType}:${resourceId}` + +async function loadMatrixMappingRows(childIds: string[]): Promise { + if (childIds.length === 0) return [] + const rows = await db + .select({ + childWorkspaceId: workspaceForkResourceMap.childWorkspaceId, + resourceType: workspaceForkResourceMap.resourceType, + parentResourceId: workspaceForkResourceMap.parentResourceId, + childResourceId: workspaceForkResourceMap.childResourceId, + }) + .from(workspaceForkResourceMap) + .where( + and( + inArray(workspaceForkResourceMap.childWorkspaceId, childIds), + notInArray(workspaceForkResourceMap.resourceType, [...NON_MAPPABLE_RESOURCE_TYPES]) + ) + ) + .limit(MAX_MATRIX_MAPPING_ROWS) + + // The query already excludes the non-mappable types; narrowing here is what turns that into a + // type the rest of the module can trust without a cast. + return rows.flatMap((row) => + MAPPABLE_RESOURCE_TYPES.has(row.resourceType) ? [row as ForkMatrixMappingRow] : [] + ) +} + +/** + * Compose per-edge mapping rows into chains, each walked down the tree from its origin. + * + * A chain STARTS at a resource nothing maps into — the shallowest workspace that knows it. That is + * what keeps two unrelated resources sharing a name apart, and what lets a resource introduced + * halfway down a lineage own its row rather than being grafted onto the root's. + * + * Pure over (columns, rows) so the composition is testable without a database. + */ +export function buildForkMatrixChains( + columns: ForkMatrixColumn[], + rows: ForkMatrixMappingRow[] +): ForkMatrixChain[] { + const parentByWorkspace = new Map(columns.map((column) => [column.id, column.parentId])) + const childWorkspaces = new Map() + for (const column of columns) { + if (!column.parentId) continue + const siblings = childWorkspaces.get(column.parentId) + if (siblings) siblings.push(column.id) + else childWorkspaces.set(column.parentId, [column.id]) + } + + /** Parent position to each child edge's landing, so a chain can be walked downward. */ + const downward = new Map>() + /** Every position some edge maps INTO — i.e. every position that is not an origin. */ + const mappedInto = new Set() + + for (const row of rows) { + const parentWorkspaceId = parentByWorkspace.get(row.childWorkspaceId) + if (!parentWorkspaceId) continue + const from = positionKey(parentWorkspaceId, row.resourceType, row.parentResourceId) + const step = { workspaceId: row.childWorkspaceId, resourceId: row.childResourceId } + const steps = downward.get(from) + if (steps) steps.push(step) + else downward.set(from, [step]) + if (row.childResourceId) { + mappedInto.add(positionKey(row.childWorkspaceId, row.resourceType, row.childResourceId)) + } + } + + const chains: ForkMatrixChain[] = [] + const seenOrigins = new Set() + + for (const row of rows) { + const parentWorkspaceId = parentByWorkspace.get(row.childWorkspaceId) + if (!parentWorkspaceId) continue + const origin = positionKey(parentWorkspaceId, row.resourceType, row.parentResourceId) + if (mappedInto.has(origin) || seenOrigins.has(origin)) continue + seenOrigins.add(origin) + + const steps = new Map([[parentWorkspaceId, row.parentResourceId]]) + const visit = (workspaceId: string, resourceId: string) => { + for (const step of downward.get(positionKey(workspaceId, row.resourceType, resourceId)) ?? + []) { + // The fork graph is a tree, so a workspace is reached once; the guard only protects + // against malformed data pointing a chain back at a workspace it already passed. + if (steps.has(step.workspaceId) && steps.get(step.workspaceId) === step.resourceId) continue + steps.set(step.workspaceId, step.resourceId) + if (step.resourceId) visit(step.workspaceId, step.resourceId) + } + // A child workspace with no row still gets a cell, so the matrix offers the mapping that + // does not exist yet instead of rendering a silent gap. + for (const childId of childWorkspaces.get(workspaceId) ?? []) { + if (!steps.has(childId)) steps.set(childId, null) + } + } + visit(parentWorkspaceId, row.parentResourceId) + + chains.push({ + resourceType: row.resourceType, + originWorkspaceId: parentWorkspaceId, + originResourceId: row.parentResourceId, + steps, + }) + } + + return chains +} + +export interface ForkMatrixData { + rows: ForkMatrixRow[] + /** Mapping targets a cell may pick, keyed by workspace id then by remap kind. */ + candidates: Record> + /** Workspaces whose candidate list hit the per-kind cap, so their pickers are partial. */ + candidatesTruncated: string[] +} + +/** + * The mappings matrix for one lineage: every resource chain across its workspaces, each cell + * labelled from the workspace it lands in, plus the targets a cell may be re-pointed at. + * + * Labels come from an exact-id lookup rather than the capped candidate list, so a workspace past + * the candidate cap still resolves a live resource's name — an id that fails to resolve therefore + * means exactly one thing, which is what `missing` reports. + */ +export async function getForkMatrix(columns: ForkMatrixColumn[]): Promise { + const childIds = columns.flatMap((column) => (column.parentId ? [column.id] : [])) + const chains = buildForkMatrixChains(columns, await loadMatrixMappingRows(childIds)) + + // Ids to resolve per workspace, grouped by remap kind, so each workspace takes one bounded read + // rather than one per cell. + const idsByWorkspace = new Map>>>() + for (const chain of chains) { + const kind = resourceTypeToForkKind(chain.resourceType) + if (!kind) continue + for (const [workspaceId, resourceId] of chain.steps) { + if (!resourceId) continue + let byKind = idsByWorkspace.get(workspaceId) + if (!byKind) { + byKind = {} + idsByWorkspace.set(workspaceId, byKind) + } + const bucket = byKind[kind] ?? new Set() + bucket.add(resourceId) + byKind[kind] = bucket + } + } + + const [labelEntries, candidateEntries] = await Promise.all([ + Promise.all( + columns.map(async (column) => { + const idsByKind = idsByWorkspace.get(column.id) + const labels = idsByKind ? await loadForkResourceLabels(db, column.id, idsByKind) : {} + return [column.id, labels] as const + }) + ), + Promise.all( + columns.map( + async (column) => [column.id, await listForkResourceCandidates(db, column.id)] as const + ) + ), + ]) + + const labelsByWorkspace = new Map(labelEntries) + const candidates: Record> = {} + const candidatesTruncated: string[] = [] + for (const [workspaceId, byKind] of candidateEntries) { + candidates[workspaceId] = byKind + if (Object.values(byKind).some((list) => list.length >= CANDIDATE_LIMIT)) { + candidatesTruncated.push(workspaceId) + } + } + + const rows: ForkMatrixRow[] = [] + for (const chain of chains) { + const kind = resourceTypeToForkKind(chain.resourceType) + if (!kind) continue + const cells: Record = {} + for (const [workspaceId, resourceId] of chain.steps) { + const label = resourceId + ? (labelsByWorkspace.get(workspaceId)?.[kind]?.get(resourceId) ?? null) + : null + cells[workspaceId] = { + resourceId, + label: resourceId ? (label ?? resourceId) : null, + missing: resourceId !== null && label === null, + } + } + rows.push({ + key: positionKey(chain.originWorkspaceId, chain.resourceType, chain.originResourceId), + resourceType: chain.resourceType, + kind, + originWorkspaceId: chain.originWorkspaceId, + label: cells[chain.originWorkspaceId]?.label ?? chain.originResourceId, + cells, + }) + } + + rows.sort((a, b) => a.kind.localeCompare(b.kind) || a.label.localeCompare(b.label)) + return { rows, candidates, candidatesTruncated } +} diff --git a/apps/sim/ee/workspace-forking/search-params.ts b/apps/sim/ee/workspace-forking/search-params.ts new file mode 100644 index 00000000000..4da854ee6bb --- /dev/null +++ b/apps/sim/ee/workspace-forking/search-params.ts @@ -0,0 +1,105 @@ +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import type { ForkActivityFilter, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork' + +/** + * URL view-state for the Forks console, co-located with the feature so the client hooks and any + * server read share one definition. + * + * Everything here is shareable: a copied link reopens the same tab, the same lineage, the same + * filters, and the same edge — which is the point of a console that spans several workspaces. + * + * The literal lists below are restated rather than derived from their Zod schemas because client + * params must not import Zod, but each one `satisfies` the contract type it mirrors — so adding a + * kind on the wire and forgetting it here fails to compile. + */ + +/** The console's four peer views. */ +export const FORK_TABS = ['lineage', 'mappings', 'excluded', 'activity'] as const +export type ForkTab = (typeof FORK_TABS)[number] + +/** Resource kinds the mappings matrix can narrow to. `all` leaves it unfiltered. */ +export const FORK_RESOURCE_FILTERS = [ + 'all', + 'credential', + 'env-var', + 'table', + 'knowledge-base', + 'file', + 'mcp-server', + 'custom-tool', + 'skill', +] as const satisfies readonly ('all' | ForkMappingEntry['kind'])[] +export type ForkResourceFilter = (typeof FORK_RESOURCE_FILTERS)[number] + +/** Fork events the Activity view can narrow to. */ +export const FORK_EVENT_FILTERS = [ + 'all', + 'fork_content_copy', + 'fork_sync', + 'fork_rollback', +] as const satisfies readonly ForkActivityFilter[] + +/** Which of the console's views is showing. */ +export const forkTabParam = { + key: 'fork-tab', + parser: parseAsStringLiteral(FORK_TABS).withDefault('lineage'), +} as const + +/** Switching views is in-place state, not a destination — replace, and clear at the default. */ +export const forkTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * The edge whose sync detail is open, named by its CHILD workspace id — an edge has exactly one + * child, so that id identifies it without a compound key. + */ +export const forkEdgeIdParam = { + key: 'fork-edge', + parser: parseAsString, +} as const + +/** Opening an edge is a destination → push to history; clear on close. */ +export const forkEdgeIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + +/** + * Sync direction on the open edge. Deliberately nullable rather than defaulted to `push`: the + * detail derives its own default from which side of the edge the viewer is standing on, and a + * parser default would overwrite that before the workspace is known. + */ +export const forkDirectionParam = { + key: 'fork-direction', + parser: parseAsStringLiteral(['push', 'pull'] as const), +} as const + +/** Toggling direction is in-place view state → replace history. */ +export const forkDirectionUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * The lineage the mappings matrix lays out, named by its root workspace id. Nullable because the + * default is derived — the root of whichever lineage the current workspace belongs to. + */ +export const forkRootIdParam = { + key: 'fork-root', + parser: parseAsString, +} as const + +/** Grouped filter state for the console's list views. */ +export const forkFilterParsers = { + resource: parseAsStringLiteral(FORK_RESOURCE_FILTERS).withDefault('all'), + event: parseAsStringLiteral(FORK_EVENT_FILTERS).withDefault('all'), +} as const + +/** Filter view-state: clean URLs, no back-stack churn. */ +export const forkFilterUrlKeys = { + history: 'replace', + clearOnDefault: true, + urlKeys: { resource: 'fork-resource', event: 'fork-event' }, +} as const diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index bbd1fcf69ce..92540b14ea4 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -295,7 +295,20 @@ export const rosterPendingInvitationSchema = z.object({ export const organizationRosterSchema = z.object({ members: z.array(rosterMemberSchema), pendingInvitations: z.array(rosterPendingInvitationSchema), - workspaces: z.array(z.object({ id: z.string(), name: z.string() })), + workspaces: z.array( + z.object({ + id: z.string(), + name: z.string(), + /** + * The workspace's logo and accent colour, so its tile reads the same here + * as in the sidebar's workspace header — which prefers the logo and falls + * back to a monogram on the colour. Both nullable; consumers fall back to + * the brand accent. + */ + logoUrl: z.string().nullable(), + color: z.string().nullable(), + }) + ), }) export const organizationMemberUsageSchema = z @@ -308,7 +321,20 @@ export const organizationMemberUsageSchema = z userName: z.string().nullable(), userEmail: z.string().nullable(), currentPeriodCost: numericResponseSchema.nullable().optional(), + /** + * The member's PERSONAL subscription cap (`user_stats.current_usage_limit`). + * Org-scoped members carry `null` here by design, so this is not the cap that + * governs their usage inside the organization — see + * {@link organizationMemberUsageSchema.shape.organizationCreditLimit}. + */ currentUsageLimit: numericResponseSchema.nullable().optional(), + /** + * The per-member cap that actually governs usage in this organization's + * workspaces (`organization_member_usage_limit`, keyed by organization+user). + * `null` means no per-member cap — only the pooled organization limit applies. + * This is the value the Usage settings page reads and writes. + */ + organizationCreditLimit: numericResponseSchema.nullable().optional(), usageLimitUpdatedAt: z.string().nullable().optional(), billingPeriodStart: z.string().nullable().optional(), billingPeriodEnd: z.string().nullable().optional(), diff --git a/apps/sim/lib/api/contracts/workspace-fork.test.ts b/apps/sim/lib/api/contracts/workspace-fork.test.ts index b8defe69939..8e975949653 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.test.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.test.ts @@ -3,9 +3,9 @@ */ import { describe, expect, it } from 'vitest' import { - forkLineageChildSchema, - forkLineageNodeSchema, + forkForestNodeSchema, forkMappableResourceTypeSchema, + forkMatrixRowSchema, getForkDiffContract, getWorkspaceBackgroundWorkQuerySchema, promoteForkBodySchema, @@ -39,25 +39,79 @@ describe('forkMappableResourceTypeSchema', () => { }) }) -describe('forkLineageNodeSchema', () => { - const baseNode = { id: 'ws-1', name: 'Parent', organizationId: null } +describe('forkForestNodeSchema', () => { + const rootNode = { + id: 'ws-1', + name: 'Root', + color: '#33C482', + logoUrl: null, + organizationId: null, + parentId: null, + createdAt: '2026-01-01T00:00:00.000Z', + viewerAccessible: true, + viewerCanAdmin: true, + deployedWorkflowCount: 2, + edge: null, + } - it('requires viewerAccessible on every node (both accessible and inaccessible parse)', () => { - expect(forkLineageNodeSchema.safeParse(baseNode).success).toBe(false) - expect(forkLineageNodeSchema.safeParse({ ...baseNode, viewerAccessible: true }).success).toBe( - true - ) - expect(forkLineageNodeSchema.safeParse({ ...baseNode, viewerAccessible: false }).success).toBe( - true - ) + it('requires both permission flags, so a row can never render un-gated by accident', () => { + const { viewerCanAdmin, ...withoutAdmin } = rootNode + expect(forkForestNodeSchema.safeParse(withoutAdmin).success).toBe(false) + const { viewerAccessible, ...withoutAccess } = rootNode + expect(forkForestNodeSchema.safeParse(withoutAccess).success).toBe(false) + expect(forkForestNodeSchema.safeParse(rootNode).success).toBe(true) }) - it('requires viewerAccessible on child nodes too', () => { - const child = { ...baseNode, createdAt: '2026-01-01T00:00:00.000Z' } - expect(forkLineageChildSchema.safeParse(child).success).toBe(false) - expect(forkLineageChildSchema.safeParse({ ...child, viewerAccessible: false }).success).toBe( - true - ) + it('carries no edge on a root and a full edge on a fork', () => { + expect(forkForestNodeSchema.parse(rootNode).edge).toBeNull() + const fork = { + ...rootNode, + id: 'ws-2', + parentId: 'ws-1', + edge: { + mapped: 3, + unmapped: 1, + lastSyncAt: '2026-01-02T00:00:00.000Z', + undoableRun: { otherWorkspaceId: 'ws-1', otherName: 'Root', direction: 'push' }, + }, + } + expect(forkForestNodeSchema.parse(fork).edge?.unmapped).toBe(1) + }) + + it('rejects an edge whose undoable run names an unknown direction', () => { + const fork = { + ...rootNode, + parentId: 'ws-1', + edge: { + mapped: 0, + unmapped: 0, + lastSyncAt: null, + undoableRun: { otherWorkspaceId: 'ws-1', otherName: 'Root', direction: 'sideways' }, + }, + } + expect(forkForestNodeSchema.safeParse(fork).success).toBe(false) + }) +}) + +describe('forkMatrixRowSchema', () => { + const row = { + key: 'ws-1:env_var:API_KEY', + resourceType: 'env_var', + kind: 'env-var', + originWorkspaceId: 'ws-1', + label: 'API_KEY', + cells: { + 'ws-1': { resourceId: 'API_KEY', label: 'API_KEY', missing: false }, + 'ws-2': { resourceId: null, label: null, missing: false }, + }, + } + + it('accepts a chain whose downstream cell is unmapped', () => { + expect(forkMatrixRowSchema.safeParse(row).success).toBe(true) + }) + + it('rejects a row typed as a system-managed resource, which is never user-mappable', () => { + expect(forkMatrixRowSchema.safeParse({ ...row, resourceType: 'workflow' }).success).toBe(false) }) }) diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index c861a0995d6..d8157ea31dc 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -78,49 +78,69 @@ export const forkCopyableKindSchema = z.enum([ ]) export type ForkCopyableKind = z.infer -export const forkLineageNodeSchema = z.object({ +/** The most recent undoable promote into a workspace, for the rollback affordance. */ +export const forkUndoableRunSchema = z.object({ + otherWorkspaceId: z.string(), + otherName: z.string(), + direction: forkDirectionSchema, +}) +export type ForkUndoableRun = z.output + +/** + * Stored-mapping and sync state for a node's PARENT edge, so the console can rank a whole + * lineage without running a diff per edge (which would mean scanning every deployed workflow on + * page load). `mapped`/`unmapped` therefore count persisted mapping rows, not live references - + * the edge's sync page remains the authority on what actually blocks a sync. + */ +export const forkForestEdgeSchema = z.object({ + mapped: z.number().int(), + unmapped: z.number().int(), + /** When this edge last synced in either direction (ISO timestamp), or null if never. */ + lastSyncAt: z.string().nullable(), + /** The most recent undoable promote INTO this node, when there is one. */ + undoableRun: forkUndoableRunSchema.nullable(), +}) + +/** + * One workspace in a fork tree the viewer can reach. + * + * `viewerAccessible` is read-or-higher access (explicit or org-derived) and gates opening the + * workspace; `viewerCanAdmin` additionally gates every fork operation, because a node is listed + * to any admin of the anchor workspace, who may hold nothing on the other side of an edge. + */ +export const forkForestNodeSchema = z.object({ id: z.string(), name: z.string(), + color: z.string().nullable(), + logoUrl: z.string().nullable(), organizationId: z.string().nullable(), - /** - * Whether the viewer has any access (read or higher, explicit or org-derived) to this - * lineage workspace. Drives the Forks page's row-action gating - lineage rows are visible - * to any admin of the CURRENT workspace, who may hold no access to the other side. - */ - viewerAccessible: z.boolean(), -}) - -/** A live fork of this workspace, listed read-only on the Forks settings page. */ -export const forkLineageChildSchema = forkLineageNodeSchema.extend({ - /** When the fork was created (ISO timestamp). */ + /** The workspace this one was forked from, or null for a root. */ + parentId: z.string().nullable(), + /** When the workspace was created (ISO timestamp). */ createdAt: z.string(), + viewerAccessible: z.boolean(), + viewerCanAdmin: z.boolean(), + /** Deployed, unarchived workflows — the only ones a fork or a sync ever carries. */ + deployedWorkflowCount: z.number().int(), + /** Parent-edge state; null on a root, which has no edge. */ + edge: forkForestEdgeSchema.nullable(), }) +export type ForkForestNode = z.output -export const getForkLineageContract = defineRouteContract({ +export const getForkForestContract = defineRouteContract({ method: 'GET', - path: '/api/workspaces/[id]/fork/lineage', + path: '/api/workspaces/[id]/fork/forest', params: workspaceIdParamsSchema, response: { mode: 'json', schema: z.object({ workspaceId: z.string(), - parent: forkLineageNodeSchema.nullable(), - /** Live forks created from this workspace, newest first. */ - children: z.array(forkLineageChildSchema), - /** The most recent undoable promote into this workspace, for the rollback UI. */ - undoableRun: z - .object({ - otherWorkspaceId: z.string(), - otherName: z.string(), - direction: forkDirectionSchema, - }) - .nullable(), + /** Every workspace in a fork tree reachable from this one, roots first then depth-first. */ + nodes: z.array(forkForestNodeSchema), }), }, }) -export type ForkLineageNodeApi = z.output -export type ForkLineageChildApi = z.output -export type GetForkLineageResponse = z.output +export type GetForkForestResponse = z.output const forkResourceIdList = z.array(nonEmptyIdSchema).max(2000).optional() @@ -202,6 +222,7 @@ export const forkMappingCandidateSchema = z.object({ label: z.string(), providerId: z.string().optional(), }) +export type ForkMappingCandidate = z.output export const forkMappingEntrySchema = z.object({ kind: forkRemapKindSchema, @@ -301,6 +322,77 @@ export const updateForkMappingContract = defineRouteContract({ }) export type UpdateForkMappingBody = z.input +/** One workspace column of the mappings matrix, in depth-first order under the chosen root. */ +export const forkMatrixWorkspaceSchema = z.object({ + id: z.string(), + name: z.string(), + color: z.string().nullable(), + logoUrl: z.string().nullable(), + /** The workspace this column was forked from; null on the root column. */ + parentId: z.string().nullable(), + /** Whether the viewer may edit the mapping on this column's parent edge. */ + viewerCanAdmin: z.boolean(), +}) +export type ForkMatrixWorkspace = z.output + +/** What one resource chain resolves to inside one workspace column. */ +export const forkMatrixCellSchema = z.object({ + /** The resource id in this workspace, or null where the chain has no mapping. */ + resourceId: z.string().nullable(), + /** Display name, falling back to the raw id when the resource no longer exists. */ + label: z.string().nullable(), + /** True when `resourceId` names a resource that is gone from this workspace. */ + missing: z.boolean(), +}) +export type ForkMatrixCell = z.output + +/** + * One resource followed across a lineage: the chain of `workspace_fork_resource_map` pairs that + * link a root resource to its counterpart in each descendant workspace. A chain is keyed by the + * workspace it starts in plus that workspace's resource id, so two unrelated resources that + * happen to share a name stay separate rows. + */ +export const forkMatrixRowSchema = z.object({ + key: z.string(), + resourceType: forkMappableResourceTypeSchema, + kind: forkRemapKindSchema, + /** The workspace the chain starts in — the shallowest column that resolves it. */ + originWorkspaceId: z.string(), + /** The chain's name, taken from its origin. */ + label: z.string(), + /** One entry per workspace the chain reaches, keyed by workspace id. */ + cells: z.record(z.string(), forkMatrixCellSchema), +}) +export type ForkMatrixRow = z.output + +export const getForkMatrixQuerySchema = z.object({ + /** Root of the lineage to lay out. Must be a workspace in the anchor's own fork tree. */ + rootId: workspaceIdSchema, +}) + +export const getForkMatrixContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/fork/matrix', + params: workspaceIdParamsSchema, + query: getForkMatrixQuerySchema, + response: { + mode: 'json', + schema: z.object({ + rootWorkspaceId: z.string(), + workspaces: z.array(forkMatrixWorkspaceSchema), + rows: z.array(forkMatrixRowSchema), + /** + * Mapping targets a cell may pick, keyed by workspace id then by remap kind. Capped per + * workspace like the mapping editor's own picker; `candidatesTruncated` names the + * workspaces whose list is partial. + */ + candidates: z.record(z.string(), z.record(z.string(), z.array(forkMappingCandidateSchema))), + candidatesTruncated: z.array(z.string()), + }), + }, +}) +export type GetForkMatrixResponse = z.output + export const forkUnmappedReferenceSchema = z.object({ kind: forkRemapKindSchema, sourceId: z.string(), @@ -804,6 +896,18 @@ export const backgroundWorkItemSchema = z.object({ completedAt: z.string().nullable(), }) export type BackgroundWorkMetadata = z.output +/** + * Fork events the Activity view can narrow to. `all` is the unfiltered feed; the rest name one + * job kind each, so the filter and the badge a row renders can never disagree. + */ +export const forkActivityFilterSchema = z.enum([ + 'all', + 'fork_content_copy', + 'fork_sync', + 'fork_rollback', +]) +export type ForkActivityFilter = z.output + /** Keyset pagination inputs, mirroring the audit log's (`auditLogsQuerySchema`). */ export const getWorkspaceBackgroundWorkQuerySchema = z.object({ /** Opaque cursor from a prior page's `nextCursor`; omit for the first page. */ @@ -812,6 +916,7 @@ export const getWorkspaceBackgroundWorkQuerySchema = z.object({ .string() .optional() .transform((value) => Math.min(Math.max(Number(value) || 50, 1), 100)), + kind: forkActivityFilterSchema.default('all'), }) export const getWorkspaceBackgroundWorkContract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index b68d1982623..4b74d046103 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -858,9 +858,15 @@ export async function maybeSendUsageThresholdEmail(params: { const upgradeCreditsLink = params.workspaceId ? `${baseUrl}${buildUpgradeHref(params.workspaceId, 'credits')}` : `${baseUrl}/workspace` + /** + * Organization billing is a workspace-plane section, so the link needs the + * workspace the usage happened in — the same id the upgrade link uses. + * Without one there is no organization-scoped page to reach, so personal + * billing is the honest fallback. + */ const billingSettingsLink = - params.scope === 'organization' && params.organizationId - ? `${baseUrl}/organization/${params.organizationId}/settings/billing` + params.scope === 'organization' && params.workspaceId + ? `${baseUrl}/workspace/${params.workspaceId}/settings/billing` : `${baseUrl}/account/settings/billing` // Check for 80% threshold crossing — used for paid users (budget warning) and free users (upgrade nudge) diff --git a/packages/emcn/src/components/chip/chip-chrome.ts b/packages/emcn/src/components/chip/chip-chrome.ts index fa8d4754819..3e8c7f58d37 100644 --- a/packages/emcn/src/components/chip/chip-chrome.ts +++ b/packages/emcn/src/components/chip/chip-chrome.ts @@ -1,12 +1,23 @@ /** The filled FILL (surface only, no border) — used by the borderless `filled` chip variant. */ export const chipFilledFillTokens = 'bg-[var(--surface-5)] dark:bg-[var(--surface-4)]' /** - * The filled surface WITH a `--border-1` border, for chip FIELDS ({@link ChipInput}, + * The neutral chip outline — a REAL CSS border, so it follows `--border-width` + * (a true hairline at 2dppx) and the `--border` colour token like every other + * line in the product. A box-shadow ring cannot follow `--border-width`, so it + * renders at a full device pixel beside hairline neighbours; see + * `.claude/rules/sim-styling.md` § "Line weight". + * + * Single source for the chip FIELDS ({@link chipFilledSurfaceTokens}), the pill + * TRIGGERS (`TRIGGER_BORDER_CLASS`), and the `border` chip variant. + */ +export const chipBorderClass = 'border border-[var(--border)]' +/** + * The filled surface WITH its border, for chip FIELDS ({@link ChipInput}, * {@link ChipTextarea}). The `filled` chip variant itself is borderless * ({@link chipFilledFillTokens}); pill triggers (`ChipDropdown`/`ChipSelect`/ * `ChipDatePicker`) opt into the border via `TRIGGER_BORDER_CLASS`. */ -export const chipFilledSurfaceTokens = `border border-[var(--border-1)] ${chipFilledFillTokens}` +export const chipFilledSurfaceTokens = `${chipBorderClass} ${chipFilledFillTokens}` /** * The primary (inverse) chip fill at rest — dark fill, inverse text, mirrored in * dark mode. `chipVariants`' `primary` variant composes this with its hover diff --git a/packages/emcn/src/components/chip/chip.tsx b/packages/emcn/src/components/chip/chip.tsx index aa80a5d225a..f6a29f7aeb4 100644 --- a/packages/emcn/src/components/chip/chip.tsx +++ b/packages/emcn/src/components/chip/chip.tsx @@ -12,6 +12,7 @@ import Link, { type LinkProps } from 'next/link' import { cn } from '../../lib/cn' import { chipActiveSurfaceClass, + chipBorderClass, chipContentIconClass, chipContentLabelClass, chipFilledFillTokens, @@ -33,10 +34,10 @@ import { * to get it (shadcn-style); never write `variant='default'`. Named variants: * `filled` (`--surface-5` light / `--surface-4` dark fill, `--surface-hover` hover) — a borderless surface reserved for * chip FIELDS/TRIGGERS ({@link ChipInput}/{@link ChipDropdown}/{@link ChipSelect}/{@link ChipDatePicker}), **never `Chip` - * itself**; those triggers add the `--border-1` outline themselves via `TRIGGER_BORDER_CLASS`; + * itself**; those triggers add the {@link chipBorderClass} outline themselves via `TRIGGER_BORDER_CLASS`; * `primary` (inverse surface), `destructive` (error-token surface), `border-shadow` (raised card-like surface), - * `border` (the `border-shadow` shadow ring on a transparent surface — an outline drawn purely via box-shadow, - * no CSS border, no fill). + * `border` (a flat {@link chipBorderClass} outline on a transparent surface — a real CSS border, so it stays a + * hairline beside the cards and dividers it sits among, rather than the raised box-shadow ring it used to draw). * `active` renders the default/filled chip in its selected state — `--surface-active`, held through hover. * `fullWidth` swaps `inline-flex` for block-level `flex`. * @@ -65,7 +66,7 @@ const chipVariants = cva( 'bg-[var(--text-error)] text-white hover-hover:text-white hover-hover:brightness-106', 'border-shadow': 'bg-[var(--surface-2)] shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-3)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)] dark:hover-hover:bg-[var(--surface-4)]', - border: `shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] ${chipHoverSurfaceClass} dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)]`, + border: `${chipBorderClass} ${chipHoverSurfaceClass}`, }, active: { true: '', false: '' }, fullWidth: { true: 'flex', false: 'inline-flex' }, @@ -197,12 +198,12 @@ const ChipLink = forwardRef(function ChipLink( }) /** - * 1px border applied to `filled` and default chip triggers to read as - * interactive form controls rather than static pills. Omitted on `primary`, - * `destructive`, and `border-shadow` variants which carry their own surface - * treatment. + * The neutral outline applied to `filled` and default chip triggers so they read + * as interactive form controls rather than static pills. Omitted on `primary`, + * `destructive`, and `border-shadow`, which carry their own surface treatment, + * and on `border`, which already draws {@link chipBorderClass} itself. */ -export const TRIGGER_BORDER_CLASS = 'border border-[var(--border-1)]' +export const TRIGGER_BORDER_CLASS = chipBorderClass export { Chip, ChipLink, chipVariants } export type { ChipLinkProps, ChipProps } diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx index 1d4b9cf85eb..cd30b034c32 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.test.tsx @@ -109,4 +109,73 @@ describe('menu row labels', () => { expect(row().querySelectorAll('span')).toHaveLength(1) }) + + /** + * Radix reads `disabled` off the Trigger, not off an `asChild` child — and a + * disabled ` + + ) + expect(trigger.disabled).toBe(true) + pointerDown(trigger) + expect(document.querySelector('[role="menu"]')).toBeNull() + }) + + it('stays closed when only the trigger is disabled, and disables the child too', () => { + const trigger = mountTrigger( + + + + ) + expect(trigger.disabled).toBe(true) + pointerDown(trigger) + expect(document.querySelector('[role="menu"]')).toBeNull() + }) + + it('still opens when nothing is disabled', () => { + const trigger = mountTrigger( + + + + ) + expect(trigger.disabled).toBe(false) + pointerDown(trigger) + expect(document.querySelector('[role="menu"]')).not.toBeNull() + }) + }) }) diff --git a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx index ee7555508d6..e2ecae2a586 100644 --- a/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx +++ b/packages/emcn/src/components/dropdown-menu/dropdown-menu.tsx @@ -141,7 +141,31 @@ function DropdownMenu({ return } -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +/** + * Radix decides whether to open from the Trigger's OWN `disabled`, and Chrome + * still dispatches `pointerdown` on a disabled `