Skip to content

Commit e6b7b92

Browse files
committed
improvement(admin): uupdate defaults for better UX
1 parent 0e4f0e7 commit e6b7b92

10 files changed

Lines changed: 367 additions & 110 deletions

File tree

apps/sim/app/api/v1/admin/dashboard/workspaces/[id]/move/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const POST = withRouteHandler(
2727
const data = await moveWorkspaceToOrganization({
2828
workspaceId: parsed.data.params.id,
2929
destinationOrganizationId: parsed.data.body.destinationOrganizationId,
30+
expectedOwnerId: parsed.data.body.expectedOwnerId,
3031
adminEmail: request.headers.get('x-admin-email') ?? 'admin-api@sim.ai',
3132
})
3233
return NextResponse.json({ data })

apps/sim/lib/admin/dashboard-credit-grant.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ vi.mock('@/lib/billing/organizations/seats', () => ({
5757
vi.mock('@/lib/billing/core/usage', () => ({
5858
syncUsageLimitsFromSubscription: mocks.syncUsageLimits,
5959
}))
60+
vi.mock('@/lib/workspaces/organization-workspaces', () => ({
61+
ownedAttachableWorkspacesWhere: vi.fn(() => undefined),
62+
}))
6063
vi.mock('@/lib/workspaces/admin-move', () => ({
6164
moveWorkspaceToOrganization: mocks.moveWorkspace,
6265
}))
@@ -211,6 +214,9 @@ describe('addDashboardOrganizationMember', () => {
211214
resetDbChainMock()
212215
mocks.billingSubscriptions = []
213216
mocks.idempotencyCalls = []
217+
mocks.ensureMembership.mockReset()
218+
mocks.transferMembership.mockReset()
219+
mocks.moveWorkspace.mockReset()
214220
})
215221

216222
it('rejects an existing member inside the transaction before touching their cap', async () => {
@@ -239,7 +245,50 @@ describe('addDashboardOrganizationMember', () => {
239245
expect(mocks.recordAudit).not.toHaveBeenCalled()
240246
})
241247

242-
it('uses the canonical transfer service and reports each selected personal workspace move', async () => {
248+
it('moves every selected workspace through the invitation-aware service after adding a member', async () => {
249+
queueTableRows(workspace, [{ id: 'workspace-1' }, { id: 'workspace-2' }])
250+
queueTableRows(subscription, [{ plan: 'enterprise' }])
251+
mocks.ensureMembership.mockResolvedValue({
252+
success: true,
253+
memberId: 'member-new',
254+
alreadyMember: false,
255+
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
256+
})
257+
mocks.moveWorkspace.mockResolvedValue({})
258+
259+
const result = await addDashboardOrganizationMember(
260+
'org-1',
261+
{
262+
userId: 'user-1',
263+
role: 'member',
264+
personalWorkspaceIds: ['workspace-1', 'workspace-2'],
265+
},
266+
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
267+
)
268+
269+
expect(mocks.moveWorkspace).toHaveBeenNthCalledWith(1, {
270+
workspaceId: 'workspace-1',
271+
destinationOrganizationId: 'org-1',
272+
adminEmail: 'admin@sim.ai',
273+
expectedOwnerId: 'user-1',
274+
})
275+
expect(mocks.moveWorkspace).toHaveBeenNthCalledWith(2, {
276+
workspaceId: 'workspace-2',
277+
destinationOrganizationId: 'org-1',
278+
adminEmail: 'admin@sim.ai',
279+
expectedOwnerId: 'user-1',
280+
})
281+
expect(result).toEqual({
282+
memberId: 'member-new',
283+
transferredFromOrganizationId: null,
284+
workspaceMoves: [
285+
{ workspaceId: 'workspace-1', success: true },
286+
{ workspaceId: 'workspace-2', success: true },
287+
],
288+
})
289+
})
290+
291+
it('uses the canonical transfer service and reports each selected workspace move', async () => {
243292
queueTableRows(workspace, [{ id: 'workspace-1' }, { id: 'workspace-2' }])
244293
queueTableRows(member, [{ id: 'member-old', organizationId: 'org-old' }])
245294
mocks.transferMembership.mockResolvedValue({
@@ -289,4 +338,36 @@ describe('addDashboardOrganizationMember', () => {
289338
expect(mocks.reconcileSeats).toHaveBeenCalledTimes(2)
290339
expect(mocks.recordAudit).toHaveBeenCalledTimes(2)
291340
})
341+
342+
it('uses the expected owner guard for an administrator-selected subset', async () => {
343+
queueTableRows(workspace, [{ id: 'workspace-1' }, { id: 'workspace-2' }])
344+
queueTableRows(member, [{ id: 'member-old', organizationId: 'org-old' }])
345+
mocks.transferMembership.mockResolvedValue({
346+
success: true,
347+
memberId: 'member-new',
348+
workspaceAccessRevoked: 0,
349+
credentialMembershipsRevoked: 0,
350+
pendingInvitationsCancelled: 0,
351+
usageCaptured: 0,
352+
})
353+
mocks.moveWorkspace.mockResolvedValue({})
354+
355+
const result = await addDashboardOrganizationMember(
356+
'org-new',
357+
{
358+
userId: 'user-1',
359+
role: 'member',
360+
personalWorkspaceIds: ['workspace-1'],
361+
},
362+
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
363+
)
364+
365+
expect(mocks.moveWorkspace).toHaveBeenCalledWith({
366+
workspaceId: 'workspace-1',
367+
destinationOrganizationId: 'org-new',
368+
adminEmail: 'admin@sim.ai',
369+
expectedOwnerId: 'user-1',
370+
})
371+
expect(result.workspaceMoves).toEqual([{ workspaceId: 'workspace-1', success: true }])
372+
})
292373
})

apps/sim/lib/admin/dashboard.ts

Lines changed: 13 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,7 @@ import {
1212
} from '@sim/db/schema'
1313
import { getErrorMessage } from '@sim/utils/errors'
1414
import { generateId } from '@sim/utils/id'
15-
import {
16-
and,
17-
count,
18-
countDistinct,
19-
desc,
20-
eq,
21-
ilike,
22-
inArray,
23-
isNull,
24-
ne,
25-
or,
26-
sql,
27-
} from 'drizzle-orm'
15+
import { and, count, countDistinct, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'
2816
import {
2917
getOrganizationUsageLimitFallbackDollars,
3018
getTeamOrganizationEconomics,
@@ -64,6 +52,7 @@ import { executeTransactionallyIdempotent } from '@/lib/core/idempotency/transac
6452
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
6553
import type { DbOrTx } from '@/lib/db/types'
6654
import { moveWorkspaceToOrganization } from '@/lib/workspaces/admin-move'
55+
import { ownedAttachableWorkspacesWhere } from '@/lib/workspaces/organization-workspaces'
6756

6857
interface PaginationInput {
6958
search: string
@@ -317,7 +306,7 @@ async function getDashboardOrganizationSummary(organizationId: string) {
317306
member,
318307
and(eq(member.userId, permissions.userId), eq(member.organizationId, organizationId))
319308
)
320-
.where(and(isNull(member.id), isNull(workspace.archivedAt))),
309+
.where(isNull(member.id)),
321310
getLatestSubscription(organizationId),
322311
getLatestEnterpriseProvisionings([organizationId]),
323312
])
@@ -398,13 +387,7 @@ export async function listDashboardOrganizations({ search, limit, offset }: Pagi
398387
eq(member.organizationId, workspace.organizationId)
399388
)
400389
)
401-
.where(
402-
and(
403-
inArray(workspace.organizationId, organizationIds),
404-
isNull(member.id),
405-
isNull(workspace.archivedAt)
406-
)
407-
)
390+
.where(and(inArray(workspace.organizationId, organizationIds), isNull(member.id)))
408391
.groupBy(workspace.organizationId),
409392
db
410393
.selectDistinctOn([subscription.referenceId])
@@ -496,7 +479,7 @@ export async function getDashboardOrganization(organizationId: string) {
496479
member,
497480
and(eq(member.userId, permissions.userId), eq(member.organizationId, organizationId))
498481
)
499-
.where(and(isNull(member.id), isNull(workspace.archivedAt)))
482+
.where(isNull(member.id))
500483
.groupBy(user.id, user.name, user.email)
501484
.orderBy(user.name),
502485
db
@@ -939,7 +922,7 @@ export async function getDashboardMemberTransferPreflight(
939922
db
940923
.select({ id: workspace.id, name: workspace.name, archivedAt: workspace.archivedAt })
941924
.from(workspace)
942-
.where(and(eq(workspace.ownerId, userId), ne(workspace.workspaceMode, 'organization')))
925+
.where(ownedAttachableWorkspacesWhere({ userId, includeArchived: true }))
943926
.orderBy(workspace.name, workspace.id),
944927
])
945928
if (!destination) throw new Error('Destination organization not found')
@@ -985,22 +968,15 @@ export async function addDashboardOrganizationMember(
985968
actor: AdminMutationActor
986969
) {
987970
const selectedWorkspaceIds = [...new Set(values.personalWorkspaceIds ?? [])]
988-
if (selectedWorkspaceIds.length > 0) {
989-
const selectable = await db
971+
const attachableWorkspaceIds = (
972+
await db
990973
.select({ id: workspace.id })
991974
.from(workspace)
992-
.where(
993-
and(
994-
inArray(workspace.id, selectedWorkspaceIds),
995-
eq(workspace.ownerId, values.userId),
996-
ne(workspace.workspaceMode, 'organization')
997-
)
998-
)
999-
if (selectable.length !== selectedWorkspaceIds.length) {
1000-
throw new Error('One or more selected personal workspaces can no longer be moved')
1001-
}
975+
.where(ownedAttachableWorkspacesWhere({ userId: values.userId, includeArchived: true }))
976+
).map((row) => row.id)
977+
if (selectedWorkspaceIds.some((id) => !attachableWorkspaceIds.includes(id))) {
978+
throw new Error('One or more selected personal workspaces can no longer be moved')
1002979
}
1003-
1004980
const [existingMembership] = await db
1005981
.select({ id: member.id, organizationId: member.organizationId })
1006982
.from(member)
@@ -1095,6 +1071,7 @@ export async function addDashboardOrganizationMember(
10951071
workspaceId,
10961072
destinationOrganizationId: organizationId,
10971073
adminEmail: actor.email ?? 'admin-api',
1074+
expectedOwnerId: values.userId,
10981075
})
10991076
workspaceMoves.push({ workspaceId, success: true })
11001077
} catch (error) {

apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const adminDashboardWorkspacePreflightQuerySchema = z.object({
2424

2525
export const adminDashboardWorkspaceMoveBodySchema = z.object({
2626
destinationOrganizationId: z.string().min(1).max(200),
27+
expectedOwnerId: z.string().min(1).max(200).optional(),
2728
})
2829

2930
const adminDashboardWorkspaceCandidateSchema = z.object({
@@ -78,9 +79,7 @@ const adminDashboardWorkspacePreflightResponseSchema = z.object({
7879
})
7980

8081
const adminDashboardWorkspaceMoveResponseSchema = z.object({
81-
data: adminDashboardWorkspacePreflightSchema.extend({
82-
invitationEmailFailures: z.array(z.string()),
83-
}),
82+
data: adminDashboardWorkspacePreflightSchema,
8483
})
8584

8685
export const adminDashboardWorkspaceSearchContract = defineRouteContract({

apps/sim/lib/billing/validation/seat-management.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export async function countPendingSeatInvitations(
6767
* Resolves the organization's seat capacity, honouring an Enterprise seat
6868
* change that is still in flight to Stripe.
6969
*/
70-
async function resolveSeatCapacity(
70+
export async function resolveSeatCapacity(
7171
organizationSubscription: { id: string; plan: string; metadata?: unknown } & Record<
7272
string,
7373
unknown

apps/sim/lib/invitations/send.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export interface CreatePendingInvitationResult {
6969
* person per organization, which is what makes coalescing mandatory rather
7070
* than optional.
7171
*/
72-
const PENDING_INVITATION_UNIQUE_INDEX = 'invitation_pending_email_org_unique'
72+
export const PENDING_INVITATION_UNIQUE_INDEX = 'invitation_pending_email_org_unique'
7373

7474
/**
7575
* Raised when the granted workspaces changed organization between the

0 commit comments

Comments
 (0)