chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockEnsureUserInOrganization,
|
||||
mockGetUserOrganization,
|
||||
mockAcquireOrgMembershipLock,
|
||||
mockEnsureTeamOrganizationForAcceptance,
|
||||
mockReconcileOrganizationSeats,
|
||||
mockGetWorkspaceWithOwner,
|
||||
mockSetActiveOrganizationForCurrentSession,
|
||||
mockSyncUsageLimitsFromSubscription,
|
||||
mockSyncWorkspaceEnvCredentials,
|
||||
mockIsWorkspaceOnEnterprisePlan,
|
||||
mockFeatureFlags,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnsureUserInOrganization: vi.fn(),
|
||||
mockGetUserOrganization: vi.fn(),
|
||||
mockAcquireOrgMembershipLock: vi.fn(),
|
||||
mockEnsureTeamOrganizationForAcceptance: vi.fn(),
|
||||
mockReconcileOrganizationSeats: vi.fn(),
|
||||
mockGetWorkspaceWithOwner: vi.fn(),
|
||||
mockSetActiveOrganizationForCurrentSession: vi.fn(),
|
||||
mockSyncUsageLimitsFromSubscription: vi.fn(),
|
||||
mockSyncWorkspaceEnvCredentials: vi.fn(),
|
||||
mockIsWorkspaceOnEnterprisePlan: vi.fn(async () => true),
|
||||
mockFeatureFlags: { isBillingEnabled: true },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
vi.mock('@/lib/billing/organizations/membership', () => ({
|
||||
ensureUserInOrganization: mockEnsureUserInOrganization,
|
||||
getUserOrganization: mockGetUserOrganization,
|
||||
acquireOrgMembershipLock: mockAcquireOrgMembershipLock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/organizations/provision-seat', () => ({
|
||||
ensureTeamOrganizationForAcceptance: mockEnsureTeamOrganizationForAcceptance,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/organizations/seats', () => ({
|
||||
reconcileOrganizationSeats: mockReconcileOrganizationSeats,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getWorkspaceWithOwner: mockGetWorkspaceWithOwner,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
get isBillingEnabled() {
|
||||
return mockFeatureFlags.isBillingEnabled
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/active-organization', () => ({
|
||||
setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
isWorkspaceOnEnterprisePlan: mockIsWorkspaceOnEnterprisePlan,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage', () => ({
|
||||
syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/environment', () => ({
|
||||
syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
import { acceptInvitation } from '@/lib/invitations/core'
|
||||
|
||||
function queueWhereResponses(responses: unknown[][]) {
|
||||
const queue = [...responses]
|
||||
dbChainMockFns.where.mockImplementation(() => {
|
||||
const result = queue.shift() ?? []
|
||||
const thenable = Promise.resolve(result) as Promise<unknown[]> & {
|
||||
limit: ReturnType<typeof vi.fn>
|
||||
orderBy: ReturnType<typeof vi.fn>
|
||||
returning: ReturnType<typeof vi.fn>
|
||||
groupBy: ReturnType<typeof vi.fn>
|
||||
}
|
||||
thenable.limit = vi.fn(() => Promise.resolve(result))
|
||||
thenable.orderBy = vi.fn(() => Promise.resolve(result))
|
||||
thenable.returning = vi.fn(() => Promise.resolve(result))
|
||||
thenable.groupBy = vi.fn(() => Promise.resolve(result))
|
||||
return thenable as ReturnType<typeof dbChainMockFns.where>
|
||||
})
|
||||
}
|
||||
|
||||
describe('acceptInvitation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockFeatureFlags.isBillingEnabled = true
|
||||
mockGetUserOrganization.mockResolvedValue(null)
|
||||
mockGetWorkspaceWithOwner.mockResolvedValue(null)
|
||||
mockEnsureTeamOrganizationForAcceptance.mockResolvedValue({
|
||||
success: true,
|
||||
organizationId: 'org-1',
|
||||
fixedSeats: false,
|
||||
})
|
||||
mockReconcileOrganizationSeats.mockResolvedValue({
|
||||
changed: true,
|
||||
previousSeats: 1,
|
||||
seats: 2,
|
||||
})
|
||||
mockEnsureUserInOrganization.mockResolvedValue({
|
||||
success: true,
|
||||
alreadyMember: false,
|
||||
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts external workspace invitations without joining the organization', async () => {
|
||||
queueWhereResponses([
|
||||
[
|
||||
{
|
||||
id: 'inv-1',
|
||||
kind: 'workspace',
|
||||
email: 'external@example.com',
|
||||
organizationId: 'org-1',
|
||||
membershipIntent: 'external',
|
||||
inviterId: 'inviter-1',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'grant-1',
|
||||
workspaceId: 'workspace-1',
|
||||
permission: 'write',
|
||||
workspaceName: 'Workspace',
|
||||
},
|
||||
],
|
||||
[{ name: 'Acme' }],
|
||||
[{ name: 'Inviter', email: 'inviter@example.com' }],
|
||||
[],
|
||||
[],
|
||||
[{ variables: {} }],
|
||||
])
|
||||
|
||||
const result = await acceptInvitation({
|
||||
userId: 'external-user',
|
||||
userEmail: 'external@example.com',
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.acceptedWorkspaceIds).toEqual(['workspace-1'])
|
||||
expect(result.membershipAlreadyExists).toBe(false)
|
||||
}
|
||||
expect(mockEnsureTeamOrganizationForAcceptance).not.toHaveBeenCalled()
|
||||
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
|
||||
expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled()
|
||||
expect(mockSyncUsageLimitsFromSubscription).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'external-user',
|
||||
entityType: 'workspace',
|
||||
entityId: 'workspace-1',
|
||||
permissionType: 'write',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to external access when an internal workspace invitee already joined another organization', async () => {
|
||||
mockGetUserOrganization.mockResolvedValueOnce({
|
||||
organizationId: 'org-2',
|
||||
role: 'member',
|
||||
memberId: 'member-2',
|
||||
})
|
||||
|
||||
queueWhereResponses([
|
||||
[
|
||||
{
|
||||
id: 'inv-1',
|
||||
kind: 'workspace',
|
||||
email: 'invitee@example.com',
|
||||
organizationId: 'org-1',
|
||||
membershipIntent: 'internal',
|
||||
inviterId: 'inviter-1',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'grant-1',
|
||||
workspaceId: 'workspace-1',
|
||||
permission: 'read',
|
||||
workspaceName: 'Workspace',
|
||||
},
|
||||
],
|
||||
[{ name: 'Acme' }],
|
||||
[{ name: 'Inviter', email: 'inviter@example.com' }],
|
||||
[],
|
||||
[],
|
||||
[{ variables: {} }],
|
||||
])
|
||||
|
||||
const result = await acceptInvitation({
|
||||
userId: 'invitee-user',
|
||||
userEmail: 'invitee@example.com',
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.invitation.membershipIntent).toBe('external')
|
||||
expect(result.acceptedWorkspaceIds).toEqual(['workspace-1'])
|
||||
}
|
||||
// The single-org pre-check short-circuits to external access without
|
||||
// provisioning a seat or attempting an organization join.
|
||||
expect(mockEnsureTeamOrganizationForAcceptance).not.toHaveBeenCalled()
|
||||
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'accepted',
|
||||
membershipIntent: 'external',
|
||||
})
|
||||
)
|
||||
expect(dbChainMockFns.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'invitee-user',
|
||||
entityType: 'workspace',
|
||||
entityId: 'workspace-1',
|
||||
permissionType: 'read',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('provisions a team seat and joins the org created from a Pro owner conversion', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
|
||||
id: 'workspace-1',
|
||||
name: 'Workspace',
|
||||
ownerId: 'owner-1',
|
||||
organizationId: null,
|
||||
workspaceMode: 'personal',
|
||||
billedAccountUserId: 'owner-1',
|
||||
})
|
||||
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
|
||||
success: true,
|
||||
organizationId: 'org-new',
|
||||
fixedSeats: false,
|
||||
})
|
||||
|
||||
queueWhereResponses([
|
||||
[
|
||||
{
|
||||
id: 'inv-1',
|
||||
kind: 'workspace',
|
||||
email: 'invitee@example.com',
|
||||
organizationId: null,
|
||||
membershipIntent: 'internal',
|
||||
inviterId: 'owner-1',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'grant-1',
|
||||
workspaceId: 'workspace-1',
|
||||
permission: 'write',
|
||||
workspaceName: 'Workspace',
|
||||
},
|
||||
],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
// Grant-txn membership re-check under the lock: member still present.
|
||||
[{ id: 'member-1' }],
|
||||
])
|
||||
|
||||
const result = await acceptInvitation({
|
||||
userId: 'invitee-user',
|
||||
userEmail: 'invitee@example.com',
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockEnsureTeamOrganizationForAcceptance).toHaveBeenCalledWith({
|
||||
billingOwnerUserId: 'owner-1',
|
||||
workspaceOrganizationId: null,
|
||||
})
|
||||
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'invitee-user',
|
||||
organizationId: 'org-new',
|
||||
acceptingInvitationId: 'inv-1',
|
||||
skipSeatValidation: true,
|
||||
})
|
||||
)
|
||||
// Seats grow to match the new member; the Stripe charge is deferred to the
|
||||
// seat-sync outbox.
|
||||
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
|
||||
organizationId: 'org-new',
|
||||
reason: 'member-accepted-invite',
|
||||
actorId: 'invitee-user',
|
||||
})
|
||||
expect(mockSetActiveOrganizationForCurrentSession).toHaveBeenCalledWith('org-new')
|
||||
expect(auditMock.recordAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'invitee-user',
|
||||
action: auditMock.AuditAction.ORG_MEMBER_ADDED,
|
||||
resourceType: auditMock.AuditResourceType.ORGANIZATION,
|
||||
resourceId: 'org-new',
|
||||
metadata: expect.objectContaining({ invitationId: 'inv-1', memberRole: 'member' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not record an ORG_MEMBER_ADDED audit for a user who is already a member', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
|
||||
id: 'workspace-1',
|
||||
name: 'Workspace',
|
||||
ownerId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
workspaceMode: 'organization',
|
||||
billedAccountUserId: 'owner-1',
|
||||
})
|
||||
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
|
||||
success: true,
|
||||
organizationId: 'org-1',
|
||||
fixedSeats: false,
|
||||
})
|
||||
mockEnsureUserInOrganization.mockResolvedValueOnce({
|
||||
success: true,
|
||||
alreadyMember: true,
|
||||
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
|
||||
})
|
||||
|
||||
queueWhereResponses([
|
||||
[
|
||||
{
|
||||
id: 'inv-1',
|
||||
kind: 'workspace',
|
||||
email: 'invitee@example.com',
|
||||
organizationId: 'org-1',
|
||||
membershipIntent: 'internal',
|
||||
inviterId: 'owner-1',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'grant-1',
|
||||
workspaceId: 'workspace-1',
|
||||
permission: 'write',
|
||||
workspaceName: 'Workspace',
|
||||
},
|
||||
],
|
||||
[{ name: 'Acme' }],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
[{ id: 'member-1' }],
|
||||
])
|
||||
|
||||
const result = await acceptInvitation({
|
||||
userId: 'invitee-user',
|
||||
userEmail: 'invitee@example.com',
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.membershipAlreadyExists).toBe(true)
|
||||
}
|
||||
expect(auditMock.recordAudit).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: auditMock.AuditAction.ORG_MEMBER_ADDED })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reconcile seats for an Enterprise organization (fixed seats)', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
|
||||
id: 'workspace-1',
|
||||
name: 'Workspace',
|
||||
ownerId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
workspaceMode: 'organization',
|
||||
billedAccountUserId: 'owner-1',
|
||||
})
|
||||
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
|
||||
success: true,
|
||||
organizationId: 'org-1',
|
||||
fixedSeats: true,
|
||||
})
|
||||
|
||||
queueWhereResponses([
|
||||
[
|
||||
{
|
||||
id: 'inv-1',
|
||||
kind: 'workspace',
|
||||
email: 'invitee@example.com',
|
||||
organizationId: 'org-1',
|
||||
membershipIntent: 'internal',
|
||||
inviterId: 'owner-1',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'grant-1',
|
||||
workspaceId: 'workspace-1',
|
||||
permission: 'write',
|
||||
workspaceName: 'Workspace',
|
||||
},
|
||||
],
|
||||
[{ name: 'Acme' }],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
// Grant-txn membership re-check under the lock: member still present.
|
||||
[{ id: 'member-1' }],
|
||||
])
|
||||
|
||||
const result = await acceptInvitation({
|
||||
userId: 'invitee-user',
|
||||
userEmail: 'invitee@example.com',
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockEnsureUserInOrganization).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ organizationId: 'org-1', skipSeatValidation: false })
|
||||
)
|
||||
expect(mockReconcileOrganizationSeats).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks acceptance with upgrade-required when the owner has no usable plan', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
|
||||
id: 'workspace-1',
|
||||
name: 'Workspace',
|
||||
ownerId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
workspaceMode: 'organization',
|
||||
billedAccountUserId: 'owner-1',
|
||||
})
|
||||
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
|
||||
success: false,
|
||||
failureCode: 'upgrade-required',
|
||||
})
|
||||
|
||||
queueWhereResponses([
|
||||
[
|
||||
{
|
||||
id: 'inv-1',
|
||||
kind: 'workspace',
|
||||
email: 'invitee@example.com',
|
||||
organizationId: 'org-1',
|
||||
membershipIntent: 'internal',
|
||||
inviterId: 'owner-1',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'grant-1',
|
||||
workspaceId: 'workspace-1',
|
||||
permission: 'write',
|
||||
workspaceName: 'Workspace',
|
||||
},
|
||||
],
|
||||
[{ name: 'Acme' }],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
])
|
||||
|
||||
const result = await acceptInvitation({
|
||||
userId: 'invitee-user',
|
||||
userEmail: 'invitee@example.com',
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.kind).toBe('upgrade-required')
|
||||
}
|
||||
expect(mockEnsureUserInOrganization).not.toHaveBeenCalled()
|
||||
expect(mockReconcileOrganizationSeats).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts when the org membership is revoked concurrently during the grant', async () => {
|
||||
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
|
||||
id: 'workspace-1',
|
||||
name: 'Workspace',
|
||||
ownerId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
workspaceMode: 'organization',
|
||||
billedAccountUserId: 'owner-1',
|
||||
})
|
||||
mockEnsureTeamOrganizationForAcceptance.mockResolvedValueOnce({
|
||||
success: true,
|
||||
organizationId: 'org-1',
|
||||
fixedSeats: false,
|
||||
})
|
||||
|
||||
queueWhereResponses([
|
||||
[
|
||||
{
|
||||
id: 'inv-1',
|
||||
kind: 'workspace',
|
||||
email: 'invitee@example.com',
|
||||
organizationId: 'org-1',
|
||||
membershipIntent: 'internal',
|
||||
inviterId: 'owner-1',
|
||||
role: 'member',
|
||||
status: 'pending',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'grant-1',
|
||||
workspaceId: 'workspace-1',
|
||||
permission: 'write',
|
||||
workspaceName: 'Workspace',
|
||||
},
|
||||
],
|
||||
[{ name: 'Acme' }],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
// Grant-txn membership re-check finds no member row (removed concurrently).
|
||||
[],
|
||||
])
|
||||
|
||||
const result = await acceptInvitation({
|
||||
userId: 'invitee-user',
|
||||
userEmail: 'invitee@example.com',
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.kind).toBe('already-processed')
|
||||
}
|
||||
// Aborted before granting workspace access — no zombie permission write.
|
||||
expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,656 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
type InvitationKind,
|
||||
type InvitationMembershipIntent,
|
||||
type InvitationStatus,
|
||||
invitation,
|
||||
invitationWorkspaceGrant,
|
||||
member,
|
||||
organization,
|
||||
permissions,
|
||||
user,
|
||||
workspace,
|
||||
workspaceEnvironment,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { PERMISSION_RANK, type PermissionType } from '@sim/platform-authz/workspace'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { and, eq, inArray, lte } from 'drizzle-orm'
|
||||
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
acquireOrgMembershipLock,
|
||||
ensureUserInOrganization,
|
||||
getUserOrganization,
|
||||
} from '@/lib/billing/organizations/membership'
|
||||
import { ensureTeamOrganizationForAcceptance } from '@/lib/billing/organizations/provision-seat'
|
||||
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('InvitationCore')
|
||||
|
||||
export const INVITATION_EXPIRY_DAYS = 7
|
||||
|
||||
export function computeInvitationExpiry(daysFromNow = INVITATION_EXPIRY_DAYS): Date {
|
||||
return new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
export interface InvitationWithGrants {
|
||||
id: string
|
||||
kind: InvitationKind
|
||||
email: string
|
||||
organizationId: string | null
|
||||
membershipIntent: InvitationMembershipIntent
|
||||
inviterId: string
|
||||
role: string
|
||||
status: InvitationStatus
|
||||
token: string
|
||||
expiresAt: Date
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
grants: Array<{
|
||||
id: string
|
||||
workspaceId: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
workspaceName: string | null
|
||||
}>
|
||||
organizationName: string | null
|
||||
inviterName: string | null
|
||||
inviterEmail: string | null
|
||||
}
|
||||
|
||||
export async function getInvitationById(id: string): Promise<InvitationWithGrants | null> {
|
||||
const [row] = await db.select().from(invitation).where(eq(invitation.id, id)).limit(1)
|
||||
if (!row) return null
|
||||
return hydrateInvitation(row)
|
||||
}
|
||||
|
||||
export async function getInvitationByToken(token: string): Promise<InvitationWithGrants | null> {
|
||||
const [row] = await db.select().from(invitation).where(eq(invitation.token, token)).limit(1)
|
||||
if (!row) return null
|
||||
return hydrateInvitation(row)
|
||||
}
|
||||
|
||||
async function hydrateInvitation(
|
||||
row: typeof invitation.$inferSelect
|
||||
): Promise<InvitationWithGrants> {
|
||||
const grantRows = await db
|
||||
.select({
|
||||
id: invitationWorkspaceGrant.id,
|
||||
workspaceId: invitationWorkspaceGrant.workspaceId,
|
||||
permission: invitationWorkspaceGrant.permission,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
.from(invitationWorkspaceGrant)
|
||||
.leftJoin(workspace, eq(workspace.id, invitationWorkspaceGrant.workspaceId))
|
||||
.where(eq(invitationWorkspaceGrant.invitationId, row.id))
|
||||
|
||||
let organizationName: string | null = null
|
||||
if (row.organizationId) {
|
||||
const [orgRow] = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, row.organizationId))
|
||||
.limit(1)
|
||||
organizationName = orgRow?.name ?? null
|
||||
}
|
||||
|
||||
const [inviterRow] = await db
|
||||
.select({ name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, row.inviterId))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
email: row.email,
|
||||
organizationId: row.organizationId,
|
||||
membershipIntent: row.membershipIntent,
|
||||
inviterId: row.inviterId,
|
||||
role: row.role,
|
||||
status: row.status,
|
||||
token: row.token,
|
||||
expiresAt: row.expiresAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
grants: grantRows.map((grant) => ({
|
||||
id: grant.id,
|
||||
workspaceId: grant.workspaceId,
|
||||
permission: grant.permission,
|
||||
workspaceName: grant.workspaceName,
|
||||
})),
|
||||
organizationName,
|
||||
inviterName: inviterRow?.name ?? null,
|
||||
inviterEmail: inviterRow?.email ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function isInvitationExpired(inv: Pick<InvitationWithGrants, 'expiresAt'>): boolean {
|
||||
return new Date() > new Date(inv.expiresAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip any still-pending invitations for the given organization whose
|
||||
* `expiresAt` has already passed to `expired`. Best-effort housekeeping
|
||||
* — callers can rely on this for display freshness, but seat math also
|
||||
* defensively filters by `expiresAt` at query time.
|
||||
*/
|
||||
export async function expireStalePendingInvitationsForOrganization(
|
||||
organizationId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db
|
||||
.update(invitation)
|
||||
.set({ status: 'expired', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.organizationId, organizationId),
|
||||
eq(invitation.status, 'pending'),
|
||||
lte(invitation.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to expire stale pending invitations for organization', {
|
||||
organizationId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip any still-pending invitations with grants on the given workspaces
|
||||
* whose `expiresAt` has already passed to `expired`.
|
||||
*/
|
||||
export async function expireStalePendingInvitationsForWorkspaces(
|
||||
workspaceIds: string[]
|
||||
): Promise<void> {
|
||||
if (workspaceIds.length === 0) return
|
||||
try {
|
||||
const staleIds = await db
|
||||
.select({ id: invitation.id })
|
||||
.from(invitation)
|
||||
.innerJoin(invitationWorkspaceGrant, eq(invitationWorkspaceGrant.invitationId, invitation.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(invitationWorkspaceGrant.workspaceId, workspaceIds),
|
||||
eq(invitation.status, 'pending'),
|
||||
lte(invitation.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
|
||||
if (staleIds.length === 0) return
|
||||
|
||||
await db
|
||||
.update(invitation)
|
||||
.set({ status: 'expired', updatedAt: new Date() })
|
||||
.where(
|
||||
inArray(
|
||||
invitation.id,
|
||||
staleIds.map((row) => row.id)
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to expire stale pending invitations for workspaces', {
|
||||
workspaceCount: workspaceIds.length,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type AcceptInvitationFailure =
|
||||
| { kind: 'not-found' }
|
||||
| { kind: 'already-processed' }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'email-mismatch' }
|
||||
| { kind: 'invalid-token' }
|
||||
| { kind: 'already-in-organization' }
|
||||
| { kind: 'no-seats-available' }
|
||||
| { kind: 'upgrade-required' }
|
||||
| { kind: 'server-error'; message?: string }
|
||||
|
||||
export type AcceptInvitationSuccess = {
|
||||
success: true
|
||||
invitation: InvitationWithGrants
|
||||
acceptedWorkspaceIds: string[]
|
||||
redirectPath: string
|
||||
membershipAlreadyExists: boolean
|
||||
}
|
||||
|
||||
export type AcceptInvitationResult =
|
||||
| AcceptInvitationSuccess
|
||||
| ({ success: false } & AcceptInvitationFailure)
|
||||
|
||||
export interface AcceptInvitationInput {
|
||||
userId: string
|
||||
userEmail: string
|
||||
invitationId: string
|
||||
token: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown inside the grant transaction when the invitee's org membership was
|
||||
* removed concurrently (between the join and the grant) — detected under the
|
||||
* membership lock. Aborts the grant so we never write workspace access for a
|
||||
* user who is no longer an org member (the "zombie" state).
|
||||
*/
|
||||
class MembershipRevokedDuringAcceptError extends Error {
|
||||
constructor() {
|
||||
super('Org membership was revoked during invite acceptance')
|
||||
this.name = 'MembershipRevokedDuringAcceptError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An invitee who already belongs to a different organization cannot join a
|
||||
* second one. A workspace invite (with grants) falls back to external access;
|
||||
* anything else is rejected. Returns `true` when the caller should downgrade to
|
||||
* external membership, `false` when the invite was rejected as cross-org.
|
||||
*/
|
||||
async function downgradeOrRejectCrossOrgInvite(inv: InvitationWithGrants): Promise<boolean> {
|
||||
if (inv.kind === 'workspace' && inv.grants.length > 0) {
|
||||
return true
|
||||
}
|
||||
await db
|
||||
.update(invitation)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
.where(eq(invitation.id, inv.id))
|
||||
return false
|
||||
}
|
||||
|
||||
export async function acceptInvitation(
|
||||
input: AcceptInvitationInput
|
||||
): Promise<AcceptInvitationResult> {
|
||||
const inv = await getInvitationById(input.invitationId)
|
||||
|
||||
if (!inv) {
|
||||
return { success: false, kind: 'not-found' }
|
||||
}
|
||||
|
||||
if (input.token && inv.token !== input.token) {
|
||||
return { success: false, kind: 'invalid-token' }
|
||||
}
|
||||
|
||||
if (inv.status !== 'pending') {
|
||||
return { success: false, kind: 'already-processed' }
|
||||
}
|
||||
|
||||
if (isInvitationExpired(inv)) {
|
||||
await db
|
||||
.update(invitation)
|
||||
.set({ status: 'expired', updatedAt: new Date() })
|
||||
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
|
||||
return { success: false, kind: 'expired' }
|
||||
}
|
||||
|
||||
if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) {
|
||||
return { success: false, kind: 'email-mismatch' }
|
||||
}
|
||||
|
||||
let membershipAlreadyExists = false
|
||||
let acceptedMembershipIntent = inv.membershipIntent
|
||||
let shouldJoinOrganization = inv.membershipIntent !== 'external'
|
||||
|
||||
const primaryGrant = inv.grants[0]
|
||||
let billingOwnerUserId = inv.inviterId
|
||||
let workspaceOrganizationId = inv.organizationId
|
||||
if (primaryGrant) {
|
||||
const grantWorkspace = await getWorkspaceWithOwner(primaryGrant.workspaceId)
|
||||
if (grantWorkspace) {
|
||||
billingOwnerUserId = grantWorkspace.billedAccountUserId
|
||||
workspaceOrganizationId = grantWorkspace.organizationId ?? inv.organizationId
|
||||
}
|
||||
}
|
||||
|
||||
const existingMembership = await getUserOrganization(input.userId)
|
||||
const inviteeAlreadyInDifferentOrg =
|
||||
!!existingMembership &&
|
||||
(workspaceOrganizationId ? existingMembership.organizationId !== workspaceOrganizationId : true)
|
||||
|
||||
if (shouldJoinOrganization && inviteeAlreadyInDifferentOrg) {
|
||||
if (await downgradeOrRejectCrossOrgInvite(inv)) {
|
||||
acceptedMembershipIntent = 'external'
|
||||
shouldJoinOrganization = false
|
||||
} else {
|
||||
return { success: false, kind: 'already-in-organization' }
|
||||
}
|
||||
}
|
||||
|
||||
let targetOrganizationId = workspaceOrganizationId
|
||||
|
||||
if (shouldJoinOrganization) {
|
||||
const alreadyMemberOfTarget =
|
||||
!!existingMembership &&
|
||||
!!workspaceOrganizationId &&
|
||||
existingMembership.organizationId === workspaceOrganizationId
|
||||
|
||||
let fixedSeats = false
|
||||
|
||||
if (isBillingEnabled && !alreadyMemberOfTarget) {
|
||||
const orgResult = await ensureTeamOrganizationForAcceptance({
|
||||
billingOwnerUserId,
|
||||
workspaceOrganizationId,
|
||||
})
|
||||
if (!orgResult.success) {
|
||||
return { success: false, kind: orgResult.failureCode }
|
||||
}
|
||||
targetOrganizationId = orgResult.organizationId
|
||||
fixedSeats = orgResult.fixedSeats
|
||||
}
|
||||
|
||||
// Team plans manage seats by reconciling to the member count after the
|
||||
// join (and charging async), so the synchronous seat-cap validation is
|
||||
// skipped. Enterprise keeps its fixed-seat validation, and when billing is
|
||||
// disabled we leave validation in place unchanged.
|
||||
const billingManagesSeats = isBillingEnabled && !fixedSeats
|
||||
|
||||
if (targetOrganizationId) {
|
||||
const membershipResult = await ensureUserInOrganization({
|
||||
userId: input.userId,
|
||||
organizationId: targetOrganizationId,
|
||||
role: (inv.role || 'member') as 'admin' | 'member' | 'owner',
|
||||
acceptingInvitationId: inv.id,
|
||||
skipSeatValidation: billingManagesSeats,
|
||||
})
|
||||
|
||||
if (!membershipResult.success) {
|
||||
if (membershipResult.existingOrgId) {
|
||||
if (await downgradeOrRejectCrossOrgInvite(inv)) {
|
||||
acceptedMembershipIntent = 'external'
|
||||
shouldJoinOrganization = false
|
||||
} else {
|
||||
return { success: false, kind: 'already-in-organization' }
|
||||
}
|
||||
} else if (membershipResult.failureCode === 'no-seats-available') {
|
||||
return { success: false, kind: 'no-seats-available' }
|
||||
} else {
|
||||
return { success: false, kind: 'server-error', message: membershipResult.error }
|
||||
}
|
||||
} else {
|
||||
membershipAlreadyExists = membershipResult.alreadyMember
|
||||
|
||||
if (!membershipResult.alreadyMember) {
|
||||
const memberRole = inv.role || 'member'
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: input.userId,
|
||||
action: AuditAction.ORG_MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: targetOrganizationId,
|
||||
description: `Joined organization as ${memberRole} via invite acceptance`,
|
||||
metadata: { invitationId: inv.id, memberRole },
|
||||
})
|
||||
captureServerEvent(
|
||||
input.userId,
|
||||
'org_member_added',
|
||||
{ organization_id: targetOrganizationId, member_role: memberRole },
|
||||
{ groups: { organization: targetOrganizationId } }
|
||||
)
|
||||
}
|
||||
|
||||
// Grow the paid seat count to match the new member and push the charge
|
||||
// to Stripe asynchronously (Team plans only; Enterprise seats are
|
||||
// fixed). Best-effort: the member is already in, and a transient
|
||||
// failure self-heals on the next join/removal reconcile, matching the
|
||||
// removal path's seat accounting.
|
||||
if (billingManagesSeats && !membershipResult.alreadyMember) {
|
||||
try {
|
||||
await reconcileOrganizationSeats({
|
||||
organizationId: targetOrganizationId,
|
||||
reason: 'member-accepted-invite',
|
||||
actorId: input.userId,
|
||||
})
|
||||
} catch (seatError) {
|
||||
logger.error('Failed to reconcile organization seats after invite acceptance', {
|
||||
userId: input.userId,
|
||||
organizationId: targetOrganizationId,
|
||||
invitationId: inv.id,
|
||||
error: seatError,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldJoinOrganization = false
|
||||
}
|
||||
}
|
||||
|
||||
const acceptedWorkspaceIds: string[] = []
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
/**
|
||||
* When this acceptance joins an organization, serialize against a
|
||||
* concurrent member-removal for the same user+org and confirm the member
|
||||
* still exists before granting workspace access. Without this, a removal
|
||||
* landing between the join and the grant would leave the user with
|
||||
* workspace access but no org membership/seat.
|
||||
*/
|
||||
if (shouldJoinOrganization && targetOrganizationId) {
|
||||
await acquireOrgMembershipLock(tx, input.userId, targetOrganizationId)
|
||||
const [stillMember] = await tx
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.organizationId, targetOrganizationId), eq(member.userId, input.userId))
|
||||
)
|
||||
.limit(1)
|
||||
if (!stillMember) {
|
||||
throw new MembershipRevokedDuringAcceptError()
|
||||
}
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(invitation)
|
||||
.set({
|
||||
status: 'accepted',
|
||||
membershipIntent: acceptedMembershipIntent,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invitation.id, inv.id))
|
||||
|
||||
for (const grant of inv.grants) {
|
||||
const [existingPermission] = await tx
|
||||
.select({ id: permissions.id, permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, grant.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, input.userId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const newPermission = grant.permission as PermissionType
|
||||
const newRank = PERMISSION_RANK[newPermission] ?? 0
|
||||
|
||||
if (existingPermission) {
|
||||
const existingRank =
|
||||
PERMISSION_RANK[existingPermission.permissionType as PermissionType] ?? 0
|
||||
if (newRank > existingRank) {
|
||||
await tx
|
||||
.update(permissions)
|
||||
.set({ permissionType: newPermission, updatedAt: new Date() })
|
||||
.where(eq(permissions.id, existingPermission.id))
|
||||
}
|
||||
} else {
|
||||
await tx.insert(permissions).values({
|
||||
id: generateId(),
|
||||
entityType: 'workspace',
|
||||
entityId: grant.workspaceId,
|
||||
userId: input.userId,
|
||||
permissionType: newPermission,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
acceptedWorkspaceIds.push(grant.workspaceId)
|
||||
}
|
||||
})
|
||||
} catch (grantError) {
|
||||
if (grantError instanceof MembershipRevokedDuringAcceptError) {
|
||||
logger.warn('Aborted invite acceptance: org membership revoked concurrently', {
|
||||
userId: input.userId,
|
||||
organizationId: targetOrganizationId,
|
||||
invitationId: inv.id,
|
||||
})
|
||||
return { success: false, kind: 'already-processed' }
|
||||
}
|
||||
throw grantError
|
||||
}
|
||||
|
||||
if (shouldJoinOrganization && targetOrganizationId) {
|
||||
try {
|
||||
await setActiveOrganizationForCurrentSession(targetOrganizationId)
|
||||
} catch (activeOrgError) {
|
||||
logger.error('Failed to activate organization after accepting invitation', {
|
||||
userId: input.userId,
|
||||
organizationId: targetOrganizationId,
|
||||
invitationId: inv.id,
|
||||
error: activeOrgError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspaceId of acceptedWorkspaceIds) {
|
||||
try {
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: input.userId,
|
||||
})
|
||||
}
|
||||
} catch (envError) {
|
||||
logger.error('Failed to sync workspace env credentials after invitation accept', {
|
||||
userId: input.userId,
|
||||
workspaceId,
|
||||
invitationId: inv.id,
|
||||
error: envError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldJoinOrganization && targetOrganizationId && !membershipAlreadyExists) {
|
||||
try {
|
||||
await syncUsageLimitsFromSubscription(input.userId)
|
||||
} catch (syncError) {
|
||||
logger.error('Failed to sync usage limits after joining org', {
|
||||
userId: input.userId,
|
||||
organizationId: targetOrganizationId,
|
||||
invitationId: inv.id,
|
||||
error: syncError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const redirectPath =
|
||||
inv.kind === 'workspace' && acceptedWorkspaceIds.length > 0
|
||||
? `/workspace/${acceptedWorkspaceIds[0]}/home`
|
||||
: '/workspace'
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invitation: { ...inv, status: 'accepted', membershipIntent: acceptedMembershipIntent },
|
||||
acceptedWorkspaceIds,
|
||||
redirectPath,
|
||||
membershipAlreadyExists,
|
||||
}
|
||||
}
|
||||
|
||||
export type RejectInvitationResult =
|
||||
| { success: true; invitation: InvitationWithGrants }
|
||||
| { success: false; kind: AcceptInvitationFailure['kind'] }
|
||||
|
||||
export async function rejectInvitation(
|
||||
input: AcceptInvitationInput
|
||||
): Promise<RejectInvitationResult> {
|
||||
const inv = await getInvitationById(input.invitationId)
|
||||
|
||||
if (!inv) return { success: false, kind: 'not-found' }
|
||||
if (input.token && inv.token !== input.token) return { success: false, kind: 'invalid-token' }
|
||||
if (inv.status !== 'pending') return { success: false, kind: 'already-processed' }
|
||||
if (isInvitationExpired(inv)) {
|
||||
await db
|
||||
.update(invitation)
|
||||
.set({ status: 'expired', updatedAt: new Date() })
|
||||
.where(and(eq(invitation.id, inv.id), eq(invitation.status, 'pending')))
|
||||
return { success: false, kind: 'expired' }
|
||||
}
|
||||
if (normalizeEmail(input.userEmail) !== normalizeEmail(inv.email)) {
|
||||
return { success: false, kind: 'email-mismatch' }
|
||||
}
|
||||
|
||||
await db
|
||||
.update(invitation)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
.where(eq(invitation.id, inv.id))
|
||||
|
||||
return { success: true, invitation: { ...inv, status: 'rejected' } }
|
||||
}
|
||||
|
||||
export async function cancelInvitation(invitationId: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(invitation)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(and(eq(invitation.id, invitationId), eq(invitation.status, 'pending')))
|
||||
.returning({ id: invitation.id })
|
||||
|
||||
return result.length > 0
|
||||
}
|
||||
|
||||
export async function listPendingInvitationsForOrganization(organizationId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
kind: invitation.kind,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
membershipIntent: invitation.membershipIntent,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt,
|
||||
createdAt: invitation.createdAt,
|
||||
inviterName: user.name,
|
||||
inviterEmail: user.email,
|
||||
})
|
||||
.from(invitation)
|
||||
.leftJoin(user, eq(invitation.inviterId, user.id))
|
||||
.where(eq(invitation.organizationId, organizationId))
|
||||
.orderBy(invitation.createdAt)
|
||||
}
|
||||
|
||||
export async function listInvitationsForWorkspaces(workspaceIds: string[]) {
|
||||
if (workspaceIds.length === 0) return []
|
||||
return db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
kind: invitation.kind,
|
||||
email: invitation.email,
|
||||
token: invitation.token,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt,
|
||||
createdAt: invitation.createdAt,
|
||||
updatedAt: invitation.updatedAt,
|
||||
organizationId: invitation.organizationId,
|
||||
membershipIntent: invitation.membershipIntent,
|
||||
inviterId: invitation.inviterId,
|
||||
workspaceId: invitationWorkspaceGrant.workspaceId,
|
||||
permission: invitationWorkspaceGrant.permission,
|
||||
})
|
||||
.from(invitationWorkspaceGrant)
|
||||
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
|
||||
.where(inArray(invitationWorkspaceGrant.workspaceId, workspaceIds))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
auditMockFns,
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
resetDbChainMock,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetUserOrganization,
|
||||
mockSyncWorkspaceEnvCredentials,
|
||||
mockCancelPendingInvitation,
|
||||
mockSendWorkspaceAddedEmail,
|
||||
mockCaptureServerEvent,
|
||||
mockWorkspaceMemberAdded,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetUserOrganization: vi.fn(),
|
||||
mockSyncWorkspaceEnvCredentials: vi.fn(),
|
||||
mockCancelPendingInvitation: vi.fn(),
|
||||
mockSendWorkspaceAddedEmail: vi.fn(),
|
||||
mockCaptureServerEvent: vi.fn(),
|
||||
mockWorkspaceMemberAdded: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/billing/organizations/membership', () => ({
|
||||
getUserOrganization: mockGetUserOrganization,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/telemetry', () => ({
|
||||
PlatformEvents: { workspaceMemberAdded: mockWorkspaceMemberAdded },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credentials/environment', () => ({
|
||||
syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invitations/send', () => ({
|
||||
cancelPendingInvitation: mockCancelPendingInvitation,
|
||||
sendWorkspaceAddedEmail: mockSendWorkspaceAddedEmail,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => ({
|
||||
captureServerEvent: mockCaptureServerEvent,
|
||||
}))
|
||||
|
||||
import { grantWorkspaceAccessDirectly, isSameOrgMember } from '@/lib/invitations/direct-grant'
|
||||
|
||||
/**
|
||||
* Drives `db.select().from().where()` results in call order. Both an awaited
|
||||
* `where()` and a chained `.limit()` resolve to the same per-call value.
|
||||
*/
|
||||
function queueWhereResponses(responses: unknown[][]) {
|
||||
const queue = [...responses]
|
||||
dbChainMockFns.where.mockImplementation(() => {
|
||||
const result = queue.shift() ?? []
|
||||
const thenable = Promise.resolve(result) as Promise<unknown[]> & {
|
||||
limit: ReturnType<typeof vi.fn>
|
||||
orderBy: ReturnType<typeof vi.fn>
|
||||
returning: ReturnType<typeof vi.fn>
|
||||
groupBy: ReturnType<typeof vi.fn>
|
||||
}
|
||||
thenable.limit = vi.fn(() => Promise.resolve(result))
|
||||
thenable.orderBy = vi.fn(() => Promise.resolve(result))
|
||||
thenable.returning = vi.fn(() => Promise.resolve(result))
|
||||
thenable.groupBy = vi.fn(() => Promise.resolve(result))
|
||||
return thenable as ReturnType<typeof dbChainMockFns.where>
|
||||
})
|
||||
}
|
||||
|
||||
const baseInput = {
|
||||
userId: 'user-2',
|
||||
email: 'Member@Example.com',
|
||||
workspaceId: 'ws-1',
|
||||
workspaceName: 'Workspace 1',
|
||||
permission: 'write' as const,
|
||||
organizationId: 'org-1',
|
||||
actorId: 'user-1',
|
||||
actorName: 'Owner',
|
||||
actorEmail: 'owner@example.com',
|
||||
}
|
||||
|
||||
describe('grantWorkspaceAccessDirectly', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockSendWorkspaceAddedEmail.mockResolvedValue({ success: true })
|
||||
// Insert path reports the new row via `.returning()`.
|
||||
dbChainMockFns.returning.mockResolvedValue([{ id: 'perm-new' }])
|
||||
})
|
||||
|
||||
it('inserts a permission row when the user has no existing access', async () => {
|
||||
const result = await grantWorkspaceAccessDirectly({ ...baseInput })
|
||||
|
||||
expect(result).toEqual({ outcome: 'added', permission: 'write' })
|
||||
expect(dbChainMockFns.insert).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'member.added', resourceId: 'ws-1' })
|
||||
)
|
||||
expect(mockWorkspaceMemberAdded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceId: 'ws-1' })
|
||||
)
|
||||
expect(mockSendWorkspaceAddedEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'member@example.com', workspaceId: 'ws-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports unchanged (no audit/email) when a concurrent insert wins the race', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([])
|
||||
|
||||
const result = await grantWorkspaceAccessDirectly({ ...baseInput })
|
||||
|
||||
expect(result).toEqual({ outcome: 'unchanged', permission: 'write' })
|
||||
expect(dbChainMockFns.insert).toHaveBeenCalled()
|
||||
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled()
|
||||
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not upgrade an existing lower permission (invites never modify access)', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'read' }])
|
||||
|
||||
const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'admin' })
|
||||
|
||||
expect(result).toEqual({ outcome: 'unchanged', permission: 'read' })
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
|
||||
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when the user already has access', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'admin' }])
|
||||
|
||||
const result = await grantWorkspaceAccessDirectly({ ...baseInput, permission: 'write' })
|
||||
|
||||
expect(result).toEqual({ outcome: 'unchanged', permission: 'admin' })
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
expect(auditMockFns.mockRecordAudit).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled()
|
||||
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the email when notify is false', async () => {
|
||||
const result = await grantWorkspaceAccessDirectly({ ...baseInput, notify: false })
|
||||
|
||||
expect(result.outcome).toBe('added')
|
||||
expect(auditMockFns.mockRecordAudit).toHaveBeenCalled()
|
||||
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs workspace env credentials when env variables exist', async () => {
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([]) // existing permission lookup
|
||||
.mockResolvedValueOnce([{ variables: { API_KEY: 'x', BASE_URL: 'y' } }]) // env lookup
|
||||
|
||||
await grantWorkspaceAccessDirectly({ ...baseInput })
|
||||
|
||||
expect(mockSyncWorkspaceEnvCredentials).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'ws-1',
|
||||
actingUserId: 'user-2',
|
||||
envKeys: ['API_KEY', 'BASE_URL'],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('supersedes lingering pending workspace invitations for the same email', async () => {
|
||||
queueWhereResponses([
|
||||
[], // existing permission lookup (transaction)
|
||||
[{ invitationId: 'old-inv' }], // supersede lookup
|
||||
[], // env lookup
|
||||
])
|
||||
|
||||
await grantWorkspaceAccessDirectly({ ...baseInput })
|
||||
|
||||
expect(mockCancelPendingInvitation).toHaveBeenCalledWith('old-inv')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSameOrgMember', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('returns false when the workspace has no organization', async () => {
|
||||
expect(await isSameOrgMember('user-2', null)).toBe(false)
|
||||
expect(mockGetUserOrganization).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when the user belongs to no organization', async () => {
|
||||
mockGetUserOrganization.mockResolvedValueOnce(null)
|
||||
expect(await isSameOrgMember('user-2', 'org-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the user belongs to the workspace organization', async () => {
|
||||
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' })
|
||||
expect(await isSameOrgMember('user-2', 'org-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the user belongs to a different organization', async () => {
|
||||
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' })
|
||||
expect(await isSameOrgMember('user-2', 'org-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
invitation,
|
||||
invitationWorkspaceGrant,
|
||||
permissions,
|
||||
workspaceEnvironment,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getUserOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { cancelPendingInvitation, sendWorkspaceAddedEmail } from '@/lib/invitations/send'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('InvitationDirectGrant')
|
||||
|
||||
export type DirectGrantOutcome =
|
||||
| { outcome: 'added'; permission: PermissionType }
|
||||
| { outcome: 'unchanged'; permission: PermissionType }
|
||||
|
||||
export interface GrantWorkspaceAccessDirectlyInput {
|
||||
/** Registered user receiving access. */
|
||||
userId: string
|
||||
/** Invitee email (used for notification + audit; normalized internally). */
|
||||
email: string
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
permission: PermissionType
|
||||
/** Organization that owns the workspace. */
|
||||
organizationId: string
|
||||
actorId: string
|
||||
actorName: string
|
||||
actorEmail?: string | null
|
||||
request?: NextRequest
|
||||
/** Send the lightweight "you've been added" email. Defaults to true. */
|
||||
notify?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given user is already a member of the workspace's
|
||||
* organization. Only same-org members are eligible for direct (no-acceptance)
|
||||
* workspace access.
|
||||
*/
|
||||
export async function isSameOrgMember(
|
||||
userId: string,
|
||||
workspaceOrganizationId: string | null
|
||||
): Promise<boolean> {
|
||||
if (!workspaceOrganizationId) return false
|
||||
const membership = await getUserOrganization(userId)
|
||||
return !!membership && membership.organizationId === workspaceOrganizationId
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels any pending single-workspace invitations that grant exactly this
|
||||
* workspace to this email. Multi-workspace organization invitations are left
|
||||
* untouched — their remaining grants stay valid and the accept flow upserts
|
||||
* permissions idempotently.
|
||||
*/
|
||||
async function supersedePendingWorkspaceInvites(
|
||||
workspaceId: string,
|
||||
normalizedEmail: string
|
||||
): Promise<void> {
|
||||
const rows = await db
|
||||
.select({ invitationId: invitation.id })
|
||||
.from(invitation)
|
||||
.innerJoin(invitationWorkspaceGrant, eq(invitationWorkspaceGrant.invitationId, invitation.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.kind, 'workspace'),
|
||||
eq(invitation.email, normalizedEmail),
|
||||
eq(invitation.status, 'pending'),
|
||||
eq(invitationWorkspaceGrant.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
await cancelPendingInvitation(row.invitationId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants a user workspace access immediately, without an invitation or
|
||||
* acceptance step. Intended for users who already belong to the workspace's
|
||||
* organization and are not yet members of the workspace. Idempotent: when a
|
||||
* permission already exists it is left untouched (no-op) — invites never modify
|
||||
* or upgrade an existing member's permission.
|
||||
*/
|
||||
export async function grantWorkspaceAccessDirectly(
|
||||
input: GrantWorkspaceAccessDirectlyInput
|
||||
): Promise<DirectGrantOutcome> {
|
||||
const normalizedEmail = normalizeEmail(input.email)
|
||||
|
||||
const result = await db.transaction(async (tx): Promise<DirectGrantOutcome> => {
|
||||
const [existing] = await tx
|
||||
.select({ id: permissions.id, permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, input.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, input.userId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return { outcome: 'unchanged', permission: existing.permissionType as PermissionType }
|
||||
}
|
||||
|
||||
const inserted = await tx
|
||||
.insert(permissions)
|
||||
.values({
|
||||
id: generateId(),
|
||||
entityType: 'workspace',
|
||||
entityId: input.workspaceId,
|
||||
userId: input.userId,
|
||||
permissionType: input.permission,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: permissions.id })
|
||||
|
||||
if (inserted.length === 0) {
|
||||
return { outcome: 'unchanged', permission: input.permission }
|
||||
}
|
||||
|
||||
return { outcome: 'added', permission: input.permission }
|
||||
})
|
||||
|
||||
if (result.outcome === 'unchanged') {
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
await supersedePendingWorkspaceInvites(input.workspaceId, normalizedEmail)
|
||||
} catch (error) {
|
||||
logger.error('Failed to supersede pending workspace invitations after direct grant', {
|
||||
workspaceId: input.workspaceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, input.workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId: input.workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: input.userId,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync workspace env credentials after direct grant', {
|
||||
workspaceId: input.workspaceId,
|
||||
userId: input.userId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
PlatformEvents.workspaceMemberAdded({
|
||||
workspaceId: input.workspaceId,
|
||||
addedBy: input.actorId,
|
||||
addedUserId: input.userId,
|
||||
role: input.permission,
|
||||
})
|
||||
} catch {
|
||||
/**
|
||||
* Telemetry must not fail the grant.
|
||||
*/
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
input.actorId,
|
||||
'workspace_member_added',
|
||||
{
|
||||
workspace_id: input.workspaceId,
|
||||
member_role: input.permission,
|
||||
},
|
||||
{
|
||||
groups: { workspace: input.workspaceId },
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: input.workspaceId,
|
||||
actorId: input.actorId,
|
||||
actorName: input.actorName,
|
||||
actorEmail: input.actorEmail,
|
||||
action: AuditAction.MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: input.workspaceId,
|
||||
resourceName: normalizedEmail,
|
||||
description: `Added existing organization member ${normalizedEmail} as ${input.permission}`,
|
||||
metadata: {
|
||||
targetEmail: normalizedEmail,
|
||||
targetRole: input.permission,
|
||||
organizationId: input.organizationId,
|
||||
workspaceName: input.workspaceName,
|
||||
addedUserId: input.userId,
|
||||
},
|
||||
request: input.request,
|
||||
})
|
||||
|
||||
if (input.notify ?? true) {
|
||||
try {
|
||||
await sendWorkspaceAddedEmail({
|
||||
email: normalizedEmail,
|
||||
inviterName: input.actorName,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceName: input.workspaceName,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to send workspace added email', {
|
||||
workspaceId: input.workspaceId,
|
||||
email: normalizedEmail,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
type InvitationKind,
|
||||
type InvitationMembershipIntent,
|
||||
invitation,
|
||||
invitationWorkspaceGrant,
|
||||
organization,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { and, eq, inArray, ne, sql } from 'drizzle-orm'
|
||||
import {
|
||||
getEmailSubject,
|
||||
renderBatchInvitationEmail,
|
||||
renderInvitationEmail,
|
||||
renderWorkspaceAddedEmail,
|
||||
renderWorkspaceInvitationEmail,
|
||||
} from '@/components/emails'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { computeInvitationExpiry } from '@/lib/invitations/core'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
const logger = createLogger('InvitationSend')
|
||||
|
||||
interface WorkspaceGrantInput {
|
||||
workspaceId: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export interface CreatePendingInvitationInput {
|
||||
kind: InvitationKind
|
||||
email: string
|
||||
inviterId: string
|
||||
organizationId: string | null
|
||||
membershipIntent?: InvitationMembershipIntent
|
||||
role: 'admin' | 'member'
|
||||
grants: WorkspaceGrantInput[]
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
export interface CreatePendingInvitationResult {
|
||||
invitationId: string
|
||||
token: string
|
||||
expiresAt: Date
|
||||
}
|
||||
|
||||
export async function createPendingInvitation(
|
||||
input: CreatePendingInvitationInput
|
||||
): Promise<CreatePendingInvitationResult> {
|
||||
const invitationId = generateId()
|
||||
const token = generateId()
|
||||
const expiresAt = input.expiresAt ?? computeInvitationExpiry()
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(invitation).values({
|
||||
id: invitationId,
|
||||
kind: input.kind,
|
||||
email: normalizeEmail(input.email),
|
||||
inviterId: input.inviterId,
|
||||
organizationId: input.organizationId,
|
||||
membershipIntent: input.membershipIntent ?? 'internal',
|
||||
role: input.role,
|
||||
status: 'pending',
|
||||
token,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
for (const grant of input.grants) {
|
||||
await tx.insert(invitationWorkspaceGrant).values({
|
||||
id: generateId(),
|
||||
invitationId,
|
||||
workspaceId: grant.workspaceId,
|
||||
permission: grant.permission,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { invitationId, token, expiresAt }
|
||||
}
|
||||
|
||||
async function countPendingInvitationsForOrganization(organizationId: string): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(invitation)
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.organizationId, organizationId),
|
||||
eq(invitation.status, 'pending'),
|
||||
ne(invitation.membershipIntent, 'external')
|
||||
)
|
||||
)
|
||||
return row?.count ?? 0
|
||||
}
|
||||
|
||||
async function findPendingInvitationByOrgEmail(params: {
|
||||
organizationId: string | null
|
||||
email: string
|
||||
}) {
|
||||
const normalized = normalizeEmail(params.email)
|
||||
|
||||
if (params.organizationId) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.organizationId, params.organizationId),
|
||||
eq(invitation.email, normalized),
|
||||
eq(invitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(
|
||||
and(
|
||||
sql`${invitation.organizationId} IS NULL`,
|
||||
eq(invitation.email, normalized),
|
||||
eq(invitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export async function findPendingGrantForWorkspaceEmail(params: {
|
||||
workspaceId: string
|
||||
email: string
|
||||
}) {
|
||||
const normalized = normalizeEmail(params.email)
|
||||
const [row] = await db
|
||||
.select({
|
||||
invitationId: invitation.id,
|
||||
grantId: invitationWorkspaceGrant.id,
|
||||
})
|
||||
.from(invitationWorkspaceGrant)
|
||||
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
|
||||
.where(
|
||||
and(
|
||||
eq(invitationWorkspaceGrant.workspaceId, params.workspaceId),
|
||||
eq(invitation.email, normalized),
|
||||
eq(invitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export async function cancelPendingInvitation(invitationId: string): Promise<void> {
|
||||
await db
|
||||
.update(invitation)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(and(eq(invitation.id, invitationId), eq(invitation.status, 'pending')))
|
||||
}
|
||||
|
||||
export interface SendInvitationEmailInput {
|
||||
invitationId: string
|
||||
token: string
|
||||
kind: InvitationKind
|
||||
email: string
|
||||
inviterName: string
|
||||
organizationId: string | null
|
||||
organizationRole: 'admin' | 'member'
|
||||
grants: WorkspaceGrantInput[]
|
||||
}
|
||||
|
||||
export interface SendInvitationEmailResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function sendInvitationEmail(
|
||||
input: SendInvitationEmailInput
|
||||
): Promise<SendInvitationEmailResult> {
|
||||
const inviteUrl = `${getBaseUrl()}/invite/${input.invitationId}?token=${input.token}`
|
||||
|
||||
if (input.kind === 'workspace') {
|
||||
if (input.grants.length === 0) {
|
||||
return { success: false, error: 'Workspace invitation is missing a workspace grant' }
|
||||
}
|
||||
|
||||
const grantWorkspaceIds = input.grants.map((grant) => grant.workspaceId)
|
||||
const workspaceRows = await db
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(inArray(workspace.id, grantWorkspaceIds))
|
||||
const workspaceNames = grantWorkspaceIds.map(
|
||||
(id) => workspaceRows.find((row) => row.id === id)?.name || 'a workspace'
|
||||
)
|
||||
|
||||
const emailHtml = await renderWorkspaceInvitationEmail(
|
||||
input.inviterName,
|
||||
workspaceNames,
|
||||
inviteUrl
|
||||
)
|
||||
|
||||
const brandName = getBrandConfig().name
|
||||
const subject =
|
||||
workspaceNames.length === 1
|
||||
? `You've been invited to join "${workspaceNames[0]}" on ${brandName}`
|
||||
: `You've been invited to join ${workspaceNames.length} workspaces on ${brandName}`
|
||||
|
||||
const result = await sendEmail({
|
||||
to: input.email,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
from: getFromEmailAddress(),
|
||||
emailType: 'transactional',
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (!input.organizationId) {
|
||||
return { success: false, error: 'Organization invitation missing organization id' }
|
||||
}
|
||||
|
||||
const [orgRow] = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, input.organizationId))
|
||||
.limit(1)
|
||||
const organizationName = orgRow?.name || 'organization'
|
||||
|
||||
if (input.grants.length > 0) {
|
||||
const workspaceIds = input.grants.map((grant) => grant.workspaceId)
|
||||
const workspaceRows = await db
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(inArray(workspace.id, workspaceIds))
|
||||
|
||||
const grantPayloads = input.grants.map((grant) => ({
|
||||
workspaceId: grant.workspaceId,
|
||||
workspaceName:
|
||||
workspaceRows.find((row) => row.id === grant.workspaceId)?.name || 'Unknown Workspace',
|
||||
permission: grant.permission,
|
||||
}))
|
||||
|
||||
const emailHtml = await renderBatchInvitationEmail(
|
||||
input.inviterName,
|
||||
organizationName,
|
||||
input.organizationRole,
|
||||
grantPayloads,
|
||||
inviteUrl
|
||||
)
|
||||
|
||||
const result = await sendEmail({
|
||||
to: input.email,
|
||||
subject: getEmailSubject('batch-invitation'),
|
||||
html: emailHtml,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const emailHtml = await renderInvitationEmail(input.inviterName, organizationName, inviteUrl)
|
||||
const result = await sendEmail({
|
||||
to: input.email,
|
||||
subject: getEmailSubject('invitation'),
|
||||
html: emailHtml,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export interface SendWorkspaceAddedEmailInput {
|
||||
email: string
|
||||
inviterName: string
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight notification sent when an existing organization member is added
|
||||
* directly to a workspace. Unlike an invitation email, this links straight to
|
||||
* the workspace and has no acceptance step.
|
||||
*/
|
||||
export async function sendWorkspaceAddedEmail(
|
||||
input: SendWorkspaceAddedEmailInput
|
||||
): Promise<SendInvitationEmailResult> {
|
||||
const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}/home`
|
||||
const emailHtml = await renderWorkspaceAddedEmail(
|
||||
input.inviterName,
|
||||
input.workspaceName,
|
||||
workspaceLink
|
||||
)
|
||||
|
||||
const result = await sendEmail({
|
||||
to: input.email,
|
||||
subject: getEmailSubject('workspace-added'),
|
||||
html: emailHtml,
|
||||
from: getFromEmailAddress(),
|
||||
emailType: 'transactional',
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function prepareInvitationResend(params: {
|
||||
invitationId: string
|
||||
rotateToken?: boolean
|
||||
currentToken: string
|
||||
}): Promise<{ tokenForEmail: string; nextExpiresAt: Date; nextToken: string | null }> {
|
||||
const nextExpiresAt = computeInvitationExpiry()
|
||||
const nextToken = params.rotateToken ? generateId() : null
|
||||
const tokenForEmail = nextToken ?? params.currentToken
|
||||
return { tokenForEmail, nextExpiresAt, nextToken }
|
||||
}
|
||||
|
||||
export async function persistInvitationResend(params: {
|
||||
invitationId: string
|
||||
nextToken: string | null
|
||||
nextExpiresAt: Date
|
||||
}): Promise<void> {
|
||||
const [row] = await db
|
||||
.update(invitation)
|
||||
.set({
|
||||
expiresAt: params.nextExpiresAt,
|
||||
updatedAt: new Date(),
|
||||
...(params.nextToken ? { token: params.nextToken } : {}),
|
||||
})
|
||||
.where(and(eq(invitation.id, params.invitationId), eq(invitation.status, 'pending')))
|
||||
.returning({ id: invitation.id })
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Invitation ${params.invitationId} not found or no longer pending`)
|
||||
}
|
||||
|
||||
logger.info('Persisted invitation resend', {
|
||||
invitationId: params.invitationId,
|
||||
rotated: !!params.nextToken,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
createMockRequest,
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
resetDbChainMock,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetUserOrganization,
|
||||
mockValidateSeatAvailability,
|
||||
mockGrantWorkspaceAccessDirectly,
|
||||
mockCreatePendingInvitation,
|
||||
mockSendInvitationEmail,
|
||||
mockCancelPendingInvitation,
|
||||
mockFindPendingGrantForWorkspaceEmail,
|
||||
mockWorkspaceMemberInvited,
|
||||
mockCaptureServerEvent,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetUserOrganization: vi.fn(),
|
||||
mockValidateSeatAvailability: vi.fn(),
|
||||
mockGrantWorkspaceAccessDirectly: vi.fn(),
|
||||
mockCreatePendingInvitation: vi.fn(),
|
||||
mockSendInvitationEmail: vi.fn(),
|
||||
mockCancelPendingInvitation: vi.fn(),
|
||||
mockFindPendingGrantForWorkspaceEmail: vi.fn(),
|
||||
mockWorkspaceMemberInvited: vi.fn(),
|
||||
mockCaptureServerEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/billing/organizations/membership', () => ({
|
||||
getUserOrganization: mockGetUserOrganization,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/validation/seat-management', () => ({
|
||||
validateSeatAvailability: mockValidateSeatAvailability,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/telemetry', () => ({
|
||||
PlatformEvents: { workspaceMemberInvited: mockWorkspaceMemberInvited },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invitations/direct-grant', () => ({
|
||||
grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invitations/send', () => ({
|
||||
createPendingInvitation: mockCreatePendingInvitation,
|
||||
sendInvitationEmail: mockSendInvitationEmail,
|
||||
cancelPendingInvitation: mockCancelPendingInvitation,
|
||||
findPendingGrantForWorkspaceEmail: mockFindPendingGrantForWorkspaceEmail,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/posthog/server', () => ({
|
||||
captureServerEvent: mockCaptureServerEvent,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getWorkspaceWithOwner: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/policy', () => ({
|
||||
getWorkspaceInvitePolicy: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/access-control/utils/permission-check', () => ({
|
||||
validateInvitationsAllowed: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createWorkspaceInvitation } from '@/lib/invitations/workspace-invitations'
|
||||
|
||||
function queueWhereResponses(responses: unknown[][]) {
|
||||
const queue = [...responses]
|
||||
dbChainMockFns.where.mockImplementation(() => {
|
||||
const result = queue.shift() ?? []
|
||||
const thenable = Promise.resolve(result) as Promise<unknown[]> & {
|
||||
limit: ReturnType<typeof vi.fn>
|
||||
}
|
||||
thenable.limit = vi.fn(() => Promise.resolve(result))
|
||||
return thenable as ReturnType<typeof dbChainMockFns.where>
|
||||
})
|
||||
}
|
||||
|
||||
function makeContext() {
|
||||
return {
|
||||
workspaceId: 'ws-1',
|
||||
inviterId: 'user-1',
|
||||
inviterName: 'Owner',
|
||||
inviterEmail: 'owner@example.com',
|
||||
workspaceDetails: {
|
||||
id: 'ws-1',
|
||||
name: 'Workspace 1',
|
||||
ownerId: 'user-1',
|
||||
organizationId: 'org-1',
|
||||
billedAccountUserId: 'user-1',
|
||||
},
|
||||
invitePolicy: {
|
||||
allowed: true,
|
||||
reason: null,
|
||||
requiresSeat: false,
|
||||
organizationId: 'org-1',
|
||||
upgradeRequired: false,
|
||||
},
|
||||
// The function only reads the fields above at runtime.
|
||||
} as Parameters<typeof createWorkspaceInvitation>[0]['context']
|
||||
}
|
||||
|
||||
const request = createMockRequest(
|
||||
'POST',
|
||||
{},
|
||||
{},
|
||||
'http://localhost/api/workspaces/invitations/batch'
|
||||
)
|
||||
|
||||
describe('createWorkspaceInvitation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' })
|
||||
mockCreatePendingInvitation.mockResolvedValue({ invitationId: 'inv-1', token: 'tok-1' })
|
||||
mockSendInvitationEmail.mockResolvedValue({ success: true })
|
||||
mockFindPendingGrantForWorkspaceEmail.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('directly grants access to an existing member of the workspace organization', async () => {
|
||||
queueWhereResponses([[{ id: 'user-2', email: 'member@example.com' }], []])
|
||||
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' })
|
||||
|
||||
const result = await createWorkspaceInvitation({
|
||||
context: makeContext(),
|
||||
email: 'member@example.com',
|
||||
permission: 'write',
|
||||
request,
|
||||
})
|
||||
|
||||
expect(result.instantAdd).toBe(true)
|
||||
expect(result.outcome).toBe('added')
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-2',
|
||||
workspaceId: 'ws-1',
|
||||
permission: 'write',
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
)
|
||||
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
|
||||
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an existing workspace member without upgrading their permission', async () => {
|
||||
queueWhereResponses([
|
||||
[{ id: 'user-2', email: 'member@example.com' }],
|
||||
[{ id: 'perm-1', permissionType: 'read' }],
|
||||
])
|
||||
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-1', role: 'member' })
|
||||
|
||||
await expect(
|
||||
createWorkspaceInvitation({
|
||||
context: makeContext(),
|
||||
email: 'member@example.com',
|
||||
permission: 'admin',
|
||||
request,
|
||||
})
|
||||
).rejects.toThrow('already has access')
|
||||
|
||||
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
|
||||
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates an external pending invitation when the user belongs to a different org', async () => {
|
||||
queueWhereResponses([[{ id: 'user-3', email: 'ext@example.com' }], []])
|
||||
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' })
|
||||
|
||||
const result = await createWorkspaceInvitation({
|
||||
context: makeContext(),
|
||||
email: 'ext@example.com',
|
||||
permission: 'read',
|
||||
request,
|
||||
})
|
||||
|
||||
expect(result.instantAdd).toBeFalsy()
|
||||
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'workspace', membershipIntent: 'external' })
|
||||
)
|
||||
expect(mockSendInvitationEmail).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates an internal pending invitation when the registered user has no org', async () => {
|
||||
queueWhereResponses([[{ id: 'user-4', email: 'noorg@example.com' }], []])
|
||||
mockGetUserOrganization.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await createWorkspaceInvitation({
|
||||
context: makeContext(),
|
||||
email: 'noorg@example.com',
|
||||
permission: 'write',
|
||||
request,
|
||||
})
|
||||
|
||||
expect(result.instantAdd).toBeFalsy()
|
||||
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'workspace', membershipIntent: 'internal' })
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a pending invitation for a brand-new email', async () => {
|
||||
queueWhereResponses([[]])
|
||||
|
||||
const result = await createWorkspaceInvitation({
|
||||
context: makeContext(),
|
||||
email: 'new@example.com',
|
||||
permission: 'read',
|
||||
request,
|
||||
})
|
||||
|
||||
expect(result.instantAdd).toBeFalsy()
|
||||
expect(mockGetUserOrganization).not.toHaveBeenCalled()
|
||||
expect(mockGrantWorkspaceAccessDirectly).not.toHaveBeenCalled()
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'workspace', membershipIntent: 'internal' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { type InvitationMembershipIntent, permissions, user } from '@sim/db/schema'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getUserOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import {
|
||||
type DirectGrantOutcome,
|
||||
grantWorkspaceAccessDirectly,
|
||||
} from '@/lib/invitations/direct-grant'
|
||||
import {
|
||||
cancelPendingInvitation,
|
||||
createPendingInvitation,
|
||||
findPendingGrantForWorkspaceEmail,
|
||||
sendInvitationEmail,
|
||||
} from '@/lib/invitations/send'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
getWorkspaceWithOwner,
|
||||
hasWorkspaceAdminAccess,
|
||||
type PermissionType,
|
||||
type WorkspaceWithOwner,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceInvitePolicy, type WorkspaceInvitePolicy } from '@/lib/workspaces/policy'
|
||||
import { validateInvitationsAllowed } from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
export interface WorkspaceInvitationContext {
|
||||
workspaceId: string
|
||||
inviterId: string
|
||||
inviterName: string
|
||||
inviterEmail?: string | null
|
||||
workspaceDetails: WorkspaceWithOwner
|
||||
invitePolicy: WorkspaceInvitePolicy
|
||||
}
|
||||
|
||||
export interface WorkspaceInvitationResult {
|
||||
id: string
|
||||
workspaceId: string
|
||||
email: string
|
||||
permission: PermissionType
|
||||
membershipIntent: InvitationMembershipIntent
|
||||
expiresAt: Date | undefined
|
||||
/** True when the user was granted access directly (no pending invitation). */
|
||||
instantAdd?: boolean
|
||||
/** Direct-grant outcome when `instantAdd` is true. */
|
||||
outcome?: DirectGrantOutcome['outcome']
|
||||
}
|
||||
|
||||
export class WorkspaceInvitationError extends Error {
|
||||
status: number
|
||||
email?: string
|
||||
upgradeRequired?: boolean
|
||||
|
||||
constructor({
|
||||
message,
|
||||
status,
|
||||
email,
|
||||
upgradeRequired,
|
||||
}: {
|
||||
message: string
|
||||
status: number
|
||||
email?: string
|
||||
upgradeRequired?: boolean
|
||||
}) {
|
||||
super(message)
|
||||
this.name = 'WorkspaceInvitationError'
|
||||
this.status = status
|
||||
this.email = email
|
||||
this.upgradeRequired = upgradeRequired
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareWorkspaceInvitationContext({
|
||||
workspaceId,
|
||||
inviterId,
|
||||
inviterName,
|
||||
inviterEmail,
|
||||
}: {
|
||||
workspaceId: string
|
||||
inviterId: string
|
||||
inviterName: string
|
||||
inviterEmail?: string | null
|
||||
}): Promise<WorkspaceInvitationContext> {
|
||||
await validateInvitationsAllowed(inviterId, workspaceId)
|
||||
|
||||
const isAdmin = await hasWorkspaceAdminAccess(inviterId, workspaceId)
|
||||
if (!isAdmin) {
|
||||
throw new WorkspaceInvitationError({
|
||||
message: 'You need admin permissions to invite users',
|
||||
status: 403,
|
||||
})
|
||||
}
|
||||
|
||||
const workspaceDetails = await getWorkspaceWithOwner(workspaceId)
|
||||
if (!workspaceDetails) {
|
||||
throw new WorkspaceInvitationError({ message: 'Workspace not found', status: 404 })
|
||||
}
|
||||
|
||||
const invitePolicy = await getWorkspaceInvitePolicy(workspaceDetails)
|
||||
if (!invitePolicy.allowed) {
|
||||
throw new WorkspaceInvitationError({
|
||||
message: invitePolicy.reason ?? 'Invites are disabled for this workspace.',
|
||||
status: 403,
|
||||
upgradeRequired: invitePolicy.upgradeRequired,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
inviterId,
|
||||
inviterName,
|
||||
inviterEmail,
|
||||
workspaceDetails,
|
||||
invitePolicy,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkspaceInvitation({
|
||||
context,
|
||||
email,
|
||||
permission = 'read',
|
||||
request,
|
||||
}: {
|
||||
context: WorkspaceInvitationContext
|
||||
email: string
|
||||
permission?: string
|
||||
request: NextRequest
|
||||
}): Promise<WorkspaceInvitationResult> {
|
||||
const validPermissions: PermissionType[] = ['admin', 'write', 'read']
|
||||
if (!validPermissions.includes(permission as PermissionType)) {
|
||||
throw new WorkspaceInvitationError({
|
||||
message: `Invalid permission: must be one of ${validPermissions.join(', ')}`,
|
||||
status: 400,
|
||||
email,
|
||||
})
|
||||
}
|
||||
const invitationPermission = permission as PermissionType
|
||||
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
let membershipIntent: InvitationMembershipIntent = 'internal'
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(sql`lower(${user.email}) = ${normalizedEmail}`)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingUser) {
|
||||
const workspaceOrganizationId = context.workspaceDetails.organizationId
|
||||
const existingMembership = workspaceOrganizationId
|
||||
? await getUserOrganization(existingUser.id)
|
||||
: null
|
||||
|
||||
const existingPermission = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, context.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, existingUser.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
/**
|
||||
* Already a workspace member: reject. Invites never change an existing
|
||||
* member's permission — role changes go through the members list, not the
|
||||
* invite flow. (The client also blocks re-inviting current teammates.)
|
||||
*/
|
||||
if (existingPermission) {
|
||||
throw new WorkspaceInvitationError({
|
||||
message: `${normalizedEmail} already has access to this workspace`,
|
||||
status: 400,
|
||||
email: normalizedEmail,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitee already belongs to the workspace's organization (and is not yet a
|
||||
* member of this workspace): grant access directly, with no invitation or
|
||||
* acceptance step.
|
||||
*/
|
||||
if (
|
||||
workspaceOrganizationId &&
|
||||
existingMembership &&
|
||||
existingMembership.organizationId === workspaceOrganizationId
|
||||
) {
|
||||
const directGrant = await grantWorkspaceAccessDirectly({
|
||||
userId: existingUser.id,
|
||||
email: normalizedEmail,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceName: context.workspaceDetails.name,
|
||||
permission: invitationPermission,
|
||||
organizationId: workspaceOrganizationId,
|
||||
actorId: context.inviterId,
|
||||
actorName: context.inviterName,
|
||||
actorEmail: context.inviterEmail,
|
||||
request,
|
||||
})
|
||||
|
||||
return {
|
||||
id: existingUser.id,
|
||||
workspaceId: context.workspaceId,
|
||||
email: normalizedEmail,
|
||||
permission: invitationPermission,
|
||||
membershipIntent: 'internal',
|
||||
expiresAt: undefined,
|
||||
instantAdd: true,
|
||||
outcome: directGrant.outcome,
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaceOrganizationId) {
|
||||
if (existingMembership && existingMembership.organizationId !== workspaceOrganizationId) {
|
||||
membershipIntent = 'external'
|
||||
} else if (context.invitePolicy.requiresSeat && !existingMembership) {
|
||||
const seatValidation = await validateSeatAvailability(workspaceOrganizationId, 1)
|
||||
if (!seatValidation.canInvite) {
|
||||
throw new WorkspaceInvitationError({
|
||||
message: seatValidation.reason || 'No available seats for this organization.',
|
||||
status: 400,
|
||||
email: normalizedEmail,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (context.invitePolicy.requiresSeat && context.invitePolicy.organizationId) {
|
||||
const seatValidation = await validateSeatAvailability(context.invitePolicy.organizationId, 1)
|
||||
if (!seatValidation.canInvite) {
|
||||
throw new WorkspaceInvitationError({
|
||||
message: seatValidation.reason || 'No available seats for this organization.',
|
||||
status: 400,
|
||||
email: normalizedEmail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const existingInvitation = await findPendingGrantForWorkspaceEmail({
|
||||
workspaceId: context.workspaceId,
|
||||
email: normalizedEmail,
|
||||
})
|
||||
if (existingInvitation) {
|
||||
throw new WorkspaceInvitationError({
|
||||
message: `${normalizedEmail} has already been invited to this workspace`,
|
||||
status: 400,
|
||||
email: normalizedEmail,
|
||||
})
|
||||
}
|
||||
|
||||
const { invitationId, token } = await createPendingInvitation({
|
||||
kind: 'workspace',
|
||||
email: normalizedEmail,
|
||||
inviterId: context.inviterId,
|
||||
organizationId: context.workspaceDetails.organizationId,
|
||||
membershipIntent,
|
||||
role: 'member',
|
||||
grants: [
|
||||
{
|
||||
workspaceId: context.workspaceId,
|
||||
permission: invitationPermission,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
try {
|
||||
PlatformEvents.workspaceMemberInvited({
|
||||
workspaceId: context.workspaceId,
|
||||
invitedBy: context.inviterId,
|
||||
inviteeEmail: normalizedEmail,
|
||||
role: invitationPermission,
|
||||
membershipIntent,
|
||||
})
|
||||
} catch {
|
||||
/**
|
||||
* Telemetry must not fail invitation creation.
|
||||
*/
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
context.inviterId,
|
||||
'workspace_member_invited',
|
||||
{
|
||||
workspace_id: context.workspaceId,
|
||||
invitee_role: invitationPermission,
|
||||
membership_intent: membershipIntent,
|
||||
},
|
||||
{
|
||||
groups: { workspace: context.workspaceId },
|
||||
setOnce: { first_invitation_sent_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
const emailResult = await sendInvitationEmail({
|
||||
invitationId,
|
||||
token,
|
||||
kind: 'workspace',
|
||||
email: normalizedEmail,
|
||||
inviterName: context.inviterName,
|
||||
organizationId: context.workspaceDetails.organizationId,
|
||||
organizationRole: 'member',
|
||||
grants: [{ workspaceId: context.workspaceId, permission: invitationPermission }],
|
||||
})
|
||||
|
||||
if (!emailResult.success) {
|
||||
await cancelPendingInvitation(invitationId)
|
||||
throw new WorkspaceInvitationError({
|
||||
message: emailResult.error || 'Failed to send invitation email',
|
||||
status: 502,
|
||||
email: normalizedEmail,
|
||||
})
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: context.workspaceId,
|
||||
actorId: context.inviterId,
|
||||
actorName: context.inviterName,
|
||||
actorEmail: context.inviterEmail,
|
||||
action: AuditAction.MEMBER_INVITED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: context.workspaceId,
|
||||
resourceName: normalizedEmail,
|
||||
description: `Invited ${normalizedEmail} as ${invitationPermission}`,
|
||||
metadata: {
|
||||
targetEmail: normalizedEmail,
|
||||
targetRole: invitationPermission,
|
||||
membershipIntent,
|
||||
workspaceName: context.workspaceDetails.name,
|
||||
invitationId,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return {
|
||||
id: invitationId,
|
||||
workspaceId: context.workspaceId,
|
||||
email: normalizedEmail,
|
||||
permission: invitationPermission,
|
||||
membershipIntent,
|
||||
expiresAt: undefined,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user